Back to Testing guides

A Beginner's Guide to Unit Testing with Node Tap

Stanley Ulili
Updated on April 4, 2025

Node Tap is a fast and lightweight testing tool for Node.js. It’s built to work smoothly with modern JavaScript projects.

Node Tap gives you a clean, easy-to-use API, quick test runs, and TAP-friendly output. It works great for both front-end and back-end JavaScript apps.

This tutorial will explore Node Tap’s main features and learn how to write and run tests effectively.

Prerequisites

To follow this guide, make sure you have Node.js installed—preferably the latest LTS version, which is 22 at the time of writing. You should also be comfortable writing basic JavaScript programs.

Step 1 — Setting up the directory

In this step, you’ll create a new project folder, set it up with npm, and install Node Tap so you can start writing tests.

Start by creating a new directory and moving into it:

 
mkdir node-tap-demo && cd node-tap-demo

Then, initialize the directory as an npm project:

 
npm init -y

This command creates a package.json file, which stores metadata and dependencies for your project.

Next, install Node Tap as a development dependency:

 
npm install -D tap

Then, enable ES Modules in your project:

 
npm pkg set type="module"

This command automatically adds "type": "module" to your package.json file without manually editing it, telling Node.js to treat all .js files as ES modules.

Now, create a .taprc file to help with ESM compatibility issues:

 
echo '{ "node-arg": ["--no-warnings"] }' > .taprc

This configuration file helps avoid common issues when running Node Tap with ES Modules.

After that, add a test script using npm:

 
npm pkg set scripts.test="tap"

This command automatically adds the test script to your package.json file.

You're now ready to create and test a simple JavaScript function using Node Tap. Here's the function in full:

math.js
export function add(a, b) {
  return a + b;
}

The add() function takes two numbers and returns their sum. Save this file at the root of your project.

Next, let's write the first test using Node Tap.

Step 2 — Writing your first test

Writing unit tests makes it easier to catch bugs early by checking if your functions return the right results in different situations. With Node Tap, you can automate these checks instead of testing everything manually.

Start by creating a test directory in your project root:

 
mkdir test

Next, create a file named math.test.js inside the test directory and add the following test:

test/math.test.js
import t from 'tap';
import { add } from '../math.js';

t.test('add function', (t) => {
  t.equal(add(1, 2), 3, 'should return 3 when adding 1 and 2');
  t.end();
});

The tap.test function organizes related tests within a test suite, providing a clear structure to the test file.

Within this suite, the test function defines individual test cases, each specifying a particular behavior to verify.

To ensure correctness, the t.equal function performs assertions, checking whether add(1, 2) returns 3 as expected.

Now that the test is written, let's run it in the next step.

Step 3 — Running your tests

Now that your test file is ready, it’s time to see it in action. Node Tap will automatically find and run any test files inside your test directory.

To execute all tests, run:

 
npm test

Node Tap automatically looks for test files inside the test folder and runs them for you. When you run your tests, you’ll see output in the terminal that looks something like this, with colors like green for passing tests:

Screenshot of test output

The test runner successfully detected and executed the math.test.js file, confirming that the test suite ran as expected. Alongside the results, the execution time for each test case is displayed, providing insight into the performance of the test run.

Step 4 — Test filtering and running specific tests

Running all your tests every time can get slow as your project grows. Node Tap lets you run only the tests you need to stay efficient. With built-in filtering options, you can quickly focus on specific test files or even individual test cases—making development and debugging much faster.

Using .only to focus on specific tests

If you want to run just one test or a group of tests within a file, you can use the .only method. This will execute only the test(s) marked with .only, allowing you to focus on specific functionality during development.

For example, if you're working on a feature and only want to run the add function test, modify the test like this:

test/math.test.js
import t from 'tap';
import { add } from '../math.js';

t.only('add function', (t) => {
t.equal(add(1, 2), 3, 'should return 3 when adding 1 and 2'); t.end(); });
t.test('add function additional', (t) => {
t.equal(add(2, 3), 5, 'should return 5 when adding 2 and 3');
t.end();
});

When you add .only to a test, Node Tap will run "only" that specific test and skip the rest. This is useful when you're debugging or focusing on a single feature.

Run the tests with the --only flag:

 
npm test -- --only

Only the test marked with .only will run—everything else will be ignored until you remove it:

Output
> tap-demo@1.0.0 test
> tap --only

 SKIP  test/math.test.js 1 skip of 2 654ms


  🌈 TEST COMPLETE 🌈  


 SKIP  test/math.test.js 1 skip of 2 654ms

Asserts:  1 pass  0 fail  1 skip  1 of 2 complete
Suites:   1 pass  0 fail  0 skip  1 of 1 complete

