Przejdź do treści

Testowanie maili w testach automatycznych i CI – rejestracja i reset hasła

Ostatnia aktualizacja: 26:10:2026

English version

Czego nie powie Ci atrapa wysyłki

Większość zestawów testów zastępuje wysyłkę maili atrapą i sprawdza, czy została wywołana z właściwym odbiorcą i szablonem. To dobry test jednostkowy i warto go mieć. Ale procesy zależne od maila – potwierdzenie rejestracji, reset hasła, logowanie linkiem, zaproszenia – psują się w sposób, którego atrapa nigdy nie zobaczy:

  • link prowadzi do localhost albo starej domeny, bo adres bazowy jest na środowisku testowym ustawiony inaczej,
  • token w linku jest podwójnie zakodowany, ucięty albo wygasa, zanim mail dotrze,
  • proces w tle, który wysyła maile, nie działa na wdrożonym środowisku,
  • dane logowania SMTP albo klucz API na tym środowisku są błędne lub unieważnione,
  • szablon renderuje się na komputerze programisty, ale wywraca na danych produkcyjnych (brak imienia, długa nazwa produktu),
  • wiadomość wychodzi, ale zostaje odrzucona albo trafia do spamu, bo domena nadawcy nie jest uwierzytelniona.

Każdy z tych błędów dociera do użytkownika jako „nie dostałem maila”. Test end-to-end, który czeka na prawdziwą wiadomość i klika prawdziwy link, wyłapie je wszystkie – dlatego kluczowe procesy mailowe zasługują przynajmniej na jeden taki test.

Trzy poziomy testowania maili

PoziomCo odbiera pocztęDo czego się nadajeCzego nie sprawdzi
Test jednostkowyatrapa obiektu wysyłającegologika: kto dostaje jaki mail i z jakimi danymiwszystkiego po send()
Test integracyjnylokalny serwer SMTP do testów (np. Mailpit)renderowanie, linki i nagłówki – offline i szybkoprawdziwego doręczenia, uwierzytelnienia, filtrów spamu
Test end-to-endprawdziwa skrzynka w prawdziwej domeniecałej ścieżki, od której zależy użytkownikniewiele – ale jest wolniejszy i wymaga danych dostępowych
Test jednostkowy
Co odbiera pocztę: atrapa obiektu wysyłającego
Do czego się nadaje: logika: kto dostaje jaki mail i z jakimi danymi
Czego nie sprawdzi: wszystkiego po send()
Test integracyjny
Co odbiera pocztę: lokalny serwer SMTP do testów (np. Mailpit)
Do czego się nadaje: renderowanie, linki i nagłówki – offline i szybko
Czego nie sprawdzi: prawdziwego doręczenia, uwierzytelnienia, filtrów spamu
Test end-to-end
Co odbiera pocztę: prawdziwa skrzynka w prawdziwej domenie
Do czego się nadaje: całej ścieżki, od której zależy użytkownik
Czego nie sprawdzi: niewiele – ale jest wolniejszy i wymaga danych dostępowych

Korzystaj ze wszystkich trzech w odpowiednich proporcjach: dużo testów jednostkowych, testy integracyjne dla każdego szablonu i kilka testów end-to-end dla procesów, których awaria odcina użytkowników od konta.

Schemat: wywołaj, poczekaj, wyciągnij, sprawdź

  1. Zapisz czas, a potem wywołaj wysyłkę tak, jak zrobiłby to użytkownik – wypełnij formularz rejestracji, poproś o reset hasła.
  2. Poczekaj na wiadomość w skrzynce testowej, odpytując ją, aż wiadomość dotrze albo minie limit czasu.
  3. Wyciągnij to, czego użyłby użytkownik: link weryfikacyjny, jednorazowy kod.
  4. Wykonaj i sprawdź: otwórz link, wpisz kod i upewnij się, że aplikacja jest we właściwym stanie.

