- TypeScript 72.7%
- Shell 15.5%
- CSS 9.2%
- JavaScript 1.4%
- Dockerfile 0.9%
- Other 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| dist | ||
| docs/media | ||
| s | ||
| smoke | ||
| src | ||
| .editorconfig | ||
| .eslintrc.cjs | ||
| .gitignore | ||
| .nvmrc | ||
| .prettierrc | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| testrunner.html | ||
| testsandbox.html | ||
| tsconfig.headless.json | ||
| tsconfig.json | ||
| tsconfig.vite.json | ||
| vite.config.js | ||
gooey-test
gooey-test is an in-browser test runner.
Setup
npm install --save-dev @playwright/test # browser driver (headless mode)
npm install --save-dev vite # the dev server (see the note below)
npm install @srhazi/gooey # the framework your tests exercise
npx playwright install --with-deps chromium # firefox and webkit work too
Requirements:
Node >= 22.17, I use https://github.com/nvm-sh/nvmvite(>=4) -npm install --save-dev vite
vite is an optional peer dependency of gooey-test, not a regular one. Headless mode starts
your Vite so your config and plugins apply, and hosted mode runs inside your Vite too — so I
stay out of the way rather than pinning or shipping a second copy. Optional peers are not
auto-installed, so install vite yourself (the line above); a browser-only consumer that never
runs headless or hosted mode doesn't need it at all. Tested on Vite 4 and 8.
Then add the plugin to your Vite config — it owns the gooey-test/Vite integration for you:
// vite.config.ts
import { defineConfig } from 'vite';
import { gooeyTest } from '@srhazi/gooey-test/vite';
export default defineConfig({
plugins: [gooeyTest()],
});
That's all the wiring there is: hosted mode serves the runner at /@gooey-test/testrunner.html, and
headless mode applies the same plugin for you (merging it into your config).
Writing Tests
Put your tests in .test.ts / .test.tsx files under the current directory (skipping node_modules).
Test files need at least one test(name, () => { /* test stuff here */ }) function.
Place tests into groups with suite(name, () => { /* group similar tests here */ }).
Setup and teardown can be performed by beforeEach(() => { /* run before each test */ }) or afterEach(() => { /* run after each test regardless of success/failure */ }). Scoped to a suite or performed at the top level.
Assert in tests with assert.is(1 + 1, 2, "basic arithmetic works!") or any of the other methods in assert — the full list is below.
And if you want an HTML element to render to, testRoot is an HTMLElement that is automatically reset to an empty element in body before each test.
That's it!
- No mocks or spies or snapshot tests, those make your code smell bad. Instead, use dependency injection. Mocks aren't cool, use classical style for fast and reliable tests instead
- Test failures halt with a
debuggerright before a failing assertion, so you can inspect state right where it went wrong - Rendering to HTML is real and easy - automatic rendering and snapshotting of HTML (both raw HTML and screenshots) if
testRoothas content - It's snappy!
Running Tests
Headless mode works out of the box: it discovers your .test.{ts,tsx} files,
starts your Vite (so your config and plugins apply), and drives the runner in a
real browser.
# npm script (recommended: on PATH, version pinned)
# package.json -> { "scripts": { "test:headless": "gooey-test" } }
npm run test:headless
# yarn (classic)
yarn run gooey-test [options] [test-file ...]
# pnpm
pnpm exec gooey-test [options] [test-file ...]
# npx, without auto-install (never fetches; fails if not installed)
npx --no-install gooey-test [options] [test-file ...]
Use one package manager per tree. The runner resolves Vite from
node_modules/ itself, so mixing managers (e.g. npm install followed by
pnpm add) can leave a partial tree that neither manager owns.
Or call it programmatically:
import { runHeadless } from '@srhazi/gooey-test/headless';
const result = await runHeadless({ cwd: process.cwd() });
process.exit(result.exitCode);
In your browser
With the plugin in place, running tests in your browser is easy:
vite # Start your dev server
Then open http://localhost:5173/@gooey-test/testrunner.html
The plugin discovers every .test.ts / .test.tsx file under the current directory (skipping
node_modules) and runs them. Don't want everything? Append the files you do want, separated by
colons:
http://localhost:5173/@gooey-test/testrunner.html#/src/foo.test.ts:/src/bar.test.tsx
The #/... hash still works without the plugin too, if you'd rather point at the existing
node_modules/@srhazi/gooey-test/dist/testrunner/testrunner.html yourself:
http://localhost:5173/node_modules/@srhazi/gooey-test/dist/testrunner/testrunner.html#/src/foo.test.ts
Example
import {
afterEach,
assert,
beforeEach,
suite,
test,
testRoot,
} from '@srhazi/gooey-test';
// First some code we want to test
let todos: string[];
const render = () => {
const list = document.createElement('ul');
for (const todo of todos) {
const item = document.createElement('li');
item.textContent = todo;
list.append(item);
}
testRoot.append(list);
};
const addTodo = (todos: string[], todo: string) => {
if (todo.trim() === '') throw new Error('todo must not be empty');
todos.push(todo);
};
// Now some actual tests!
beforeEach(() => {
todos = [];
clicks = 0;
});
suite('todo list', () => {
test('remembers what we add, in order', () => {
addTodo(todos, 'buy milk');
addTodo(todos, 'walk the dog');
render();
// testRoot is always reset to a fresh, empty <div> before each test
const rendered = [...testRoot.querySelectorAll('li')].map(
(li) => li.textContent
);
assert.arrayIs(rendered, ['buy milk', 'walk the dog']);
});
test('starts empty', () => {
render();
assert.is(testRoot.querySelectorAll('li').length, 0);
});
test('intentionally fails', () => {
addTodo(todos, 'buy milk');
render();
// testRoot is always reset to a fresh, empty <div> before each test
const rendered = [...testRoot.querySelectorAll('li')].map(
(li) => li.textContent
);
assert.arrayIs(rendered, ['buy milk', 'walk the dog']);
});
});
API
Everything below is exported from @srhazi/gooey-test.
Test structure
// Define a test. The body may be async; a thrown error or rejected promise fails it.
function test(name: string, body: () => void | Promise<void>): void;
// Run only the marked test, skipping the rest of the files/suites/tests.
test.only = function (name: string, body: () => void | Promise<void>): void;
// Group tests. Suites nest; hooks are scoped to the suite that contains them.
function suite(name: string, body: () => void): void;
// Run only the marked suite, skipping the rest of the files/suites/tests.
suite.only = function (name: string, body: () => void): void;
// Run before every test in the current suite, outermost suite first.
function beforeEach(action: () => void | Promise<void>): void;
// Run after every test in the current suite — even on failure — innermost first.
function afterEach(action: () => void | Promise<void>): void;
// A fresh, empty `<div id="test-root">` appended to `<body>` before each test.
// Render into this; you never need to clean it up yourself.
let testRoot: HTMLElement;
// The version of gooey-test.
const VERSION: string;
assert
Every method takes an optional trailing msg that's shown when the assertion fails.
interface Assert {
fail(msg?: string): void; // fail immediately
is(a: any, b: any, msg?: string): void; // a === b
isNot(a: any, b: any, msg?: string): void; // a !== b
isTruthy(a: any, msg?: string): void; // !!a
isFalsy(a: any, msg?: string): void; // !a
lessThan(a: string | number, b: string | number, msg?: string): void;
lessThanOrEqual(a: string | number, b: string | number, msg?: string): void;
greaterThan(a: string | number, b: string | number, msg?: string): void;
greaterThanOrEqual(a: string | number, b: string | number, msg?: string): void;
arrayIs<T>(expected: T[], received: T[], msg?: string): void; // same length, === item-by-item
arrayIncludes(haystack: readonly any[], needle: any, msg?: string): void;
notArrayIncludes(haystack: readonly any[], needle: any, msg?: string): void;
arrayEqualsUnsorted(a: readonly any[], b: readonly any[], msg?: string): void; // same members, by reference
deepEqual(a: any, b: any, msg?: string): void; // deep equality (lodash isEqual)
notDeepEqual(a: any, b: any, msg?: string): void;
assertionCount(num: number, msg?: string): void; // assertions so far in this test === num
throwsMatching(match: string | RegExp, fn: () => void, msg?: string): void; // fn throws, and .message matches
medianRuntimeLessThan(
ms: number,
fn: (measure: <T>(measurement: () => T) => T) => void,
numRuns?: number,
msg?: string
): Promise<void>; // p50 of numRuns (default 29) < ms
}
Headless runner
The @srhazi/gooey-test/headless entry drives the runner in a real browser from Node. The full option list is in Test Runner Options.
function runHeadless(options?: HeadlessOptions): Promise<HeadlessResult>;
type BrowserName = 'chromium' | 'firefox' | 'webkit';
type HeadlessStatus = 'pass' | 'fail' | 'timeout' | 'error';
interface HeadlessResult {
status: HeadlessStatus; // outcome of the run
exitCode: number; // 0 pass, 1 fail/setup-error, 2 timeout
testCount: number; // tests that passed
fileCount: number; // files whose tests all passed
testRunnerUrl: string; // the testrunner URL that was loaded
keptOpen: boolean; // true when --debug-failures left the browser + Vite running
}
interface HeadlessOptions {
/** (--cwd) [default: `process.cwd()`] Directory tests and Vite run from. */
cwd?: string;
/** ([test-file ...]) [default: _discovered_] Explicit test files, relative to `cwd`. */
testFiles?: string[];
/** (--include / -i) [default: `**/*.test.{ts,tsx}`] Glob(s) used to discover test files. */
include?: string | string[];
/** (--exclude / -x) [default: `**/node_modules/**`] Glob(s) ignored while discovering test files. */
exclude?: string | string[];
/** (--host / -H) [default: `127.0.0.1`] Host Vite binds to. */
host?: string;
/** (--port / -p) [default: Vite default (5173)] Port for Vite. */
port?: number;
/** (--strict-port) [default: `false`] Fail instead of auto-incrementing when the port is taken. */
strictPort?: boolean;
/** (--config / -c) [default: Vite default] Path to a Vite config file. */
viteConfigFile?: string;
/** (--timeout / -t) [default: `60000`] How long to wait for the testrunner to finish. */
timeoutMs?: number;
/** (--browser / -b) [default: `chromium`] Browser to drive: `chromium`, `firefox`, or `webkit`. */
browser?: 'chromium' | 'firefox' | 'webkit';
/** (--debug-failures / -d) [default: `false`] On failure, leave the browser + Vite running and print the testrunner URL. */
debugFailures?: boolean;
/** (--remote-debugging-port / -r) [default: _unset_] Launch the browser with `--remote-debugging-port=<port>` (Chromium-only). */
remoteDebuggingPort?: number;
/** (--artifacts-dir / -a) [default: `<cwd>/.ci/failures`] Where failure screenshots + DOM snapshots are written. */
artifactsDir?: string;
/** (programmatic only) [default: `console.log`] Sink for progress output. */
log?: (line: string) => void;
}
Vite plugin
@srhazi/gooey-test/vite exports a standard Vite plugin. Add it to your config to get hosted
mode; headless mode reuses it automatically.
function gooeyTest(options?: GooeyTestPluginOptions): Plugin;
interface GooeyTestPluginOptions {
/** Explicit test files (relative to the Vite root). Skips discovery when set. */
testFiles?: string[];
/** Glob(s) used to discover test files. [default: `**\/*.test.{ts,tsx}`] */
include?: string | string[];
/** Glob(s) ignored while discovering. [default: `**\/node_modules/**`] */
exclude?: string | string[];
}
The runner is served under the stable prefix /@gooey-test/ (the entry is
/@gooey-test/testrunner.html).
Helpful Features
Real debugging
If you care about diagnosing failures, have it stop immediately when a test fails and leave the browser + dev server up:
gooey-test --debug-failures
# when a test fails, open chrome://inspect
Other browsers use their own debuggers rather than the Chrome DevTools Protocol:
- Firefox — open
about:debuggingand connect to the running instance. - Safari / WebKit — use Safari's Develop menu (Web Inspector); set
WEBKIT_INSPECTOR_SERVER=127.0.0.1:9222to expose WebKit's inspector.
And for any browser, Playwright's own inspector works too: PWDEBUG=1 gooey-test ….
Performance testing
Note: This is an unstable API.
If you want a performance assertion, you can write a test that looks like this:
test('performance test', async () => {
await assert.medianRuntimeLessThan(16, (measure) => {
const data = ...
const renderedResult = measure(() => {
return render(data);
});
cleanup(renderedResult);
});
});
It calls the function passed to measure numRuns times (default 29), after a
short warm-up, and asserts the median (p50) wallclock duration is under ms.
In addition to wallclock time, memory usage is measured too. That isn't assertable yet, but it's shown in the test output.
Note: the mechanism currently used for performance testing does not perform any error estimate calculation. Many would argue that this makes measurements mostly worthless, they are correct. This will be fixed eventually.