TestWatchStart free

Clean up the data your tests create

Every scheduled run signs up users, creates companies and places orders in your staging app. After a month, staging is mostly test leftovers. TestWatch marks what each run creates and tells your backend, so you can delete exactly that.

by Vasyl Harastei

The test

import { test, expect, unique, testEmail, track } from '../fixtures';

test('Create a company and place an order', async ({ page }) => {
  await test.step('Create a company', async () => {
    await page.getByRole('link', { name: 'New company' }).click();
    await page.getByRole('textbox', { name: 'Company name' }).fill(unique('company'));
    await page.getByRole('textbox', { name: 'Billing email' }).fill(testEmail('billing'));
    await page.getByRole('button', { name: 'Create company' }).click();
  });

  await test.step('Place an order', async () => {
    await page.getByRole('button', { name: 'Place order' }).click();
    await expect(page.getByText('Order placed')).toBeVisible();
    track(await page.getByTestId('order-number').innerText(), 'order');
  });
});

A test like this one. unique() and testEmail() make the values; track() records an id your app made, like an order number.

How it works

Every run gets a run tag, so its values look like tw-r7k2m9x4qa-x3f9ab. The prefix is the same for every test, retry and verify pass of one run, and the end is fresh on each call, so a retry never hits “name already taken”. unique('x', { compact: true }) drops the dashes for fields that refuse them. testEmail() addresses never receive mail; when the test must read the email, inbox() gives an address with the same prefix.

Each value is recorded with its test, its attempt and a label. That covers scheduled and CI runs, and also the verify runs while TestWatch writes or fixes a test, because those create data too. Records are kept for 30 days. Values the agent types while exploring are recorded only when they carry the run’s tag, so a real login it typed isn’t listed.

The cleanup webhook

Add one webhook per app under CI & deploys, then Test data cleanup. After a run closes, or a generation or fix ends, and only if it created something, TestWatch sends one test_data.created POST. It carries the prefix and the list of values. It is signed with the Standard Webhooks headers, so any Standard Webhooks library can check it, or a few lines of code:

// Example for YOUR backend (Node + Express). Not TestWatch code.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const key = Buffer.from(process.env.TESTWATCH_CLEANUP_SECRET!.replace(/^whsec_/, ''), 'base64');

function verified(req: express.Request, body: string): boolean {
  const id = req.header('webhook-id') ?? '';
  const ts = req.header('webhook-timestamp') ?? '';
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const want = createHmac('sha256', key).update(`${id}.${ts}.${body}`).digest();
  return (req.header('webhook-signature') ?? '').split(' ').some((s) => {
    const got = Buffer.from(s.replace(/^v1,/, ''), 'base64');
    return got.length === want.length && timingSafeEqual(got, want);
  });
}

const app = express();
app.post('/hooks/testwatch-cleanup', express.text({ type: '*/*' }), async (req, res) => {
  if (!verified(req, req.body)) return res.sendStatus(401);
  const event = JSON.parse(req.body);
  if (event.type === 'test_data.created') {
    const { prefix, items } = event.data; // prefix = "tw-<runTag>", never a bare "tw-"
    await deleteTestData(prefix, items.map((i: { value: string }) => i.value)); // yours
  }
  res.sendStatus(204);
});

An example for your backend. deleteTestData is your own code; TestWatch doesn’t delete anything in your app itself.

Each item says what kind of value it is (a unique value, an email, an inbox address or a tracked id), with its label, test name and attempt, so your handler can choose what to delete. The webhook-id header stays the same on retries, so you can skip an event you already handled. The settings card can send a signed test event and lists the last deliveries.

When delivery fails

A 429, a 5xx or a network error is retried for about 30 minutes. After five other failures in a row the webhook is turned off and the dashboard says why. A long list is cut at 2,000 items and marked truncated; the full list of a run is on the API (Starter and above).

What breaks it

  • Delete by the exact prefix or the listed values. Never purge by a bare tw-: that also hits other runs.
  • Compact values have no dash after tw, so a prefix match with the dash misses them. Use the listed values.
  • Older tests that build values from the clock report nothing until they’re regenerated.

The helpers and the webhook are on every plan, including Free.

Start free See how it works

Free: 5 tests, 500 runs a month, 2 apps and one daily schedule. Unlimited teammates, no card.