Skip to Content
Living documentation — last reviewed 2026-05-28
TestingDriver pattern

Driver pattern

Every UI test in FitKit — unit, integration, or E2E — goes through a driver. Drivers encapsulate all DOM/locator access; specs read like business behaviour. This doc is the canonical reference.

The contract differs slightly between Testing Library drivers (unit / integration) and Playwright drivers (E2E). Both follow the same given / render-or-when / get / has / find / click shape.


Unit + integration drivers (Testing Library)

Shape

// component.driver.tsx export function createComponentDriver() { let userOverrides = {}; return { given: { someCondition() { /* set state */ return this; }, another(x) { /* set state */ return this; }, }, render() { // mutate any global mocks const result = renderWithProviders(<Component {...} />); return { ...result, get: { submitButton: () => screen.getByTestId('submit'), fieldByName: (n) => screen.getByTestId(`field-${n}`), }, has: { submitButton: () => screen.queryByTestId('submit') !== null, errorMessage: (text) => screen.queryByText(text) !== null, // sparing }, find: { asyncField: () => screen.findByTestId('async-field'), }, click: { submit: () => fireEvent.click(screen.getByTestId('submit')), }, }; }, }; }

Canonical example

apps/web/src/components/role-router.driver.tsx. It demonstrates:

  • A factory function (createRoleRouterDriver()) — drivers are stateful but instantiated fresh per test.
  • A given object whose methods mutate setup state and return this for chaining: .given.member(), .given.loading(), .given.pendingMembership().
  • A render() method that applies the configured mocks (setMockUserContext(...)) and returns the rendered result extended with get and has accessors.
  • All screen.* calls confined to the driver.
// usage in a spec const driver = createRoleRouterDriver(); const { get, has } = driver.given.pendingMembership().render(); expect(has.processingText()).toBe(true);

given

  • Encodes preconditions: which user, what data, what feature flags.
  • Each method returns this for fluent chaining.
  • No DOM access here — set state and return.

render()

  • The one place that mounts the component.
  • Wraps in renderWithProviders(...) from apps/web/src/test-utils/.
  • Returns an object containing get, has, find, click, and Testing Library’s rerender/unmount.

get vs has vs find

AccessorReturnsWhen to use
get.x()DOM node (throws if missing)Asserting presence + querying attributes
has.x()booleanConditional checks — “the button is/isn’t there”
find.x()Promise resolving to a nodeAsync appearance — uses Testing Library’s findBy*
click.x()voidSide effect — fires the event

Selectors

data-testid first. Use getByRole only when asserting accessibility semantics (e.g. “this is a button named Save”). Never getByText for translated content.

Where the mocks live

vi.mock(...) calls go in the spec file, not the driver. Vitest hoists mocks per-file; placing them in a driver makes them invisible to the consuming spec.

// spec import { createComponentDriver } from './component.driver'; vi.mock('@/lib/api', () => ({ useApi: () => ({ /* ... */ }) })); it('does the thing', () => { const driver = createComponentDriver(); const { get } = driver.given.someState().render(); expect(get.field()).toHaveValue('expected'); });

Provider stack

renderWithProviders (apps/web/src/test-utils/) wraps with the same providers the app uses — QueryClient, DictionaryProvider, UserProvider (mocked), etc. — so components render the same way as in production.


E2E drivers (Playwright)

Shape

// onboarding-driver.ts export class OnboardingDriver { private orgName = `FitKit E2E ${Date.now()}`; constructor(private page: Page) {} given = { opened: async () => { await this.page.goto(ROUTES.ONBOARDING); await this.page.waitForLoadState('domcontentloaded'); await this.page.getByTestId('org-name').waitFor({ state: 'visible' }); return this; }, }; when = { completeHappyPath: async () => { /* ... */ return this; }, }; get = { url: () => this.page.url(), heading: () => this.page.getByRole('heading').first(), }; }

Canonical example: apps/web/e2e/drivers/onboarding-driver.ts. Others worth reading: coaching-grid-driver.ts, purchase-driver.ts, workout-builder-driver.ts.

given (preconditions)

  • Navigate, set state, wait for the surface to appear.
  • Returns this (or a promise of this) for chaining.
  • Uses Playwright locators: getByTestId, getByRole. Not raw selectors.

when (user actions)

  • One method per business-readable action.
  • Returns this for chaining.
  • No parameters from the spec. Drivers generate their own test data (e.g. unique org names with timestamps) — this keeps specs declarative and avoids accidental data pollution.

get (locator accessors)

  • Returns Playwright Locator objects or values (URL, etc.).
  • Assertions live in the spec, not the driver:
// spec await driver.given.opened(); await driver.when.completeHappyPath(); expect(driver.get.url()).toContain('/dashboard/overview');

Constants

Routes and test ids live in apps/web/e2e/constants.ts:

export const ROUTES = { SIGN_IN: '/en/sign-in', DASHBOARD_OVERVIEW: '/en/dashboard/overview', // ... }; export const TEST_IDS = { STAT_CARD: 'stat-card', // ... };

Drivers import from there. Hard-coded routes in driver files are a smell.

Data setup

E2E specs use a testApi helper that hits /testing/* endpoints (apps/api/src/testing/). These are only mounted when NODE_ENV !== 'production' and TEST_HOOKS_ENABLED=true. Idempotent seeders (testApi.seedMember(...), testApi.seedClassSession(...)).

Persona swapping

usePersona(role) (Playwright fixture) — sets the x-test-user-id header on subsequent requests. No Clerk re-sign-in. Allowed because TEST_AUTH_BYPASS=true on the API and NEXT_PUBLIC_E2E_TEST_MODE=true on the web both let unauthenticated headers through.

Auth caching

apps/web/e2e/global.setup.ts does a real Clerk sign-in once, writes e2e/.auth/signed-in-state.json. Playwright’s project config picks it up via storageState. Persona swaps are cheap on top of this baseline state.


Driver smells

  • Driver imports screen directly in the spec. Move all screen.* into the driver.
  • vi.mock() inside a driver. Move to the spec.
  • A get that asserts. Assertions belong in the spec. The driver hands out a node/locator; the spec checks it.
  • Hard-coded English in driver locators. Use testids; English breaks in he and ru.
  • waitForTimeout(...). Use findBy* / waitFor / getByTestId(...).waitFor(...) instead.
  • Spec drives data via driver method parameters. Drivers should generate their own data so specs stay declarative.

What makes a good driver

  • A spec reads like product behaviour: “given a coach with a pending invitation, when they sign up, they land on onboarding”.
  • The driver names match domain terms, not DOM terms.
  • Adding a new test against the same feature requires no driver changes most of the time.
  • When the UI changes structurally, only the driver changes; specs continue to compile.

Spec template (Testing Library)

import { createFeatureDriver } from './feature.driver'; vi.mock('@/lib/api', () => ({ useApi: () => mockApi })); describe('Feature', () => { it('happy path', () => { const driver = createFeatureDriver(); const { get, click } = driver.given.member().render(); click.submit(); expect(get.successToast()).toBeVisible(); }); });

Spec template (Playwright)

import { test, expect } from '../helpers/test-fixture'; import { OnboardingDriver } from '../drivers/onboarding-driver'; test('owner completes onboarding', async ({ page, testApi, usePersona }) => { await usePersona('owner-no-org'); const driver = new OnboardingDriver(page); await driver.given.opened(); await driver.when.completeHappyPath(); expect(driver.get.url()).toContain('/dashboard/overview'); });