Test harnesses for components and pages

Write the harness once. Run it under every runner*.

Like page objects, but for components too. One harness API for Testing Library, node:test, Bun, Playwright, Cypress, WebdriverIO, Nightwatch, Vitest browser mode, Puppeteer, TestCafe, Ember and Angular, over React, Vue, Solid and Svelte alike, with Gherkin on top and Storybook play functions beside. The same class drives a fast jsdom unit test, a story, and a real-browser end-to-end test.

npm i -D @harnessed-ts/core @harnessed-ts/dom

Then add the driver package for your runner.

* Well, every one with a solid border below. The dashed ones haven't been through the conformance suite yet. Bring snacks.

One LoginFormHarness class between UI frameworks and unit and end-to-end test runners. Solid borders are conformance-tested in CI; dashed ones are not yet supported. React Ember Vue Svelte Angular Solid Testing Library Vitest browser Ember QUnit Storybook Bun test node:test Playwright Cypress WebdriverIO Puppeteer TestCafe Nightwatch Detox Gherkin UI frameworks Unit End-to-end Conformance-tested in CI Not yet @Harness({ host }) class LoginFormHarness signInAs(email, pw)

Why harnessed

One API across the whole test pyramid

Write once

The same harness class drives jsdom unit tests and real-browser end-to-end tests.

Every runner

Testing Library, node:test, Bun, Playwright, Cypress, WebdriverIO, Nightwatch, Vitest, Puppeteer, TestCafe, Ember and Angular, plus Storybook play functions.

Strict by default

Ambiguous matches fail loudly, and absence answers immediately, under every driver.

Framework-agnostic

Harnesses query the DOM, not React, Ember, Vue, Solid, Svelte or Angular.

Concepts

What a harness is

A class that drives a component the way a person would, through methods named for what the component does. Tests state intent; the harness owns the DOM wiring.

@Harness({ host: testId('login-form') })
class LoginFormHarness extends ComponentHarness {
  @ByLabel('Email') private accessor email!: Query
  @ByLabel('Password') private accessor password!: Query
  @ByRole('button', { name: /sign in/i }) private accessor submit!: Query
  @ByTestId('login-error') private accessor errorLine!: Query

  async signInAs(email: string, password: string): Promise<void> {
    await this.email.fill(email)
    await this.password.fill(password)
    await this.submit.click()
  }

  async error(): Promise<string | null> {
    if (await this.errorLine.isAbsent()) return null
    return this.errorLine.text()
  }
}
jsdom, via Testing Library
render(<LoginForm />)
const form = new LoginFormHarness(dom({ user: userEvent.setup() }))
await form.signInAs('ada@example.com', 'hunter2')
expect(await form.error()).toBeNull()
A real browser, via Playwright
const form = new LoginFormHarness(pw(page))
await form.signInAs('ada@example.com', 'hunter2')
await expect(form).toBeAbsent()

Mocking

Mock the API once, under every runner

@harnessed-ts/mock describes the backend a test needs, and each driver intercepts requests its own way — page.route under Playwright, cy.intercept under Cypress, fetch and XHR patched in the page under Testing Library, Ember and Storybook. The same handlers, held to the same conformance catalog, under every runner.

@Api({ base: '/api/users' })
class UsersApi extends ApiHarness {
  @Get('/:id')
  byId({ params }: MockRequest<{ id: string }>) {
    return params.id === '1' ? json({ name: 'Ada' }) : notFound()
  }
}
jsdom, via Testing Library
const api = mock(dom({ user }), { scope: '/api/**' })
await api.use(UsersApi)
render(<Profile id="1" />)
expect(await profile.name()).toBe('Ada')
A real browser, via Playwright
const api = mock(pw(page), { scope: '/api/**' })
await api.use(UsersApi, { byId: () => serverError() })
await profile.goto()
expect(await profile.error()).toBe('500')

Mocking APIs, in full.

In action

One demo, five environments

The same demo() function and harness classes, recorded under each runner against a React app and its Ember port. CI runs this demo on every pull request.

The demo under React Testing Library in jsdom, replayed beside demo.ts
Testing Library · React
The demo under Playwright against the React app
Playwright · React
The demo under Playwright against the Ember app
Playwright · Ember
The demo under Cypress against the React app
Cypress · React
The demo under Cypress against the Ember app
Cypress · Ember

Playground

Try it live

A harness and its Vitest spec running in jsdom, in your browser on StackBlitz. Edit the test or the form and watch it rerun. Browser runners need a real browser binary, which StackBlitz can't launch, so they're shown as code below.

Open the live example on StackBlitz

Compatibility

Supported runners

Every package is @harnessed-ts/<name>. Each runner passes the same conformance catalog on every pull request. Read the runner guides →

