Receive email in a Playwright test
The whole test that waits for an email, reads the code inside it and carries on, with no sleep and no package to install.
A sign-up journey ends in a mailbox, and your test has to read what lands there. Here is the whole file, with nothing of ours to install.
Before you start
Two things, and nothing else:
- A read-only API key. Create it in
Settings → API keys and put it in
your CI secrets, as
FACTEUR_API_KEY. The test below creates nothing: it reads. See API keys. - Your inbox’s identifier, the eight characters shown at the top of the inbox view.
import { expect, test } from '@playwright/test';
const KEY = process.env.FACTEUR_API_KEY!;
const INBOX = 'j3k9x2mq';
/** Stays open until the message lands, thirty seconds at most. */
const waitForMail = (sentTo: string) =>
fetch('https://api.facteur.eu/v1/messages/search', {
method: 'POST',
headers: {
authorization: `Bearer ${KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ inbox: INBOX, sentTo, wait: 30_000 }),
}).then((response) => response.json());
test('the verification link activates the account', async ({ page }) => {
const tag = `run-${Date.now()}`;
// Issued before the click: it answers as soon as the mail arrives.
const arriving = waitForMail(`signup+${tag}`);
await page.goto('/signup');
await page.getByLabel('Email').fill(`signup+${tag}@${INBOX}.inbox.facteur.eu`);
await page.getByRole('button', { name: 'Create my account' }).click();
const { messages } = await arriving;
expect(messages[0].status).toBe('parsed');
await page.goto(messages[0].links[0].href);
await expect(page.getByText('Account verified')).toBeVisible();
});
Start the wait before the action
That is the one line whose order matters. The request goes out before the click, stays open, and answers the moment the mail arrives. Issued afterwards, it would chase a message that may already have been handled, and you would meet that defect the day your pipeline gets slow.
One address per test, with nothing to create
Every address ending in your inbox’s domain arrives there. So there is no call to make
before using signup+run-1734@j3k9x2mq.inbox.facteur.eu: writing it is enough. That is
what lets sixteen parallel tests share one inbox without stealing each other’s mail.
Put the test name and the attempt number in the tag. Without the attempt, a retried test reads the mail from its own first run and passes for the wrong reason, exactly when something is already flaky.
Read status before anything else
An accepted email exists before it is parsed. For a few milliseconds it has no subject,
no code and no links. The status field separates “there is no code in this message”
from “the code is not extracted yet”. Search returns parsed messages only by default, so
the case arises only if you ask for status: any.
The code is already out of the message
messages[0].otp carries the one-time code, extracted on reception. You write no
regular expression, and you have nothing to fix the day the email template changes.
The links are there too: messages[0].links carries one object per link, in the order
they appear, with its href and its text.
Install the Playwright plugin
The plugin writes each test’s address and the wait for you. It needs Node 20 or later and Playwright 1.40 or later.
- Install it:
npm i -D playwright-facteur. - Mint a key under Settings → API keys, and set it as
FACTEUR_API_KEYin your CI environment. - Import
testandexpectfromplaywright-facteurrather than from@playwright/test.
You get an inbox fixture that hands one address to each test, and matchers such as
expect(message).toHaveOtp() that Playwright’s own engine retries. The test above does
not need the plugin and keeps working as it stands.
The state of each integration is on the Integrations page.
Checked on Is this article wrong or incomplete?