Published

Test an Express.js App with node:test

Learn how to use the builtin Node Test Runner

Terminal output from the `node --test` program

Express is still a solid web framework option for Node.js, even in 2025. Especially since v5 was released in late 2024. If you are looking to get some test coverage on your Express app, look no further than the built-in Node.js Test Runner.

If you are looking for something more in-depth on the Test Runner, check out my full guide to the Node Test Runner.

Table of contents

Initial Setup

Start a new project if you want to follow along with this blog post. Set it up with npm init and be sure to set "type": "module" in the generated package.json so we can use ESM imports.

Installing Dependencies

In this example, we’ll be using Express version 5 and testing it using supertest. Run npm install express@^5 supertest to install them both.

Basic Express Setup

Here is a pretty basic Express app. The GET route returns some text and the POST route has some basic error handling for a pretend database call. It is split into two different files, index.js and app.js. Splitting the initialization of the Express app into app.js will make it easier for us to write tests for it.

// --- index.js ---
import { setupApi } from "./app.js";

const app = setupApi();

app.listen(3000, () => {
  console.log('🚀 http://localhost:3000');
});

// --- app.js ---
import express from 'express';

export const database = {
  saveData() {
    // Pretend this is a database call
  },
};

export function setupApi() {
  const app = express();

  app.get('/', (req, res) => {
    res.send('Get Received');
  })

  app.post('/', (req, res) => {
    try {
      database.saveData();
    }
    catch (e) {
      res.status(500);
      res.send('Received an error');
      return;
    }

    res.send('Post Received');
  });

  return app;
}

To run this app, simply point Node at it: node index.js.

Writing Tests

Writing tests with node:test is very simple, especially if you are familiar with other testing frameworks like Jest or Mocha. Here is what our test file looks like:

// --- app.test.js ---
import { test, describe, before } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { setupApi, database } from "./app.js";

// Top level test suite
describe('API', () => {
  let app;

  // Before any tests run, setup our Express app
  before(async () => {
    app = setupApi();
  });

  // Test the GET request
  test('GET request', async () => {
    // Supertest has built-in tests/assertions for response codes
    const { text } = await request(app).get('/').expect(200);
    assert.equal(text, "Get Received");
  });

  // A sub test suite so we can batch related tests
  describe('POST request', () => {
    // Test the POST request
    test("success", async () => {
      const { text } = await request(app).post("/").expect(200);
      assert.equal(text, "Post Received");
    });

    // We'll come back to this later. The Node Test Runner will print out that we have a test TODO later.
    test.todo('error');
  });
});

Why Are We Importing from node:test?

If you try to just import test instead of node:test you’ll get an error like this: Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'test' imported from .../app.test.js.

The node: prefix tells Node that we are importing a core module. In this way, a package from NPM won’t be accidentally called if it has the same name as a core module. node:test came after the node: prefix so node does not offer a non-prefixed version.

Why Are We Importing from node:assert/strict?

There is a strict version of assert and a non-strict. I’ve chosen the strict version for the same reasons you would choose === over ==. Just like false === 0 is false, assert.equal(false, 0) will throw an error because they aren’t strictly the same. For most use cases, node:assert/strict is probably what you want to use.

Running the Tests

node --test

Yep! It is that simple. Node will automatically run any tests that match a specific criteria, like those in a test directory or ending with .test (ex: app.test.js).

If you want to get fancy, run node --test --watch and Node will automatically rerun the tests. Just use CTRL + C to kill the process (or the Mac equivalent).

The Node test runner will execute all the tests and print out if they succeeded or not. Then at the end, it will print a nice summary of all the tests.

Node Test Runner output

Testing Error Handling With Mocks

You may have noticed, our POST method has some error handling. How exactly can we trigger the catch part of that try/catch statement? The Node Test Runner has built-in mocking that we can use exactly for this. By mocking our saveData function we can test that the error handling works as expected.

// --- app.test.js ---

// ...
test('error', async (t) => {
  const mock = t.mock.method(database, 'saveData', () => {
    throw new Error('uh-oh!');
  });

  await request(app).post("/").expect(500);
  assert.equal(mock.mock.calls.length, 1);
});
// ...

Conclusion

That is about it for basic tests on a Node Express app. If you want to see the full source code used in this example, check out the GitLab repository.

If you want to check out a slightly more complex test suit, take a look at the tests for the OpenStore API. I migrated that project from using Mocha to the Node Test Runner, which inspired this post.

Further Reading

Related Posts