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
givenobject whose methods mutate setup state and returnthisfor chaining:.given.member(),.given.loading(),.given.pendingMembership(). - A
render()method that applies the configured mocks (setMockUserContext(...)) and returns the rendered result extended withgetandhasaccessors. - 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
thisfor fluent chaining. - No DOM access here — set state and return.
render()
- The one place that mounts the component.
- Wraps in
renderWithProviders(...)fromapps/web/src/test-utils/. - Returns an object containing
get,has,find,click, and Testing Library’srerender/unmount.
get vs has vs find
| Accessor | Returns | When to use |
|---|---|---|
get.x() | DOM node (throws if missing) | Asserting presence + querying attributes |
has.x() | boolean | Conditional checks — “the button is/isn’t there” |
find.x() | Promise resolving to a node | Async appearance — uses Testing Library’s findBy* |
click.x() | void | Side 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 ofthis) for chaining. - Uses Playwright locators:
getByTestId,getByRole. Not raw selectors.
when (user actions)
- One method per business-readable action.
- Returns
thisfor 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
Locatorobjects 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
screendirectly in the spec. Move allscreen.*into the driver. vi.mock()inside a driver. Move to the spec.- A
getthat 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
heandru. waitForTimeout(...). UsefindBy*/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');
});