Test fixtures: inboxes and test data
Every generated test imports from ../fixtures. Besides test and expect, it gives tests real email inboxes and values you can find and delete later. All of it works on every plan.
Tests import these from ../fixtures: test, expect, inbox, waitForEmail, unique, testEmail, track, otp (the current code for an authenticator 2FA login), file (a file from the app’s Files library, for uploads) and readDownload (the name, size, type and text of a download). Generated tests use them, and they keep working in an exported suite.
Test email inboxes
TestWatch receives mail for the addresses it gives your tests, so a test can follow a verify link, a magic link or a password reset, or type an emailed code.
import { test, expect, inbox, unique } from '../fixtures';
test('Sign up and verify the email', async ({ page }) => {
const box = inbox('signup');
await page.goto('/signup');
await page.getByLabel('Company').fill(unique('company'));
await page.getByLabel('Email').fill(box.address);
await page.getByRole('button', { name: 'Create account' }).click();
const mail = await box.waitForEmail({ subject: /verify/i });
await page.goto(mail.link!);
await expect(page.getByText('Email verified')).toBeVisible();
});inbox(label?)
Returns { address, label, waitForEmail }. Every call gives a fresh address that starts with tw- and the run tag, so a re-run doesn’t collide with a user an earlier run created. Type address into the form. box.waitForEmail(...) reads that address.
waitForEmail({ subject, from, to })
subjectandfromare a string (a case-insensitive substring) or a regular expression. All three are optional.- Without
to, it reads the latestinbox()address of this test, or else the test account’s own TestWatch inbox, if the account was created with one. - Only mail that arrived after the address was created (or, for an account inbox, after this test attempt started) counts. Among the matches the newest wins, so a resent email replaces the first one. An email it returned is not returned again, so a second call gets the next one.
- It waits up to 45 seconds. There is no timeout option.
It returns:
{
subject: string,
from: string,
to: string,
text: string,
html: string,
links: string[],
link: string | null, // the action link: verify, reset, sign-in
code: string | null, // a one-time code, when the email has one
receivedAt: Date,
}When link has several candidates, a link to the app’s own host is preferred.
When no email comes
| Error starts with | What happens |
|---|---|
TESTWATCH_EMAIL_NOT_RECEIVED | No matching email within 45 seconds. A normal failure that TestWatch checks: if other mail arrived (say, the subject changed), it proposes a fix to the matcher; if nothing arrived, it reports a real bug, because the app didn’t send the email. |
TESTWATCH_INBOX_MISCONFIGURED | The test or its account is set up wrong, for example the account’s inbox was removed. The message says what to change. Shown as failed; no fix is proposed. |
TESTWATCH_INBOX_UNAVAILABLE | The inbox couldn’t be read for the whole wait. Recorded as an error, not a bug in your app. |
Each email a test read appears on the result’s Technical tab, with links and codes masked.
Clean test data
Tests that create things leave them behind. The helpers below make every value a run creates easy to find and delete.
import { test, expect, unique, testEmail, track } from '../fixtures';
test('Create an invoice', async ({ page }) => {
await page.getByLabel('Customer name').fill(unique('customer')); // tw-r7k2m9x4qa-x3f9ab
await page.getByLabel('Billing email').fill(testEmail('billing')); // tw-r7k2m9x4qa-p2k8sd@example.com
await page.getByRole('button', { name: 'Create invoice' }).click();
const number = track(await page.getByTestId('invoice-number').innerText(), 'invoice');
await expect(page.getByText(number)).toBeVisible();
});unique(label?, { compact? })returnstw-<runTag>-<6 random>, for any field that must be unique: a username, a company or product name. Withcompact: trueit uses letters and digits only (tw<runTag><6 random>) for fields that reject-.testEmail(label?)returnstw-<runTag>-<6 random>@example.com, or the app’s own test email domain if you set one. It isn’t meant to receive mail. A test that has to read the email usesinbox().track(value, label?)records an id your app created, such as an order number read off the page, and returns it unchanged.
The run tag is r, g or h (a run, a generation or a fix check) plus 9 characters, the same for every test and retry in that job. Inbox addresses carry it too, so deleting by the prefix also catches signups. The label is only recorded; it isn’t part of the value. A result’s Technical tab lists what its test created.
The cleanup webhook
Set one per app on CI & deploys → Test data cleanup: an https URL that receives what each job created. You get a whsec_ signing secret, shown once. Requests are signed like webhook channels (Standard Webhooks).
- One POST when a run closes, when a generation ends and when a fix check ends, only if that job created something.
- Delete by the exact
prefix, or by the listed values. Never delete by a baretw-. - A body holds up to 2,000 items and 256 KB. Past that,
truncatedistrueand the full list is atGET /v1/runs/:id/test-data(an API key, Starter and up). - 429, 5xx and network errors are retried for about 30 minutes. After 5 other failures in a row the hook is turned off, with the reason shown.
{
"type": "test_data.created",
"timestamp": "2026-10-12T09:00:00.000Z",
"data": {
"workspaceId": "...",
"app": { "id": "...", "name": "Shop" },
"source": "run",
"sourceId": "...",
"runTag": "r7k2m9x4qa",
"prefix": "tw-r7k2m9x4qa",
"targetUrl": "https://staging.shop.example",
"run": { "id": "...", "trigger": "scheduled", "environment": "Staging", "url": "https://app.testwatch.dev/..." },
"generation": null,
"heal": null,
"items": [
{ "kind": "unique", "value": "tw-r7k2m9x4qa-x3f9ab", "label": "company", "testCaseId": "...",
"testName": "Create company", "resultId": "...", "attempt": 1, "createdAt": "..." }
],
"totalItems": 1,
"truncated": false
}
}