One config per screen: parent tests for every CRUD screen in a Playwright suite
19 October 2026 · 6 min read
- Playwright
- TypeScript
- Testing
- E2E
Most of an admin dashboard is the same screen over and over: a table, a search box, an “Add …” button that opens a form dialog, and edit/delete actions on each row. Ours had a dozen of them under different sidebar sections.
The end-to-end suite mirrored that, badly. Every screen had its own page class and its own spec, and they were near-identical copies: ten page classes, ten specs repeating the same “it loads”, “it creates”, “it deletes” tests, each with slightly different selectors and slightly different bugs. A fix to how one table found its rows never reached the other nine.
I replaced all of that with one idea: a screen is data, not a class. Every list screen is declared once, a single page object drives any of them, and each module’s spec inherits a set of parent tests before adding the tests only that module needs.
A screen is a config entry
Everything that differed between those ten page classes fitted in a small type:
export type ScreenConfig = {
/** describe title: "<Sidebar section> › <Screen>" */
title: string
singular: string // "creates and deletes a <singular>"
plural: string // "lists <plural>"
path: string // SPA route
resource: string // API collection, e.g. "invoices"
idPrefix?: string
addButton: RegExp
columns?: string[]
smoke?: boolean
}
export const screens = {
invoices: {
title: 'Billing › Invoices',
singular: 'invoice',
plural: 'invoices',
path: '/billing/invoices',
resource: 'invoices',
idPrefix: 'inv_',
addButton: /Add invoice/i,
columns: ['Number', 'Customer', 'Total'],
},
// …one entry per screen
} satisfies Record<string, ScreenConfig>
Two details in there turned out to matter more than they look.
resource ties the screen to its API collection. That lets the page object recognise the screen’s own network calls (GET /invoices?q=…, POST /invoices) without each test spelling out URLs.
Naming lives in the config too. Test titles are generated from singular and plural, and describe blocks from title, which follows the sidebar. The report reads like the product’s navigation, and nobody has to invent a test name for the eleventh time.
One page object for all of them
CrudPage takes a ScreenConfig and knows how to drive any list screen:
export class CrudPage extends BasePage {
constructor(page: Page, readonly screen: ScreenConfig) {
super(page)
}
isListCall(url: string) {
return new RegExp(`/${this.screen.resource}(\\?|$)`).test(url)
}
async search(text: string) {
const refreshed = this.page.waitForResponse(
(response) => this.isListCall(response.url()) && new URL(response.url()).searchParams.get('q') === text,
)
await this.searchInput.fill(text)
await refreshed
}
async findRow(id: string, name: string) {
await this.search(name)
const row = this.table.rowById(id)
await expect(row).toBeVisible()
return row
}
async saveNew(dialog: FormDialog) {
const { response } = await dialog.save({ resource: this.screen.resource, method: 'POST' })
const id = (await response.json()).id
expect(id).toMatch(new RegExp(`^${this.screen.idPrefix ?? ''}`))
return id
}
}
Because there’s only one of it, the hard-won fixes live in one place:
searchwaits for the debounced list request to land before returning. Acting on the grid before that refresh is a race: the re-render can unmount a row dialog you just opened.findRowsearches by name first, then finds the row by id. Never “row 0”, which is someone else’s row in a parallel run, and never a row that paging has pushed off the first page.saveNewchecks the id the API minted against the screen’s prefix, so a dialog that “saved” into the wrong collection fails right there.
When I later fixed a flaky dialog interaction, I fixed it once and every screen got the fix.
Parent tests every module inherits
The shared checks are a function that registers tests:
export function standardScreenTests(screen: ScreenConfig) {
test(`lists ${screen.plural}`, { tag: screen.smoke === false ? [] : ['@smoke'] }, async ({ page }) => {
const crud = new CrudPage(page, screen)
const listRequest = crud.waitForListRequest()
await crud.open()
const request = await listRequest
expect(request.headers()['authorization']).toMatch(/^Bearer /)
expect((await request.response())?.ok()).toBe(true)
// the request answering is not the screen working
await expect(crud.table.firstRow.or(page.getByText('No data to show'))).toBeVisible()
for (const column of screen.columns ?? []) {
await expect(crud.table.header(column)).toBeVisible()
}
})
}
Then every module’s spec starts the same way:
test.describe(screens.invoices.title, () => {
standardScreenTests(screens.invoices)
test('fills every field and saves them all', { tag: '@full-form' }, async ({ page, uniqueName, cleanup }) => {
// only what is specific to invoices
})
})
The comment in that parent test is the lesson I’d pass on. My first version stopped at “the list request returned 200”. That passed on a screen whose grid was broken and rendered nothing. The parent test now checks the user-visible result too: rows or the empty state, plus the declared columns.
The parent suite also carries cross-cutting checks a single screen would never bother with. One API silently dropped a particular sort parameter, so any screen that sent it lost its ordering without an error. A config flag on the affected screens turns on an assertion that the list request never sends it. That’s one line in the parent test, and the regression can’t come back on any screen.
What stays in the module’s own spec
The parent tests deliberately don’t try to be generic about forms. Forms are where screens really differ: pickers, nested destinations, label multi-selects. A generic “fill the form” would be either useless or a framework of its own.
So each module keeps exactly the tests that are about its behaviour, written with shared building blocks. The most valuable one is the full-form round trip:
- Create the setup rows the form needs through the API, each registered for cleanup.
- Open the add dialog, fill every field, save.
GETthe new row from the API and assert every field landed, by id rather than by label.- Reopen it in the edit dialog and assert every field shows what was saved.
Step 4 catches a class of bug the others miss: the data is saved correctly, but the edit form can’t display it, so the next save silently overwrites it.
These tests are slow, 15–30 seconds each, so they carry a @full-form tag. A fixture gives tagged tests a longer timeout, and the fast local run skips them.
The plumbing that makes it parallel
The abstraction only pays off if any two of these tests can run at the same time against one environment. Three fixtures carry that:
uniqueName(label)returnsE2E <label> <worker>-<time><n>, so names never collide and leaked rows are easy to find.cleanupwatches the page’s own create requests and deletes those rows when the test ends, pass or fail, in an order that respects references. It records a create when the request is sent, so a test that times out mid-save doesn’t leak its row.grant(scopes)narrows the permissions the frontend receives, so a role’s view is tested without keeping an account per role.
With those in place the suite runs fullyParallel, with four workers per CI shard.
What it cost, and what I’d change
The config hides some indirection. A failing test now points at standardScreenTests, not at a file named after the screen, so the config title has to be in every message. I made sure it is. I’d also resist adding a flag to ScreenConfig for every one-off difference. Two screens that need an exception get a line in their own spec, not a new option.
The result: adding a new list screen to the suite means one config entry and a spec that calls standardScreenTests, and then you write only the tests about what makes that screen different. That’s the part worth a human’s time.