Writing Tests
Register suites and tests, use modifiers, table-driven cases, and timeouts.
#Guchho Test uses a suite/test registration API. Tests are registered when the file is loaded and executed afterwards by the runner.
This page covers suite structure, table-driven cases, async tests, timeouts, selection modifiers, and file discovery. The patterns here apply equally to JavaScript and TypeScript test files.
Basic structure
#import { describe, it, expect } from '@guchho/test';
describe('calculator', () => {
it('adds', () => {
expect(2 + 2).toBe(4);
});
});
suite is an alias for describe, and test is an alias for it.
Nested suites
#Suites can be nested. Full test names are joined with >:
describe('user', () => {
describe('validation', () => {
it('rejects empty names', () => {
// full name: "user > validation > rejects empty names"
});
});
});
Table-driven tests
#it.each and describe.each generate tests or suites from an array of argument
arrays. Placeholders in the title are replaced per case.
import { describe, it, expect } from '@guchho/test';
it.each([
[1, 1, 2],
[2, 3, 5],
[0, 0, 0],
])('adds %d + %d', (left, right, expected) => {
expect(left + right).toBe(expected);
});
describe.each passes the values to the suite callback:
describe.each([
['admin', ['read', 'write']],
['viewer', ['read']],
])('%s permissions', (role, permissions) => {
it('exposes the expected permissions', () => {
expect(getPermissions(role)).toEqual(permissions);
});
});
Supported placeholders:
| Placeholder | Value |
|---|---|
%s |
String value. |
%d, %i |
Integer value. |
%f |
Numeric value. |
%j |
JSON value. |
%o |
Inspected object value. |
%# |
Zero-based case index. |
%% |
Literal %. |
A title with more placeholders than a case provides throws a TypeError. Object
and tagged-template case forms are not implemented yet; see
ROADMAP.md.
Synchronous and asynchronous tests
#A test may return a promise. The runner awaits it and applies the per-test timeout.
it('resolves data', async () => {
const user = await loadUser(1);
expect(user.id).toBe(1);
});
A rejected promise fails the test.
Timeouts
#The default timeout is 5000 ms. Override it per test with the third argument:
it('is slow', async () => {
await delay(1000);
}, 10000);
Override the default for all tests in a run with --timeout or the timeout
configuration option. Test-level timeouts take precedence.
Modifiers
#Skip
#it.skip('not ready', () => {});
skip('also skipped', () => {});
A skipped test is reported with status skipped and is not executed.
Only
#it.only('focused', () => {
// runs
});
describe.only('focused suite', () => {
// all tests in this suite are focused
});
only('focused test', () => {});
When any only exists, tests that are not marked only (directly or through an
ancestor suite) are reported as skipped.
Todo
#it.todo('write this later');
todo('also later');
A todo test has no callback and is reported with status todo.
Concurrent
#it.concurrent('loads a user', async () => {
await loadUser(1);
});
describe.concurrent('requests', () => {
it('reads', async () => {
await request('/read');
});
it('writes', async () => {
await request('/write');
});
});
Tests marked concurrent run in parallel with other concurrent tests in the same
suite. describe.concurrent applies to the suite and all nested suites. A
non-concurrent test waits for the current concurrent batch to finish. Lifecycle
hooks run per test (beforeEach/afterEach) or once per suite
(beforeAll/afterAll), and results are reported in registration order.
Concurrent tests share the same process, so avoid shared mutable state unless you synchronize access yourself.
Run if / skip if
#it.runIf(process.platform === 'win32')('Windows only', () => {});
it.skipIf(process.env.CI === 'true')('local only', () => {});
it.runIf(condition) registers a normal test when the condition is true and a
skipped test otherwise. it.skipIf(condition) registers a normal test when the
condition is false. Both variants support .each.
Retries
#Pass retry in the options object:
it(
'flaky request',
async () => {
await requestThatSometimesFails();
},
{ retry: 2 },
);
Or use the retry modifier:
it.retry(2)('flaky request', async () => {
await requestThatSometimesFails();
});
A test runs at most retry + 1 times. Each attempt reruns beforeEach and
afterEach; beforeAll and afterAll run once per suite. Retries also apply to
failures from beforeEach and to timeouts. Use the options object to set both
values:
it(
'slow and flaky',
async () => {
await requestThatSometimesFails();
},
{ retry: 2, timeout: 10000 },
);
Filters
#Filter by substring of the full test name:
guchho-test run --filter validation
Filter by regular expression:
guchho-test run --testNamePattern "^user > validation"
Filtered-out tests are not reported.
Test files
#Supported extensions: .js, .mjs, .cjs, .ts, .mts, .cts, .jsx,
.tsx.
Files are discovered using the include and exclude patterns, then sorted for
deterministic execution. Explicit file arguments bypass discovery patterns but
must exist and be files.
Isolation
#Each test file runs in its own worker (thread by default, or child process with
--pool=forks). Module state and globals do not leak between files.
Best practices
#- Keep each test focused on one behavior and one reason to fail.
- Prefer
beforeEachover shared mutable state so tests remain independent. - Use table-driven tests for input variants, not for unrelated assertions.
- Await every promise returned by a test or hook; unawaited work can leak into the next test.
- Give tests descriptive names that state the expected behavior.
What happens with no tests
#If a test file registers no tests, the CLI prints a warning. If discovery finds
no test files at all, the run exits with code 2.