Skip to content
Facteur
FRGet started

Type at least two characters.

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:

  1. 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.
  2. 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.

  1. Install it: npm i -D playwright-facteur.
  2. Mint a key under Settings → API keys, and set it as FACTEUR_API_KEY in your CI environment.
  3. Import test and expect from playwright-facteur rather 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?