W AnyMail skrzynka testowa to zwykła skrzynka w Twojej domenie, a każde z oficjalnych SDK ma wywołanie waitForMessage stworzone właśnie do tego kroku. Oto test Playwrighta w TypeScripcie:

TypeScript + Playwright: potwierdzenie rejestracji
import { expect, test } from '@playwright/test';
import { Anymail } from '@anymail/sdk';

test.beforeAll(async () => {
  await Anymail.connect({ apiKey: process.env.ANYMAIL_API_KEY! });
});

/** The first link in an email body that contains `path` - works for text and HTML bodies. */
function findLink(body: string, path: string): string {
  const links = [...body.matchAll(/https?:\/\/[^\s"'<>]+/g)].map((match) => match[0].replaceAll('&amp;', '&'));
  const link = links.find((candidate) => candidate.includes(path));
  if (!link) throw new Error(`No link containing ${path} in the email`);
  return link;
}

test('a new account can verify its email address', async ({ page }, testInfo) => {
  // One inbox per parallel worker, so two workers never read each other's mail.
  const inbox = `e2e-${testInfo.parallelIndex}@example.com`;
  const runId = crypto.randomUUID().slice(0, 8);
  const startedAt = new Date(Date.now() - 10_000); // tolerate a few seconds of clock skew
  await deleteTestUser(inbox); // your app's own helper: remove an account left over from an earlier run

  await page.goto('/register');
  await page.getByLabel('Name').fill(`E2E ${runId}`);
  await page.getByLabel('Email').fill(inbox);
  await page.getByLabel('Password').fill(`pw-${runId}-${Date.now()}`);
  await page.getByRole('button', { name: 'Create account' }).click();

  const mail = await Anymail.waitForMessage({
    inbox,
    subject: 'Verify your email',
    contains: runId, // the greeting includes the name we registered with
    since: startedAt,
    timeoutMs: 60_000
  });

  await page.goto(findLink(mail.body, '/verify-email'));
  await expect(page.getByText('Your email address is verified')).toBeVisible();

  await mail.moveToTrash();
});

Ten sam pomysł dla resetu hasła – w Pythonie, z pytestem i Playwrightem:

Python + pytest: reset hasła
import re
from datetime import datetime, timedelta, timezone

import pytest
from anymail_sdk import Anymail
from playwright.sync_api import Page, expect

APP_URL = "https://staging.example.com"
INBOX = "e2e-reset@example.com"
RESET_LINK = re.compile(r"https://[^\s\"'<>]+/reset-password[^\s\"'<>]*")


@pytest.fixture(scope="session", autouse=True)
def anymail():
    return Anymail.connect()  # ANYMAIL_API_KEY comes from a CI secret


def test_password_reset_email(page: Page):
    started_at = datetime.now(timezone.utc) - timedelta(seconds=10)

    page.goto(f"{APP_URL}/forgot-password")
    page.get_by_label("Email").fill(INBOX)
    page.get_by_role("button", name="Send reset link").click()

    mail = Anymail.wait_for_message(inbox=INBOX, subject="Reset your password", since=started_at, timeout=60)
    match = RESET_LINK.search(mail.body)
    assert match, f"no reset link in: {mail.body}"

    page.goto(match.group().replace("&amp;", "&"))  # HTML bodies escape &
    page.get_by_label("New password").fill("correct-horse-battery-staple")
    page.get_by_role("button", name="Set new password").click()
    expect(page.get_by_text("Your password has been changed")).to_be_visible()

    mail.move_to_trash()

I w Go – dla testu backendu, który wywołuje wysyłkę przez Twoje API zamiast przez przeglądarkę:

Go: link do resetu hasła
var resetLink = regexp.MustCompile(`https://[^\s"'<>]+/reset-password[^\s"'<>]*`)

func TestPasswordResetEmail(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
	defer cancel()

	mail, err := anymailsdk.Connect(ctx, anymailsdk.Options{APIKey: os.Getenv("ANYMAIL_API_KEY")})
	if err != nil {
		t.Fatal(err)
	}
	const inbox = "e2e-reset@example.com"
	startedAt := time.Now().Add(-10 * time.Second)

	if err := triggerPasswordReset(inbox); err != nil { // your app, e.g. POST /forgot-password
		t.Fatal(err)
	}

	msg, err := mail.WaitForMessage(ctx, anymailsdk.WaitOptions{
		Inbox:   inbox,
		Subject: "Reset your password",
		Since:   startedAt,
		Timeout: time.Minute,
	})
	if errors.Is(err, anymailsdk.ErrWaitTimeout) {
		t.Fatal("no password reset email arrived within a minute")
	}
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = msg.MoveToTrash(context.Background()) })

	link := strings.ReplaceAll(resetLink.FindString(msg.Body), "&amp;", "&") // HTML bodies escape &
	if link == "" {
		t.Fatalf("no reset link in: %s", msg.Body)
	}
	resp, err := http.Get(link)
	if err != nil {
		t.Fatal(err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("reset link returned %d", resp.StatusCode)
	}
}

Wszystkie trzy przykłady odczytują klucz API ze zmiennej środowiskowej, która w CI jest sekretem. Dostęp do API jest częścią planu Pro w AnyMail; dokumentacja API opisuje każde wywołanie, z którego korzystają SDK.

Jak poprawnie czekać

Odpytuj z limitem czasu, nie usypiaj

sleep(10), a potem „przeczytaj skrzynkę” to klasyczne źródło niestabilnych testów maili: w gorszy dzień za krótko, w lepszy – stracony czas. Zamiast tego odpytuj skrzynkę i kończ, gdy tylko wiadomość się pojawi. waitForMessage domyślnie sprawdza skrzynkę co dwie sekundy, zwalnia, gdy API ogranicza liczbę żądań, a po upływie limitu kończy się czytelnym błędem (WaitTimeoutError w TypeScripcie i Pythonie, ErrWaitTimeout w Go).

Nie przegap szybkiego maila

Domyślnie waitForMessage przyjmuje tylko wiadomości, które dotrą po jego uruchomieniu, więc nie złapie przez pomyłkę wczorajszego maila. To jednak tworzy wyścig: jeśli aplikacja jest szybka i mail dotrze, zanim zacznie się czekanie, zostanie pominięty. Przykłady unikają tego, zapisując czas przed wywołaniem wysyłki i przekazując go jako since. Odejmij kilka sekund, żeby uwzględnić różnicę między zegarem maszyny CI a zegarem serwera.

Jaki limit czasu wybrać

Poczta między dwiema skrzynkami u tego samego dostawcy dociera w kilka sekund; poczta przechodząca przez internet zwykle też, ale nie zawsze. Jedną z przyczyn kilkuminutowych opóźnień jest greylisting: serwer odbiorcy celowo odrzuca pierwszą wiadomość z nieznanej sieci nadawcy i przyjmuje ponowienie kilka minut później. AnyMail stosuje greylisting dla poczty przychodzącej z internetu – gdy aplikacja wysyła przez innego dostawcę do skrzynki testowej w AnyMail, pierwsza wiadomość z sieci tego dostawcy do tej skrzynki zostanie przyjęta dopiero przy ponowieniu, co najmniej pięć minut później. Potem ta para jest zapamiętana, a sieć, która przeszła kontrolę kilka razy, w ogóle ją pomija. Poczta, którą aplikacja wysyła przez samo AnyMail do skrzynki w AnyMail, jest doręczana wewnętrznie i nigdy nie podlega greylistingowi.

Sześćdziesiąt sekund oczekiwania to rozsądna wartość domyślna dla procesów, których poczta jest doręczana wewnętrznie albo ze znanej już sieci. Przy zupełnie nowej konfiguracji uruchom test raz z długim limitem, żeby przejść przez greylisting, zamiast na stałe zwiększać wszystkie limity. Własny limit czasu frameworka testowego ustaw wyraźnie powyżej czasu oczekiwania, żeby przekroczenie zgłaszało się jako „mail nie dotarł”, a nie jako ogólny błąd testu.

Dopasowanie właściwej wiadomości

Wspólna skrzynka testowa dostaje pocztę z każdego przebiegu, gałęzi i od każdego programisty. Filtruj tak dokładnie, jak się da:

  • Temat i nadawca (subject, from) zawężają wybór do właściwego rodzaju maila.
  • Coś unikalnego dla przebiegu (contains) usuwa wszelkie wątpliwości. Test rejestracji podaje imię z losowym identyfikatorem przebiegu, a powitanie w mailu zawiera to imię. Jeśli maile nie zawierają niczego, co wpisuje użytkownik, dopasuj wiadomość predykatem (match w TypeScripcie i Pythonie, Match w Go) – na przykład po tokenie odczytanym z testowej bazy danych.
  • Czas rozpoczęcia (since) wyklucza wszystko, co starsze.

Wyciąganie linków i kodów

Czytaj tę treść, którą czytają użytkownicy. Wiele aplikacji wysyła HTML z alternatywną wersją tekstową; AnyMail zachowuje część HTML, jeśli jest dostępna, więc przeszukuj pole body wiadomości zamiast pola z samym tekstem i w linkach wziętych z HTML zamieniaj &amp; z powrotem na & – link z zakodowanym ampersandem traci drugi parametr. Wąski wzorzec ścieżki, której się spodziewasz, jest pewniejszy niż „pierwszy link w mailu”, którym często okazuje się logo.

Wzorce, które zwykle wystarczają

kod jednorazowy:   \b\d{6}\b
link weryfikacyjny: https://[^\s"'<>]+/verify-email[^\s"'<>]*

Izolacja między testami i przebiegami

AnyMail doręcza pocztę tylko do utworzonych przez Ciebie skrzynek i ich aliasów (do 10 na skrzynkę, dodawanych w panelu Aliases skrzynki), więc nie wygenerujesz nowego adresu dla każdego testu tak, jak pozwalają na to adresy z plusem czy skrzynka catch-all. Zaplanuj izolację wokół kilku stałych skrzynek testowych:

  • Jedna skrzynka na równoległy proces. Utwórz e2e-0@ do e2e-3@ dla czterech procesów i wybieraj skrzynkę po numerze procesu (w Playwright – testInfo.parallelIndex), żeby dwa testy nigdy nie czekały jednocześnie w tej samej skrzynce.
  • Przywracaj stan przed testem, nie po nim. Test rejestracji potrzebuje wolnego adresu, więc na początku usuń konto testowe pozostałe po wcześniejszym przebiegu. Sprzątanie na końcu zostaje pominięte właśnie wtedy, gdy test się wywróci.
  • Porządkuj skrzynkę. Przeniesienie wiadomości do Kosza po teście sprawia, że skrzynka jest mała i szybko się ją przeszukuje; Kosz opróżnia się sam po upływie okresu przechowywania.
  • Oddziel pocztę testową od prawdziwej. Klucz API ma dostęp do każdej skrzynki na koncie, do którego należy, więc klucz zapisany w CI nie powinien móc czytać Twoich produkcyjnych skrzynek. Jeśli możesz, umieść skrzynki testowe na osobnym koncie, a przynajmniej na osobnym serwerze pocztowym i w domenie testowej.

Niezawodność w CI

  • Uruchamiaj testy end-to-end maili na środowisku, które wysyła prawdziwą pocztę – testowym albo produkcyjnym z kontami testowymi.
  • Przy błędzie zapisz w logu, co się da: skrzynkę, przedział czasu, a jeśli wiadomość dotarła, ale nie pasowała – jej temat i Message-ID. Tak „niestabilny test” zamienia się w diagnozę.
  • Pozwól najwyżej na jedno automatyczne ponowienie. Test, który przechodzi dopiero za trzecim razem, mówi Ci coś o procesie mailowym.
  • Jeśli poczta dociera, ale u prawdziwych dostawców ląduje w spamie, skrzynka testowa tego nie pokaże – sprawdzaj doręczalność osobno, na podstawie nagłówków wiadomości wysłanej do Gmaila lub Outlook.com.

Lokalne serwery SMTP do testów, takie jak Mailpit

Do pracy lokalnej i testów integracyjnych lepszym narzędziem jest często lokalny serwer SMTP, który przechwytuje pocztę. Mailpit działa na przykład jako pojedynczy plik wykonywalny albo kontener Dockera (axllent/mailpit), przyjmuje pocztę przez SMTP na porcie 1025 i pokazuje ją w interfejsie WWW na porcie 8025. Wystarczy ustawić go jako serwer SMTP aplikacji, a nic nie opuści Twojego komputera. Ma REST API do testów integracyjnych, potrafi sprawdzić linki i zgodność HTML z programami pocztowymi, ocenić „spamowatość” wiadomości przez serwer SpamAssassin i ma tryb „chaos”, który odpowiada błędami SMTP, żebyś mógł sprawdzić, jak radzi sobie z nimi aplikacja.

Lokalny serwer (Mailpit)Prawdziwa skrzynka (np. AnyMail)
Szybkośćnatychmiast, działa offlinesekundy; czasem minuty przy pierwszym kontakcie
Konfiguracjajeden kontener; zmiana serwera SMTPdomena, rekordy DNS i klucz API
Sprawdza produkcyjną ścieżkę pocztynie – serwer testowy ją zastępujetak – ten sam dostawca, domena i uwierzytelnienie
Wyłapuje problemy z DNS i uwierzytelnieniemnietak, jeśli poczta idzie prawdziwą ścieżką
Można uruchamiać przy każdym commicietaktak, z umiarem – to prawdziwa poczta
Szybkość
Lokalny serwer (Mailpit): natychmiast, działa offline
Prawdziwa skrzynka (np. AnyMail): sekundy; czasem minuty przy pierwszym kontakcie
Konfiguracja
Lokalny serwer (Mailpit): jeden kontener; zmiana serwera SMTP
Prawdziwa skrzynka (np. AnyMail): domena, rekordy DNS i klucz API
Sprawdza produkcyjną ścieżkę poczty
Lokalny serwer (Mailpit): nie – serwer testowy ją zastępuje
Prawdziwa skrzynka (np. AnyMail): tak – ten sam dostawca, domena i uwierzytelnienie
Wyłapuje problemy z DNS i uwierzytelnieniem
Lokalny serwer (Mailpit): nie
Prawdziwa skrzynka (np. AnyMail): tak, jeśli poczta idzie prawdziwą ścieżką
Można uruchamiać przy każdym commicie
Lokalny serwer (Mailpit): tak
Prawdziwa skrzynka (np. AnyMail): tak, z umiarem – to prawdziwa poczta

Pomiędzy tymi rozwiązaniami są usługi do testowania poczty w chmurze – warto się im przyjrzeć, jeśli potrzebujesz wielu jednorazowych adresów. Niezależnie od wyboru schemat pozostaje ten sam: wywołaj, poczekaj z limitem czasu, wyciągnij, sprawdź. A żeby strona wysyłająca była równie niezawodna, zajrzyj do poradnika o wysyłaniu maili z aplikacji.

Niech AnyMail zajmie się tymi rekordami za Ciebie

AnyMail generuje rekordy MX, SPF, DKIM i DMARC dla Twojej domeny, automatycznie sprawdza każdy z nich i wskazuje, który trzeba poprawić – a potem utrzymuje Twoje skrzynki. Usługa działa z Polski, dane przechowujemy w UE.