# { total: 2, pass: 1, skip: 1 }
# time=704.959ms

As shown in the output above, the first test ran successfully, while the second was skipped because it wasn't marked with .only.

This allows you to zero in on the functionality you're working on without running the entire suite.

Using .skip to temporarily exclude tests

If you want to leave out certain tests temporarily—maybe they’re still in progress or causing problems—you can use .skip. This tells Node Tap to ignore the test during the run.

For example, to skip the first test, change it like this:

test/math.test.js
import t from 'tap';
import { add } from '../math.js';

t.skip('add function', (t) => {
t.equal(add(1, 2), 3, 'should return 3 when adding 1 and 2'); t.end(); }); t.test('add function additional', (t) => { t.equal(add(2, 3), 5, 'should return 5 when adding 2 and 3'); t.end(); });

Run the tests:

 
npm test

The first test will be skipped, but the second will run:

Output
> tap-demo@1.0.0 test
> tap

 SKIP  test/math.test.js 1 skip of 2 580ms
~ add function


  🌈 TEST COMPLETE 🌈  


 SKIP  test/math.test.js 1 skip of 2 580ms
~ add function

Asserts:  1 pass  0 fail  1 skip  1 of 2 complete
Suites:   1 pass  0 fail  0 skip  1 of 1 complete

# { total: 2, pass: 1, skip: 1 }
# time=630.965ms

As shown in the output above, the test with .skip is excluded from the run, allowing you to focus on the tests that are relevant to your current work.

Filtering tests with command line arguments

Node Tap gives you flexible command-line options to run only the tests you care about—no need to change your code. This is super handy in CI/CD pipelines or when you just want to focus on a specific part of your project.

For example, let’s say you only want to run tests related to the add function. Here’s what the full test suite looks like without any filters:

test/math.test.js
import t from 'tap';
import { add } from '../math.js';

t.test('add function', (t) => {
  t.equal(add(1, 2), 3, 'should return 3 when adding 1 and 2');
  t.end();
});

t.test('add function additional', (t) => {
  t.equal(add(2, 3), 5, 'should return 5 when adding 2 and 3');
  t.end();
});

You can filter tests based on their description using the --grep flag. This allows you to run only tests that match a specific pattern in their description, which is perfect for isolating functionality while working on a particular feature or debugging an issue.

To run the tests that mention the additional in their description, use the following command:

 
npm test -- --grep "additional"     

The double dash -- separates npm's arguments from those passed to Node Tap. This command runs only tests that include the string "additional" in their descriptions. The output should look like this:

Output
> tap-demo@1.0.0 test
> tap --grep additional

 SKIP  test/math.test.js 1 skip of 2 677ms


  🌈 TEST COMPLETE 🌈  


 SKIP  test/math.test.js 1 skip of 2 677ms

Asserts:  1 pass  0 fail  1 skip  1 of 2 complete
Suites:   1 pass  0 fail  0 skip  1 of 1 complete

# { total: 2, pass: 1, skip: 1 }
# time=728.707ms

In this example, both tests related to the additional are run, and the output confirms that both tests passed.

Step 5 — Using in-source tests

Node Tap makes writing tests directly in your source code files easy, eliminating the need for separate test files. This approach, known as in-source testing, is perfect for quickly testing functions without creating and managing additional files.

For example, you can modify the math.js file that contains the add() function to include tests right alongside the code:

math.js
import t from 'tap';
export function add(a, b) { return a + b; }
// For in-source testing, Node.js ESM provides import.meta.url
// We can use this to detect if the file is being executed directly
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
// Only run tests when this file is executed directly
if (isMainModule) {
t.test('add function', (t) => {
t.equal(add(1, 2), 3, 'should return 3 when adding 1 and 2');
t.end();
});
t.test('add function additional', (t) => {
t.equal(add(2, 3), 5, 'should return 5 when adding 2 and 3');
t.end();
});
}

With this approach, the tests are written directly below the function. When you run the file with Node, Node Tap will automatically detect and run these tests:

 
node math.js

The output will look something like this:

Output
TAP version 13
TAP version 14
# Subtest: add function
    ok 1 - should return 3 when adding 1 and 2
    1..1
ok 1 - add function # time=1.583ms

# Subtest: add function additional
    ok 1 - should return 5 when adding 2 and 3
    1..1
ok 2 - add function additional # time=0.562ms

1..2
# { total: 2, pass: 2 }
# time=13.268ms

The output shows both tests ran successfully. The key here is the isMainModule check, which ensures tests only run when you execute the file directly—not when you import it into other files. This prevents your tests from running in production code.

While in-source testing is convenient for small or quick tests, separating tests from source code is generally better for larger projects. This separation improves maintainability and makes your codebase easier to navigate, especially when working in teams.

