Write once
The same harness class drives jsdom unit tests and real-browser end-to-end tests.
Test harnesses for components and pages
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.
Why harnessed
The same harness class drives jsdom unit tests and real-browser end-to-end tests.
Testing Library, Playwright, Cypress, WebdriverIO, Vitest, Puppeteer, TestCafe and Ember.
Ambiguous matches fail loudly, and absence answers immediately, under every driver.
Harnesses query the DOM, not React or Ember.
Concepts
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()
}
}
render(<LoginForm />)
const form = new LoginFormHarness(dom({ user: userEvent.setup() }))
await form.signInAs('ada@example.com', 'hunter2')
expect(await form.error()).toBeNull()
const form = new LoginFormHarness(pw(page))
await form.signInAs('ada@example.com', 'hunter2')
await expect(form).toBeAbsent()
In action
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.
Playground
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.
Compatibility
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 |
|
| Playwright | playwright |
Real browser |
|
| Ember (QUnit, exam, Vitest) | ember |
Real browser |
|
| Cypress (e2e and component) | cypress |
Real browser |
|
| WebdriverIO | webdriverio |
Real browser (BiDi, Classic) |
|
| Vitest browser mode | vitest-browser |
Real browser |
|
| Puppeteer | puppeteer |
Real browser |
|
| TestCafe | testcafe |
Real browser |
|
Every runner needs @harnessed-ts/core and standard decorators; see
required setup.
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()
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!')
})
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 })
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/)
})
})
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()
})
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()
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()
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
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.
@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)
}
}
@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)
})
@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]