How to test e-mails in automated tests
How to check sign-up, password reset and confirmation codes in end-to-end tests with real inboxes, without flaky tests.
Updated October 20, 20265 min read
Sign-up, password reset, invitations, two-factor codes: many important flows of an application run through an e-mail. Yet most end-to-end tests stop exactly where the message is sent. Whether it arrives, whether the link points to the right environment and whether the code is in the text, nobody checks. This guide shows how to close that gap without making your tests flaky.
Three levels, three tools
Not every test needs a real inbox. A split by level makes sense:
| Level | What you check | Typical tool |
|---|---|---|
| Unit and feature tests | Is the right message triggered with the right data? | the framework's mail fakes, such as Mail::fake() in Laravel |
| Local development | What does the message look like, do the links work? | a local SMTP catcher such as Mailpit |
| End-to-end in staging and CI | Does the message arrive through the real sending path? | a real inbox with an API |
The first two levels are fast and should make up the bulk of your tests. You need a real inbox only for the few tests that cover the whole path.
When you need a real inbox
Mail fakes only check that your application wanted to create a message. What happens afterwards stays invisible. A real inbox catches bugs like these:
- The staging environment still uses the log mailer, and no message leaves the server.
- The confirmation link points to
localhostor to production instead of staging. - The sending service rejects messages because the sender domain is not verified.
- A template error breaks the code so it no longer appears in the text.
- The queue that sends mail is not running in this environment.
The pattern: create, trigger, wait, read, clean up
Almost every mail test follows the same five steps:
- Create: the test creates a fresh, unique address.
- Trigger: it fills in your application's form with that address.
- Wait: it waits until the message arrives, with a fixed upper limit.
- Read: it takes the code or link from the message and uses it.
- Clean up: it deletes the address again.
With the TossPost API, this looks like this in Playwright:
import { test, expect } from '@playwright/test';
const api = 'https://tosspost.com/api/v1';
const headers = {
Authorization: `Bearer ${process.env.TOSSPOST_TOKEN}`,
Accept: 'application/json',
};
test('sign-up sends a confirmation code', async ({ page, request }) => {
const created = await request.post(`${api}/mailboxes`, {
headers,
data: { lifetime: 10 },
});
const { data: inbox } = await created.json();
await page.goto('https://staging.example.com/sign-up');
await page.getByLabel('E-mail').fill(inbox.address);
await page.getByRole('button', { name: 'Sign up' }).click();
const waited = await request.get(
`${api}/mailboxes/${inbox.id}/messages/wait?timeout=30&since=${encodeURIComponent(inbox.created_at)}`,
{ headers, timeout: 35_000 },
);
expect(waited.status()).toBe(200);
const { data: message } = await waited.json();
await page.getByLabel('Code').fill(message.verification_code);
await request.delete(`${api}/mailboxes/${inbox.id}`, { headers });
});
Wait instead of sleep
The most common reason for flaky mail tests is a fixed sleep(5000). Sometimes it is not enough, sometimes it wastes time. A request that stays open exactly until the message is there works better.
The messages/wait endpoint does that: it holds the request open for up to 30 seconds and answers with 200 and the message as soon as it arrives. If nothing comes in that time, it answers with 204. The test then fails with a clear message instead of stumbling somewhere later.
Mind the since parameter. Without it, only what arrives after the waiting request starts counts. If your application is faster than the test, the message would already be there and be missed. Pass the time the inbox was created (created_at) as since, and no message can slip past you. Because that time comes from the server, a clock that is off on the CI machine does not matter either.
Read codes and links
Every message comes with the fields verification_code and verification_url. TossPost detects confirmation codes and confirmation links in the text when the message arrives, so you do not have to maintain your own regular expressions. If you need more, for example to check a particular header or the HTML structure, load the full message via messages/{id} or the original via messages/{id}/raw.
Test in parallel
Never share an address between tests. If two tests run at the same time, one of them would read the other's code. Every test creates its own inbox; that costs a single request.
Waiting requests are limited per account because each one keeps a connection open. With many parallel tests the API answers 429 once all slots are taken. For large test suites, webhooks are the better fit: TossPost calls a URL of your choice for every new message, signed with an HMAC-SHA256 that you verify before processing it.
Tips for stable mail tests
- Store the token as a secret: keep the API token in your CI secrets, never in the repository.
- Limit permissions: give the token only the permissions the test needs, such as creating inboxes and reading messages.
- Choose a short lifetime: an inbox that expires after ten minutes cleans itself up if the test aborts.
- Clear time limits: set the HTTP client's timeout a few seconds above the wait limit so the API answers first.
- Only the key flows: test sign-up, password reset and invitations end to end. The faster levels cover everything else.
Get started
You need a TossPost account to use the API. Create a token under Settings → API; the API documentation describes every endpoint, the webhooks and how to verify the signature.
Open the API documentation – with examples for curl and Playwright.