Runner Package Environment Mocking Conformance
Testing Library dom jsdom; React, Vue, Solid, Svelte In the page ci status Vue status Solid status Svelte status
node:test dom jsdom In the page (not in CI) node:test status
Bun test dom happy-dom In the page (not in CI) Bun test status
Playwright playwright Real browser page.route ci status Playwright Ember status Playwright Vue, Solid, Svelte and Angular status
Ember (QUnit, exam, Vitest) ember Real browser In the page ember-qunit status ember-exam status Ember Vitest status
Angular (Vitest, Vitest browser) angular jsdom or real browser Not yet Angular status Angular Cypress component status
Cypress (e2e and component) cypress Real browser cy.mock Cypress e2e status Cypress component status Cypress Ember status Cypress component Vue and Svelte status
WebdriverIO webdriverio Real browser (BiDi, Classic) WebDriver BiDi WebdriverIO status
Vitest browser mode vitest-browser Real browser In the page Vitest browser status
Nightwatch (e2e and component) nightwatch Real browser (Nightwatch and Mocha runners) Not yet Nightwatch status Nightwatch component status
Puppeteer puppeteer Real browser Request interception Puppeteer status
TestCafe testcafe Real browser Request hook; no sockets TestCafe status
Storybook (React and Ember) storybook Play functions; stories as fixtures Per story, in parameters Storybook status Storybook Ember status
Gherkin (playwright-bdd, cucumber-js, Cypress cucumber, Nightwatch) gherkin Real browser world.mocks (not Nightwatch) playwright-bdd status cucumber-js status Cypress cucumber status Nightwatch cucumber status

Setup

Every runner needs @harnessed-ts/core and standard decorators; see required setup.

Testing Library (jsdom)
npm i -D @harnessed-ts/core @harnessed-ts/dom @testing-library/dom @testing-library/user-event
import { dom } from '@harnessed-ts/dom'
import '@harnessed-ts/dom/matchers' // toBeAbsent, toBeSelected, toReadAs
import userEvent from '@testing-library/user-event'

const { baseElement } = render(<LoginForm />)
const form = new LoginFormHarness(dom({ user: userEvent.setup(), container: baseElement }))
await form.signInAs('ada@example.com', 'hunter2')
expect(await form.error()).toBeNull()

Mocking the API:

import { mock } from '@harnessed-ts/dom/mock' // with @mswjs/interceptors installed
await mock(dom({ user })).use(UsersApi)
node:test (jsdom)
npm i -D @harnessed-ts/core @harnessed-ts/dom @harnessed-ts/chai chai esbuild jsdom global-jsdom
node --test --import global-jsdom/register --import @harnessed-ts/core/register "tests/**/*.test.tsx"
import { harnessedChai } from '@harnessed-ts/chai'
import * as chai from 'chai'
import { afterEach, it } from 'node:test'

chai.use(harnessedChai)
afterEach(cleanup)

it('signs in', async () => {
  const { baseElement } = render(<LoginForm />)
  const form = new LoginFormHarness(dom({ user: userEvent.setup(), container: baseElement }))
  await form.signInAs('ada@example.com', 'hunter2')
  await chai.expect(form.errorBanner).to.be.absent
})
Bun test (happy-dom)
bun add -d @harnessed-ts/core @harnessed-ts/dom esbuild @happy-dom/global-registrator
# bunfig.toml: the DOM, then the decorator loader, then the matchers
[test]
preload = ["./happydom.ts", "@harnessed-ts/core/bun", "@harnessed-ts/dom/matchers/bun"]
import { expect, it } from 'bun:test'

it('signs in', async () => {
  const { baseElement } = render(<LoginForm />)
  const form = new LoginFormHarness(dom({ user: userEvent.setup(), container: baseElement }))
  await form.signInAs('ada@example.com', 'hunter2')
  await expect(form.errorBanner).toBeAbsent()
})
Playwright
npm i -D @harnessed-ts/core @harnessed-ts/playwright
import { pw } from '@harnessed-ts/playwright'
import '@harnessed-ts/playwright/matchers'
import { expect, test } from '@playwright/test'

test('checks out', async ({ page }) => {
  const checkout = new CheckoutPage(pw(page))
  await checkout.goto({ token: 'abc' })
  const confirmation = await checkout.placeOrder()
  await expect(confirmation.heading).toReadAs('Thanks!')
})

Mocking the API:

import { mock } from '@harnessed-ts/playwright/mock'
await mock(pw(page)).use(UsersApi)
Ember (ember-qunit, ember-exam, Vitest)
npm i -D @harnessed-ts/core @harnessed-ts/ember @harnessed-ts/qunit
npm i -D @testing-library/dom @testing-library/user-event
// tests/test-helper.ts
import { install } from '@harnessed-ts/qunit'
install(QUnit)