We'll stick with separate test files for the rest of this guide. Here's the original math.js function without the tests:

math.js
export function add(a, b) {
  return a + b;
}

Now that you've learned how to embed tests directly in your source files, let's move on to more advanced techniques like mocking external dependencies.

Step 6 — Mocking with Node Tap

When testing code that interacts with external dependencies like the file system, databases, or APIs, you often need to simulate these dependencies rather than using them directly. Node Tap provides a built-in mocking system that makes this easy.

First, create a fileReader.js file in your project root with a function that reads file contents:

fileReader.js
import fs from 'node:fs/promises';

export async function readFileContent(filePath) {
  try {
    const content = await fs.readFile(filePath, 'utf-8');
    return content.trim();
  } catch (err) {
    throw new Error(`Failed to read file: ${err.message}`);
  }
}

This function uses Node's native file system module to read a file and return its contents as a string.

Now, let's create a test file that uses Node Tap's mocking capabilities to test this function without accessing the real file system:

test/fileReader.test.js
import t from 'tap';

t.test('readFileContent function', async t => {
  // Create a mock for the fs module
  const mockFs = {
    readFile: async (path, options) => {
      // Verify the correct path was used
      t.equal(path, 'text-content.txt', 'should read the expected file');
      t.equal(options, 'utf-8', 'should use utf-8 encoding');

      // Return mock content
      return '  Mocked content  ';
    }
  };

  // Import the fileReader with our mock fs module
  const { readFileContent } = await t.mockImport('../fileReader.js', {
    'node:fs/promises': mockFs
  });

  // Test with our mocked function
  const content = await readFileContent('text-content.txt');
  t.equal(content, 'Mocked content', 'should return trimmed content from the mock');
});

t.test('readFileContent function handles errors', async t => {
  // Create a mock that throws an error
  const mockFs = {
    readFile: async (path, options) => {
      throw new Error('File not found');
    }
  };

  // Import with our error-throwing mock
  const { readFileContent } = await t.mockImport('../fileReader.js', {
    'node:fs/promises': mockFs
  });

  // Verify error handling
  try {
    await readFileContent('non-existent.txt');
    t.fail('should have thrown an error');
  } catch (err) {
    t.match(err.message, /Failed to read file: File not found/, 
            'should wrap the original error with additional context');
  }
});

This test uses Node Tap's t.mockImport() method, which is specifically designed for mocking dependencies in ES modules. When the tested module imports node:fs/promises, it will receive our mock version instead of the real one.

Our approach replaces the entire fs/promises module with a mock object containing just the necessary functions. The first test verifies that our function correctly calls readFile with the right arguments and properly trims the returned content. The second test confirms that errors are caught and wrapped with helpful context.

Run the tests to see them in action:

 
npm test
Output
> tap-demo@1.0.0 test
> tap

 PASS  test/math.test.js 2 OK 757ms
 PASS  test/fileReader.test.js 4 OK 770ms

---------------|---------|----------|---------|---------|-------------------
File           | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
---------------|---------|----------|---------|---------|-------------------
All files      |   68.75 |    83.33 |     100 |   68.75 |                  
 math.js       |   54.54 |    66.66 |     100 |   54.54 | 13-22            
---------------|---------|----------|---------|---------|-------------------


  🌈 TEST COMPLETE 🌈  


Asserts:  6 pass  0 fail  6 of 6 complete
Suites:   2 pass  0 fail  2 of 2 complete

# { total: 6, pass: 6 }
# time=831.286ms

Mocking has a few key benefits: it speeds up tests, removes reliance on real files, and gives you full control over what external dependencies return. This makes it easier to test edge cases and errors, while keeping your tests focused on your own code.

You can use this same pattern to mock APIs, databases, or any other external system—not just the file system.

Final thoughts

Node Tap is a powerful and lightweight tool that makes testing in Node.js straightforward and efficient. With support for ES modules, in-source testing, mocking, filtering, and built-in code coverage, it gives you everything you need to build reliable, well-tested JavaScript applications.

Whether you're writing simple unit tests or building out a full CI/CD pipeline, Node Tap helps you catch bugs early, speed up development, and keep your codebase solid

Author's avatar
Article by
Stanley Ulili
Stanley Ulili is a technical educator at Better Stack based in Malawi. He specializes in backend development and has freelanced for platforms like DigitalOcean, LogRocket, and AppSignal. Stanley is passionate about making complex topics accessible to developers.
Got an article suggestion? Let us know
Next article
A Beginner's Guide to Unit Testing with Pytest
Learn how to write clean, concise, and effective Python tests using Pytest's intuitive syntax, fixtures, parametrization, and rich plugin ecosystem
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github