Test architecture: fixtures, builders, page objects
The difference between a suite that survives three years and one that gets deleted. Setup that composes, data built rather than hardcoded, and abstractions that hide the page without hiding the intent. This is the craft half of automation.
Test architecture is the structure underneath your tests: how they get set up, where shared knowledge lives, and what a new test has to know in order to exist.
Three patterns do most of the work.
A fixture provides a test with what it needs and cleans up afterwards. A logged-in customer, a seeded gift card, a running browser.
A builder creates test data with sensible defaults and lets you override only the part you care about. anOrder().withTotal(35.99).build().
A page object puts everything about one screen in one file, so a UI change touches one place rather than forty tests.
None of this is about writing tests faster. It is about a suite that survives a rename, a redesign and three years of staff turnover.
The terms you will hear
- Fixture. Reusable setup and teardown, often provided by the framework.
- Builder, or factory. A function producing test data with defaults and overrides.
- Page object. A class or module wrapping one screen's selectors and actions.
- Helper. Shared code that is neither of the above. Where the mess accumulates.
- Test isolation. Each test setting up and tearing down its own state.
- Shared setup. State created once for many tests. Fast, and a common cause of order dependence.
What each pattern fixes
Fixtures fix repeated setup. Without them, twelve tests each contain the same eight lines creating a customer, and changing that flow means editing twelve files.
Builders fix unreadable data. A test with fifteen fields of literal data hides which one matters. A builder makes the important field the only one visible, and the rest sensible defaults.
Page objects fix selector churn. A renamed button is one edit rather than forty, and a test reads as intent rather than as a list of CSS selectors.
Why it matters
Because a suite is read far more often than it is written, and the reading happens under pressure.
For example, a test fails on a Thursday afternoon before a release. If the test says expect(basket.total()).toBe('35.99') and the setup is one builder line, anybody on the team can judge it in ten seconds. If it is forty lines of inline setup with a helper called doTheThing, the person on call reads for ten minutes and then guesses.
How to structure a suite
- One fixture per meaningful state. A logged-in customer, a customer with a gift card, an empty basket. Named after the state, not the steps.
- One builder per domain object. Orders, customers, cards, products. Defaults that are always valid, overrides for the one field under test.
- One page object per screen, exposing actions rather than selectors.
checkout.applyGiftCard(code), notpage.click('.apply-btn'). - Assertions stay in the test. A page object that asserts hides the check from the reader. It is the assertion strength problem in another shape.
- Make every test independent. It creates what it needs and leaves the system usable. Shared setup is a speed optimisation you pay for in order dependence.
- Name the data after its purpose.
expiredCard, notcard2. This is the same discipline as naming data in a case. - Keep test code reviewed like product code. Same repository, same three-year lifespan, and worth a review that catches things.
A worked structure
For example, here is the shape of a real suite, plus one test before and after.
tests/
fixtures/
customer.ts loggedInCustomer, customerWithOrders
giftCard.ts activeCard, spentCard, voidedCard
basket.ts emptyBasket, basketWorth(amount)
builders/
order.ts anOrder().withTotal().withItems().build()
customer.ts aCustomer().withEmail().build()
pages/
checkout.ts applyGiftCard(), pay(), totalShown()
balance.ts lookUp(code), balanceShown()
e2e/
gift-card.spec.ts 9 tests, the critical journeys
api/
redemption.spec.ts 22 tests, the money maths
BEFORE one test, 31 lines
const customer = await db.insert('customers', { email: '[email protected]',
name: 'Test', created: new Date(), status: 'active', ... })
const card = await db.insert('gift_cards', { code: 'GIFT-1', balance:
2500, currency: 'GBP', status: 'active', issued: new Date(), ... })
await page.goto('/login'); await page.fill('#email', '[email protected]')
await page.fill('#pw', 'password'); await page.click('.submit')
await page.goto('/book/piranesi'); await page.click('.add-to-basket')
... 20 more lines ...
expect(await page.textContent('.remaining')).toContain('10')
AFTER the same test, 5 lines
test('card covers part of the order, card pays the rest', async ({
loggedInCustomer, activeCard, checkout }) => {
await checkout.withBasket(basketWorth(35.99))
await checkout.applyGiftCard(activeCard.code)
expect(await checkout.totalShown()).toBe('10.99')
})
WHAT CHANGED WHEN THE APPLY BUTTON WAS RENAMED
before 38 files edited
after 1 file edited (pages/checkout.ts)
WHAT CHANGED WHEN LOGIN GAINED A SECOND STEP
before 38 files
after 1 fixtureThe last two blocks are the entire argument. The five-line version is not shorter for elegance, it is shorter because the knowledge moved to one place.
How to show you know it
- A before-and-after test. Thirty-one lines to five, with the assertion still visible.
- A change-cost figure. "The rename touched one file instead of thirty-eight."
- An abstraction you refused. "Two tests shared this, so I left it duplicated." Restraint is the harder skill.
- Assertions kept in tests. Being able to explain why page objects should not assert marks out somebody who has maintained a suite.
Questions
Are page objects outdated?
The name is unfashionable and the idea is not. Modern frameworks give you fixtures and locators that cover much of it, so the file may be smaller. Screen knowledge still belongs in one place.
How do I know when to extract a helper?
On the third occurrence, when the shape is visible. Extracting on the first is guessing at a pattern, and the wrong abstraction is more expensive than the duplication.
Should tests share a database?
They can share an instance and should not share state. Each test creates what it needs, which is what makes a failure mean something and lets the suite run in parallel.
Do these patterns apply to API tests too?
Builders and fixtures very much so. Page objects have no meaning without a page. The equivalent is a thin client wrapping the endpoints, so the tests still read as intent.