// a rendering test
await render(<template><LoginForm /></template>)
const form = new LoginFormHarness(ember())
await form.signInAs('ada@example.com', 'hunter2')
await assert.harness(form.error).isAbsent()

// an application test: goto() drives the router
const checkout = new CheckoutPage(ember())
await checkout.goto({ token })

Mocking the API:

import { mock, setupMocks } from '@harnessed-ts/ember/mock'
setupMocks(hooks)
await mock(ember()).use(UsersApi)
Angular (zoneless, Vitest)
npm i -D @harnessed-ts/core @harnessed-ts/angular @testing-library/dom @testing-library/user-event
// vite.config.ts, with @analogjs/vite-plugin-angular: harness files get standard decorators
const HARNESS_FILES = /\.(harness|page)\.ts$/
plugins: [harnessedDecorators({ include: HARNESS_FILES }), angular({ transformFilter: (_c, id) => !HARNESS_FILES.test(id) })]

// a test: every action settles at fixture.whenStable()
import { angular } from '@harnessed-ts/angular'
const { fixture } = await render(LoginForm)
const form = new LoginFormHarness(angular({ fixture }))
await form.signInAs('ada@example.com', 'hunter2')
Cypress
npm i -D @harnessed-ts/core @harnessed-ts/cypress @harnessed-ts/chai \
  @testing-library/dom @testing-library/user-event
import '@harnessed-ts/cypress/support'
import { harnessedChai } from '@harnessed-ts/chai'
chai.use(harnessedChai)

it('signs in', () => {
  cy.visitPage(LoginPage)
  cy.harness(LoginFormHarness, async form => {
    await form.fillIn({ email: 'ada@example.com', password: 'hopper' })
    await form.submitIt()
    await expect(form).to.readAs(/Welcome/)
  })
})

Mocking the API:

// cypress/support/e2e.ts
import '@harnessed-ts/cypress/mock'
// in a test
cy.mock(api => api.use(UsersApi))
WebdriverIO
npm i -D @harnessed-ts/core @harnessed-ts/webdriverio   # WebdriverIO 10
import { wdio } from '@harnessed-ts/webdriverio'
import '@harnessed-ts/webdriverio/matchers'
import { browser, expect } from '@wdio/globals'

it('signs in', async () => {
  const login = new LoginPage(wdio(browser))
  await login.goto()
  await login.form.fillIn({ email: 'ada@example.com', password: 'hopper' })
  await expect(login.form.errorQuery).toBeAbsent()
})

Mocking the API:

import { mock } from '@harnessed-ts/webdriverio/mock'
await mock(wdio(browser)).use(UsersApi)
Vitest browser mode
npm i -D @harnessed-ts/core @harnessed-ts/vitest-browser @testing-library/dom
npm i -D @vitest/browser-playwright vitest-browser-react
import { vitestBrowser } from '@harnessed-ts/vitest-browser'
import '@harnessed-ts/vitest-browser/matchers'
import { render } from 'vitest-browser-react'

await render(<LoginForm />)
const form = new LoginFormHarness(vitestBrowser())
await form.signInAs('ada@example.com', 'hunter2')
expect(await form.error()).toBeNull()

Mocking the API:

import { mock } from '@harnessed-ts/vitest-browser/mock'
await mock(vitestBrowser()).use(UsersApi)
Nightwatch
npm i -D @harnessed-ts/core @harnessed-ts/nightwatch @harnessed-ts/chai chai
import { nightwatch } from '@harnessed-ts/nightwatch'

it('signs in', async function () {
  const login = new LoginPage(nightwatch(browser))
  await login.goto() // resolved against launch_url
  await login.form.signInAs('ada@example.com', 'hunter2')
  await chai.expect(login.form.errorBanner).to.be.absent
})
Puppeteer
npm i -D @harnessed-ts/core @harnessed-ts/puppeteer puppeteer
import { puppeteer } from '@harnessed-ts/puppeteer'
import '@harnessed-ts/puppeteer/matchers'
import { launch } from 'puppeteer'

const page = await (await launch()).newPage()
const login = new LoginPage(puppeteer(page, { baseURL: 'http://localhost:3000' }))
await login.goto()
await login.form.fillIn({ email: 'ada@example.com' })
await expect(login.form.errorQuery).toBeAbsent()

Mocking the API:

import { mock } from '@harnessed-ts/puppeteer/mock'
await mock(puppeteer(page)).use(UsersApi)
TestCafe
npm i -D @harnessed-ts/core @harnessed-ts/testcafe testcafe typescript
import { testcafe } from '@harnessed-ts/testcafe'

fixture('checkout').page('http://localhost:3000/cart')

