Learning Node.js - API Testing and Integration with Modern Tools
Episode 18 of 23

Learning Node.js - API Testing and Integration with Modern Tools

This episode tests APIs with modern tools: the built-in node:test test runner, HTTP endpoint testing with supertest, mocking for isolation, and coverage measurement. You lock down the API behavior so changes don't break features.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Untested code is a time bomb: one small change can break an unexpected feature. Testing changes that — you write expectations as code, then run them every time the application changes. In the Node.js world, testing tools are getting simpler and simpler.

Episode 18 puts modern API testing into practice: the built-in node:test test runner, HTTP endpoint testing with supertest, mocking to isolate the database, and coverage measurement. By the end of the episode, you'll have a test suite that runs in the terminal and is ready for CI.

The Built-in node:test Test Runner

Writing Your First Test

Since Node.js version 18, a test runner is available without installing anything. The node:test module provides test and assert:

JSFirst test with node:test
import { test } from "node:test";
import assert from "node:assert/strict";
 
function tambah(a, b) {
  return a + b;
}
 
test("tambah menjumlahkan dua angka", () => {
  assert.equal(tambah(2, 3), 5);
});

test("name", () => ...) defines a test case, and assert.equal(tambah(2, 3), 5) checks the result. Run it with node --test: the runner finds test files, runs them, and reports the results.

Running the Suite

Run tests
node --test

node --test finds files matching patterns like *.test.js and runs them. Add "test": "node --test" to the scripts in package.json so the whole team uses the same command — later in episode 22, this command will be used by the CI pipeline.

Testing the HTTP API with Supertest

Testing Without Starting the Server

supertest lets you test an HTTP server without opening it to a port — requests are sent to the server function directly:

Install supertest
npm install --save-dev supertest

npm install --save-dev supertest adds supertest to devDependencies because it's only used during development and testing.

Testing Express Endpoints

JSTest endpoints with supertest
import { test } from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { app } from "./app.js";
 
test("GET /api/pengguna returns a list", async () => {
  const respons = await request(app).get("/api/pengguna");
 
  assert.equal(respons.status, 200);
  assert.equal(respons.body.status, "success");
  assert.ok(Array.isArray(respons.body.data));
});

request(app).get("/api/pengguna") sends a request to the Express application without starting a server. Notice that app must be exported from a separate module, and app.listen moves to another file — this pattern makes the application easy to test. The assertions check the status code and body shape.

Mocking and Isolation

Injecting a Database Replacement

Tests that depend on a real database are slow and brittle. Use mocking to replace external parts with behavior you control. The simplest way: accept dependencies as parameters:

JSFunction with injected dependencies
export function buatHandler(database) {
  return async (req, res) => {
    const data = await database.ambilSemua();
    res.json(data);
  };
}

With the dependency injection pattern above, tests can pass in a fake database. database.ambilSemua() is implemented as a function returning fake data — with no real connection. node:test also provides mock.fn and mock.method to create these function replacements.

Testing Error Cases

Don't just test the happy path. Also test error responses so the application behaves correctly when something fails:

JSTest error cases
test("404 when the user doesn't exist", async () => {
  const respons = await request(app).get("/api/pengguna/999");
 
  assert.equal(respons.status, 404);
  assert.equal(respons.body.status, "error");
});

The test above ensures a user with a non-existent id gets a 404. Well-tested error cases prevent bugs that only appear under certain conditions — and give you confidence when refactoring.

Coverage and CI

Measuring How Much Code Is Tested

Coverage shows the percentage of code executed by tests. Node.js has built-in coverage via V8:

Run tests with coverage
node --test --experimental-test-coverage

node --test --experimental-test-coverage reports the lines, functions, and branches covered by tests. A reasonable target isn't 100 percent, but stable, targeted coverage of important code — high coverage doesn't guarantee correct code, it only signals what's untested.

Connecting to CI

Tests that don't run automatically are just a script. In episode 22, we'll connect npm test to the CI pipeline so every push runs the whole suite — a change that breaks tests immediately fails the build.

Closing

Here's what to take away:

  • node:test provides a built-in runner and assertions without installing anything.
  • Supertest tests HTTP endpoints without starting a server.
  • Separate app from listen so it's easy to test.
  • Mock external dependencies for fast, stable tests.
  • Test success and error cases in balance.
  • Coverage is measured and connected to CI.

In the next episode, episode 19, we'll discuss performance tuning, the event loop, and profiling — the event loop phases, causes of blocking, CPU profiling with the Node Inspector, load testing with autocannon, and memory leak detection. You'll build an API that's fast and stable under load.

Learning Node.js - API Testing and Integration with Modern Tools | Learn Node.js