Skip to content
weftGitHub

Core

Testing

@weft-js/core/test renders components under vitest and drives them as a user would. weft check also flags tests that would pass whatever the code did.

A new app has no test runner. Add one:

bash
npm install -D vitest jsdom @testing-library/dom @testing-library/user-event

<!-- example: skip @weft-js/core/vite has no type declarations yet (TS7016 under strict) -->

ts
// vitest.config.tsimport weft from '@weft-js/core/vite'import { defineConfig } from 'vitest/config'export default defineConfig({ plugins: [weft()], test: { environment: 'jsdom' } })

Then npx vitest run. A test:

tsx
import { state } from '@weft-js/core'import { render, screen, user } from '@weft-js/core/test'import { expect, test } from 'vitest'function Cart() {  const count = state(0)  return <button type="button" onClick={() => count.value++}>Cart ({count.value})</button>}test('adding to the cart', async () => {  render(Cart)  await user.click(screen.getByRole('button', { name: 'Cart (0)' }))  expect(screen.getByRole('button', { name: 'Cart (1)' })).toBeTruthy()  await expect(() => user.click(screen.getByRole('button'))).toTouchAtMost(1, { runs: 2 })})
  • render(Component, props, options) mounts into the document with a fresh app; unmounted after each test. options.state seeds defineState scopes, options.storage (memoryStorage()) is shared between renders to play two tabs or a reload, and options.slots binds plugin slots. scope(handle) runs one scope with no UI.
  • flush() applies pending updates. Every user action and clock.advance already ends in one.
  • screen has role, label and text queries only (getByRole, getByLabelText, getByText, with getAll/query/find).
  • user.click, dblClick, type, clear, keyboard, hover, unhover, tab, selectOptions, deselectOptions, upload and paste (@testing-library/user-event) each end in flush(): never sleep or waitFor.
  • clock.install() before the code schedules timers, then await clock.advance(ms) (or clock.runAll()): drives timers, rAF, Date and the resources that wait on them, and flushes. Uninstalled after each test.
  • touches(fn) returns { mutations, runs, … } for one interaction. toTouchAtMost(n, { runs }) bounds both: DOM writes alone miss identity bugs that re-run every row but write the DOM once (W014).

Junk tests: T001–T009

weft check reports these on test files (test.include in the config). A test that finishes without an assertion also fails at runtime (T001). T012 and T013 are about open listeners and real sockets: weft docs browser and weft docs start; T010, T011 and T020 are the gate: weft docs verify.

CodeCatchesInstead
T001No assertion, or expect(x) with no matcherAssert what the user sees
T002expect(true).toBe(true), expect(x).toBe(x)Assert on the code's output
T003n.value = 5; expect(n.value).toBe(5)Act through the UI, assert what depends on it
T004setTimeout sleeps, real timers, waitForflush(), clock.advance(ms)
T005container, selectors, classes, innerHTML, test ids, app state in a component test (warning)Role, label, text
T006vi.mock('@weft-js/core'), mocking the component under testFake the edges (API, storage)
T007SnapshotsBehavior assertions
T008expect under if/?:/&&/catch, or only inside loopsAssert the condition, then assert unconditionally
T009.only, .skip, fit, xitRemove it; test.fails for a test meant to fail

A deliberate exception needs a reason: // weft-allow T007: the exported SVG is the product. In the file's header comments it covers the whole file; elsewhere, its own line and the next.

Plugins can use this clock too: fakePay from @weft-js/pay/test plays a rail on it, through your real webhook and ledger. weft docs pay.