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, Playwright, Cypress, WebdriverIO, Vitest browser mode, Puppeteer, TestCafe and Ember, with Gherkin on top. The same class drives a fast jsdom unit test 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 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, Playwright, Cypress, WebdriverIO, Vitest, Puppeteer, TestCafe and Ember.

Strict by default

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

Framework-agnostic

Harnesses query the DOM, not React or Ember.

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()

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.

Runner Package Environment Conformance
Testing Library dom jsdom ci status
Playwright playwright Real browser ci status Playwright Ember status
Ember (QUnit, exam, Vitest) ember Real browser ember-qunit status ember-exam status Ember Vitest status
Cypress (e2e and component) cypress Real browser Cypress e2e status Cypress component status
WebdriverIO webdriverio Real browser (BiDi, Classic) WebdriverIO status
Vitest browser mode vitest-browser Real browser Vitest browser status
Puppeteer puppeteer Real browser Puppeteer status
TestCafe testcafe Real browser TestCafe 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()
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!')
})
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 })
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/)
  })
})
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()
})
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()
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()
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')
})

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)
  }
}

BDD with Gherkin

@harnessed-ts/gherkin adds a page registry, a {page} parameter type and a world for playwright-bdd, cucumber-js, Cypress cucumber and ember-cli-yadda. 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/eslint-plugin: keeps selectors 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 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.
// eslint.config.js
import harnessed from '@harnessed-ts/eslint-plugin'
export default [harnessed.configs.recommended]