node-tap: TAP-Based Testing for Node.js with Powerful Assertions

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 tap

Basic 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 tap

Important: 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.js

Or 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 --coverage

Set 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 --snapshot

Snapshots 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.info

Reporters

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 XML

When 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.mock style)
  • Your team is already on Jest/Vitest and migration cost isn't worth it

Key Takeaways

  • Always call t.end() or t.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=N for parallel file execution in CI

Read more