test('applies a coupon', async t => {
  const cart = new CartPage(testcafe(t, { baseUrl: 'http://localhost:3000' }))
  await cart.coupon.apply('SAVE10')
  await t.expect(await cart.total.text()).eql('$90.00')
})

Mocking the API:

import { mock } from '@harnessed-ts/testcafe/mock'
await mock(testcafe(t)).use(UsersApi)
Storybook
npm i -D @harnessed-ts/core @harnessed-ts/storybook @testing-library/dom @testing-library/user-event
// .storybook/preview.ts
import { harnessed } from '@harnessed-ts/storybook'
export default { ...harnessed() } satisfies Preview

// LoginForm.stories.tsx
const meta = {
  title: 'Forms/Login Form',
  component: LoginForm,
  parameters: { harness: LoginFormHarness },
} satisfies Meta<typeof LoginForm>

export const WrongPassword: WithHarness<StoryObj<typeof meta>, LoginFormHarness> = {
  play: async ({ harness }) => {
    await harness.signInAs('ada@example.com', 'guess')
    await expect(await harness.error()).toBe('Wrong password')
  },
}

Ember stories (Embroider and Vite, Ember 6.8 or later) render through @harnessed-ts/storybook/ember under @storybook/html-vite, and every interaction waits for Ember to settle. Ember setup.

// .storybook/preview.ts
import { emberRenderer, harnessed } from '@harnessed-ts/storybook/ember'
export default { ...emberRenderer, ...harnessed() } satisfies Preview

Mocking the API:

// .storybook/preview.ts: harnessed() from '@harnessed-ts/storybook/mock'
export const Loaded = { parameters: { harnessed: { mocks: [UsersApi] } } }

Patterns

Use cases

One harness from unit test to end-to-end

Write selectors once. The harness that drives a component in a jsdom unit test drives the same component in a real browser, so the end-to-end suite never grows a second set of selectors that drifts out of step with the first.

Behaviour is held to the same rules under every driver: absence answers immediately, single-target operations are strict, and scopes don't leak. See the guarantees.

Page objects

@harnessed-ts/page gives one test object per screen: what it's made of, how to know it has arrived, and how to get there by URL. Pages construct under every driver; only goto() needs one with an address bar.

@Harness({ host: testId('page-checkout') })
class CheckoutPage extends PageHarness<{ token: string }> {
  override get path() {
    return '/checkout?token=$token'
  }

  @ChildHarness(CartHarness) accessor cart!: CartHarness

  protected async waitForReady(): Promise<void> {
    await this.self.waitFor('visible')
  }

  async placeOrder(): Promise<ConfirmationPage> {
    await this.cart.checkout()
    return this.transitionTo(ConfirmationPage)
  }
}

Read the guide →

Stories that test themselves

@harnessed-ts/storybook hands every play function its harness. Each harness method shows up as one step in the Interactions panel, with its clicks and keystrokes nested underneath, and Storybook's Vitest addon runs the same plays as tests. React and Ember stories alike.

The stories double as end-to-end fixtures: StoryPage opens one by URL from Playwright or any runner that navigates, before or after its play function runs.

import * as LoginStories from './LoginForm.stories'

const story = await StoryPage.goto(pw(page), LoginStories, 'WrongPassword', { play: false })
await story.harness(LoginFormHarness).signInAs('ada@example.com', 'hunter2')

Storybook setup

BDD with Gherkin

@harnessed-ts/gherkin adds a page registry, a {page} parameter type and a world for playwright-bdd, cucumber-js, Cypress cucumber, ember-cli-yadda and Nightwatch. Steps call harness methods, not selectors.

Feature: Checkout
  Scenario: Paying for the cart
    When I open the checkout page
    Then the total is "$90.00"
When('I open the {page} page', async ({ page, world }, name) => {
  world.checkout = pages.open(name, pw(page))
  await world.checkout.goto()
})

Then('the total is {string}', async ({ world }, total) => {
  expect(await world.checkout!.total()).toBe(total)
})

Gherkin setup for each runner

Tooling

  • @harnessed-ts/mock: one way to mock the API under every runner — handlers, API harnesses, response builders, factories, and checks of what was requested.
  • @harnessed-ts/eslint-plugin: keeps selectors and runner-specific mocks out of tests, and keeps harnesses speaking in behaviours rather than runner or DOM vocabulary.
  • @harnessed-ts/chai and @harnessed-ts/qunit: absent, selected and readAs assertions, and checks of mocked requests, for runners that don't use expect.extend.
  • @harnessed-ts/config: one harnessed.config.ts for shared settings such as the test-id attribute.
  • @harnessed-ts/claude: installs a Claude Code skill that writes harnesses in your project's conventions.

Read the guide →

// eslint.config.js
import harnessed from '@harnessed-ts/eslint-plugin'
export default [harnessed.configs.recommended]