Skip to content
Facteur

Type at least two characters.

Receiving 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.

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 are not rewriting the regular expression that sits in every test suite, and you are not fixing it the day the email template changes.

Links work the same way: messages[0].links carries the message’s links, in the order they appear.

A read-only key is enough

The test above creates nothing; it reads. Create a read-only key under Settings → API keys, put it in your CI secrets, and give a test suite no more than that.

What about a Playwright plugin?

It is planned and not published yet. The test above does not need it: it calls the API directly, and will keep working when the plugin ships. The state of each integration is on the Integrations page.

Checked on Is this article wrong or incomplete?