node-tap: TAP-Based Testing for Node.js with Powerful Assertions
node-tap is a TAP-producing test runner for Node.js. TAP (Test Anything Protocol) is a text-based protocol for reporting test results that's been around since Perl — node-tap implements it with a rich assertion library, subtests, parallel execution, and built-in code coverage.
Installing node-tap
npm install --save-dev tapBasic Tests
import t from 'tap';
t.test('adds numbers', t => {
t.equal(1 + 1, 2);
t.end();
});
t.test('checks an object', t => {
const user = { id: 1, name: 'Alice' };
t.equal(user.id, 1);
t.equal(user.name, 'Alice');
t.end();
});Run:
npx tap tests/math.test.js
# or run all test files
npx tapImportant: t.end() and t.plan()
Every subtest must signal completion with either t.end() or t.plan(n):
// t.end() — call when done
t.test('with end', t => {
t.ok(true);
t.end();
});
// t.plan(n) — declare how many assertions will run
t.test('with plan', t => {
t.plan(2);
t.equal(1, 1);
t.equal(2, 2);
// tap auto-ends when plan count is reached
});Assertions
tap has a large assertion library:
t.ok(value) // truthy
t.notOk(value) // falsy
t.equal(actual, expected) // strict equality (===)
t.not(actual, expected) // not strictly equal
t.same(actual, expected) // deep equality
t.notSame(actual, expected)
t.match(actual, pattern) // string/regex/object partial match
t.notMatch(actual, pattern)
t.type(actual, 'string') // typeof
t.type(actual, Array) // instanceof
t.throws(fn, expected?)
t.rejects(asyncFn, expected?)
t.doesNotThrow(fn)
t.resolves(asyncFn)
t.resolveMatch(asyncFn, pattern)t.match for partial matching
t.match is particularly useful for partial object matching:
t.test('user has required fields', t => {
const user = { id: 1, name: 'Alice', createdAt: new Date() };
t.match(user, { id: 1, name: 'Alice' }); // ignores extra fields
t.end();
});Async Tests
Return a promise or use async/await — t.end() is not needed when you return a promise:
t.test('fetches user', async t => {
const user = await getUser(1);
t.equal(user.id, 1);
t.ok(user.name);
});
t.test('rejects on invalid id', async t => {
await t.rejects(getUser(-1), { message: 'Invalid user ID' });
});Subtests and Test Organization
Nest tests with t.test() for hierarchical organization:
import t from 'tap';
t.test('UserService', async t => {
t.test('createUser', async t => {
t.test('with valid data', async t => {
const user = await UserService.create({ name: 'Alice', email: 'alice@example.com' });
t.ok(user.id);
t.equal(user.name, 'Alice');
});
t.test('throws on missing email', async t => {
await t.rejects(
UserService.create({ name: 'Alice' }),
{ message: 'email is required' }
);
});
});
t.test('deleteUser', async t => {
t.test('removes the user', async t => {
const user = await UserService.create({ name: 'Bob', email: 'bob@example.com' });
await UserService.delete(user.id);
await t.rejects(UserService.get(user.id), { message: 'Not found' });
});
});
});Before/After Hooks
import t from 'tap';
t.before(async () => {
await db.migrate();
});
t.teardown(async () => {
await db.close();
});
t.beforeEach(async () => {
await db.seed();
});
t.afterEach(async () => {
await db.truncate();
});
t.test('finds seeded user', async t => {
const user = await db.findUser(1);
t.ok(user);
});Parallel Tests
Run test files in parallel:
npx tap --jobs=4 tests/**/*.test.jsOr in package.json:
{
"tap": {
"jobs": 4
}
}Individual test cases within a file are always sequential.
Built-in Code Coverage
tap integrates with c8 (V8 coverage) automatically:
npx tap --coverageSet coverage thresholds in package.json:
{
"tap": {
"coverage": true,
"coverage-map": "coverage-map.js",
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}Snapshot Testing
tap has built-in snapshot support:
t.test('renders HTML', t => {
const html = renderComponent({ title: 'Hello' });
t.matchSnapshot(html, 'component renders correctly');
t.end();
});Update snapshots:
npx tap --snapshotSnapshots are stored in tap-snapshots/ alongside your test files.
Filtering Tests
# Run tests matching a pattern
npx tap --grep=<span class="hljs-string">"UserService"
<span class="hljs-comment"># Skip tests matching a pattern
npx tap --grep-skip=<span class="hljs-string">"slow"CI Configuration
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Run tests with coverage
run: npx tap --coverage --reporter=spec
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.infoReporters
npx tap --reporter=spec # spec-style output
npx tap --reporter=min <span class="hljs-comment"># minimal output
npx tap --reporter=tap <span class="hljs-comment"># raw TAP protocol
npx tap --reporter=junit <span class="hljs-comment"># JUnit XMLWhen to Choose node-tap
node-tap works well when:
- You want TAP-compatible output for integration with TAP consumers
- You need hierarchical subtests with rich assertion messages
- You want built-in coverage without configuring a separate tool
Consider alternatives when:
- You need heavy module mocking (
jest.mockstyle) - Your team is already on Jest/Vitest and migration cost isn't worth it
Key Takeaways
- Always call
t.end()ort.plan()in synchronous tests; async tests auto-close on promise resolution t.match()is the most flexible assertion — it handles strings, regexes, and partial object matching- Built-in coverage (
--coverage) works with zero configuration - Subtests create a clean hierarchy in test output and isolate failures
- Use
--jobs=Nfor parallel file execution in CI