Skip to content
academia.sh

Lesson 17 / 20

Test

The built-in test runner, the boundary of a unit test, writing integration tests by separating the server from listening, a failing test's output, and reading the coverage report.

Contents

The previous four lessons built the measurement collector’s pieces: the summarizer, configuration, error classes, the log, the server. That these work correctly has been checked by hand so far, by looking at output.

Manual checking has two flaws: it repeats after every change, and edge cases get forgotten. This lesson makes the check runnable. The test runner and assertion module live inside the runtime, considered stable from a certain version onward.

Unit Test

A unit test tests a single module on its own, without touching the outside world. The unit under test has to be written to support this isolation — the reason the summarizing logic moved into a separate module in the HTTP Server lesson.

// summarizer.mjs
export function recordFromLine(line) {
  const record = JSON.parse(line);
  if (typeof record.node !== 'string' || typeof record.metric !== 'string') {
    throw new TypeError('node and metric must be strings');
  }
  if (!Number.isFinite(record.value)) {
    throw new TypeError('value must be a finite number');
  }
  return record;
}

export class Summarizer {
  #buckets = new Map();

  add({ node, metric, value }) {
    const key = `${node}/${metric}`;
    const bucket = this.#buckets.get(key) ?? { count: 0, total: 0, max: -Infinity };
    bucket.count += 1;
    bucket.total += value;
    bucket.max = Math.max(bucket.max, value);
    this.#buckets.set(key, bucket);
  }

  summary() {
    return [...this.#buckets].map(([key, { count, total, max }]) => ({
      key,
      count,
      average: Number((total / count).toFixed(2)),
      max,
    }));
  }
}
// summarizer.test.mjs
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { Summarizer, recordFromLine } from './summarizer.mjs';

describe('recordFromLine', () => {
  test('converts a valid line to a record', () => {
    const record = recordFromLine('{"node":"edge-01","metric":"humidity","value":48}');
    assert.deepEqual(record, { node: 'edge-01', metric: 'humidity', value: 48 });
  });

  test('throws SyntaxError for broken JSON', () => {
    assert.throws(() => recordFromLine('broken'), SyntaxError);
  });

  test('throws TypeError for a non-numeric value', () => {
    assert.throws(
      () => recordFromLine('{"node":"a","metric":"b","value":"twenty"}'),
      { name: 'TypeError' },
    );
  });
});

describe('Summarizer', () => {
  test('merges measurements under the same key', () => {
    const s = new Summarizer();
    s.add({ node: 'edge-01', metric: 'temperature', value: 21.4 });
    s.add({ node: 'edge-01', metric: 'temperature', value: 22.6 });
    assert.deepEqual(s.summary(), [
      { key: 'edge-01/temperature', count: 2, average: 22, max: 22.6 },
    ]);
  });

  test('an empty summarizer returns an empty array', () => {
    assert.deepEqual(new Summarizer().summary(), []);
  });
});
node --test summarizer.test.mjs
▶ recordFromLine
  ✔ converts a valid line to a record (0.77825ms)
  ✔ throws SyntaxError for broken JSON (0.14575ms)
  ✔ throws TypeError for a non-numeric value (0.078125ms)
✔ recordFromLine (1.624917ms)
▶ Summarizer
  ✔ merges measurements under the same key (0.112042ms)
  ✔ an empty summarizer returns an empty array (0.042583ms)
✔ Summarizer (0.216166ms)
ℹ tests 5
ℹ suites 2
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 37.945042

The duration values change on every run; what’s meaningful is the counters.

The node:assert/strict subpath makes comparisons with strict equality. The pitfalls of loose equality were covered in the JavaScript Fundamentals course; loose comparison in a test lets a test that should fail pass instead.

The second argument to assert.throws defines the expected error — a constructor, or an object to test fields against. Saying only “throw some error” is a weak test: an error thrown for the wrong reason passes it too.

Passing the Dependency in from Outside

The logger builder in the Logging lesson took the clock function and the output stream from outside. That design is exactly for testability:

// log.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Writable } from 'node:stream';
import { createLogger } from './log.mjs';

function capturingOutput() {
  const lines = [];
  const stream = new Writable({
    write(chunk, encoding, callback) { lines.push(chunk.toString('utf8')); callback(); },
  });
  return { stream, lines };
}

test('a record below the threshold is not written', () => {
  const { stream, lines } = capturingOutput();
  const logger = createLogger({ level: 'warn', service: 'test', output: stream, clock: () => 'T' });

  logger.info('should not appear');
  logger.warn('should appear');

  assert.equal(lines.length, 1);
  assert.deepEqual(JSON.parse(lines[0]), {
    time: 'T', level: 'warn', service: 'test', message: 'should appear',
  });
});

test('a child logger adds its fixed fields to every line', () => {
  const { stream, lines } = capturingOutput();
  const logger = createLogger({ service: 'test', output: stream, clock: () => 'T' });

  logger.child({ requestId: 'r-1' }).info('first', { status: 200 });

  assert.equal(JSON.parse(lines[0]).requestId, 'r-1');
});

Thanks to the fixed clock and the collecting stream, the test verifies the full record without touching real time or real output. No need to change a value inside the module during testing — the dependency is already passed in from outside.

Integration Test

An integration test tests several pieces together. For the HTTP service, the problem is this: service.mjs starts listening the moment it is imported, so it cannot be controlled from inside a test.

The solution: separate building the server from starting to listen. The module exports a factory function, and the caller decides when to listen.

// build-server.mjs
import { createServer } from 'node:http';
import { Summarizer, recordFromLine } from './summarizer.mjs';

export function buildServer() {
  const summarizer = new Summarizer();

  return createServer(async (request, response) => {
    const url = new URL(request.url, `http://${request.headers.host}`);

    if (request.method === 'POST' && url.pathname === '/measurement') {
      const chunks = [];
      for await (const chunk of request) chunks.push(chunk);
      const body = Buffer.concat(chunks).toString('utf8');
      try {
        let count = 0;
        for (const line of body.split('\n')) {
          if (line.trim() === '') continue;
          summarizer.add(recordFromLine(line));
          count += 1;
        }
        response.writeHead(201, { 'content-type': 'application/json' });
        response.end(JSON.stringify({ received: count }));
      } catch (error) {
        response.writeHead(400, { 'content-type': 'application/json' });
        response.end(JSON.stringify({ error: error.message }));
      }
      return;
    }

    if (request.method === 'GET' && url.pathname === '/summary') {
      response.writeHead(200, { 'content-type': 'application/json' });
      response.end(JSON.stringify(summarizer.summary()));
      return;
    }

    response.writeHead(404, { 'content-type': 'application/json' });
    response.end(JSON.stringify({ error: 'not found' }));
  });
}
// server.test.mjs
import { test, before, after, describe } from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { buildServer } from './build-server.mjs';

describe('measurement service', () => {
  let server;
  let base;

  before(async () => {
    server = buildServer();
    server.listen(0, '127.0.0.1');            // 0: the kernel picks a free port
    await once(server, 'listening');
    base = `http://127.0.0.1:${server.address().port}`;
  });

  after(async () => {
    server.close();
    await once(server, 'close');
  });

  test('posting a measurement returns 201', async () => {
    const response = await fetch(`${base}/measurement`, {
      method: 'POST',
      body: '{"node":"edge-01","metric":"humidity","value":48}\n',
    });
    assert.equal(response.status, 201);
    assert.deepEqual(await response.json(), { received: 1 });
  });

  test('the summary reflects the measurement sent', async () => {
    const response = await fetch(`${base}/summary`);
    assert.equal(response.status, 200);
    assert.deepEqual(await response.json(), [
      { key: 'edge-01/humidity', count: 1, average: 48, max: 48 },
    ]);
  });

  test('an unknown route returns 404', async () => {
    const response = await fetch(`${base}/missing`);
    assert.equal(response.status, 404);
  });
});
node --test server.test.mjs
▶ measurement service
  ✔ posting a measurement returns 201 (9.895416ms)
  ✔ the summary reflects the measurement sent (1.354708ms)
  ✔ an unknown route returns 404 (0.68975ms)
✔ measurement service (16.061875ms)
ℹ tests 3
ℹ suites 1
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 72.861542

Two details make this test reliable.

Zero was given as the port: the kernel picks a free ephemeral port, and the real number is learned through address(). A fixed number would let two tests running at once collide.

The after hook closes the server and waits for the close. A server never closed keeps the event loop alive, and the test process never ends.

The request during the test was sent with fetch — one of the interfaces the host environment inherited from the web platform. No separate client dependency was needed.

An end-to-end test goes one step further: it starts the build as a real process, runs it with real configuration, and sends requests from outside. spawn from the Child Processes lesson does this job. End-to-end tests are slow and brittle; kept few, limited to critical paths.

A Failing Test and Coverage

Seeing what a test prints when it fails is the shortest way to learn how to read the output. The test below deliberately carries an incomplete expectation:

// failing.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Summarizer } from './summarizer.mjs';

test('summary fields', () => {
  const s = new Summarizer();
  s.add({ node: 'edge-01', metric: 'humidity', value: 48 });
  assert.deepEqual(s.summary(), [{ key: 'edge-01/humidity', count: 1, average: 48 }]);
});
node --test failing.test.mjs; echo "exit code: $?"
✖ summary fields (1.018209ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 34.896208

✖ failing tests:

test at failing.test.mjs:5:1
✖ summary fields (1.018209ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
  + actual - expected
  
    [
      {
        average: 48,
        count: 1,
        key: 'edge-01/humidity',
  +     max: 48
exit code: 1

The diff report marks the field present in the actual value but not the expected one with +. A nonzero exit code lets the failure be reported when the test is called from an automation step.

To see which lines never ran, coverage measurement is turned on:

node --test --experimental-test-coverage summarizer.test.mjs
ℹ start of coverage report
ℹ ---------------------------------------------------------------
ℹ file           | line % | branch % | funcs % | uncovered lines
ℹ ---------------------------------------------------------------
ℹ summarizer.mjs |  93.75 |    90.91 |  100.00 | 4-5
ℹ ---------------------------------------------------------------
ℹ all files      |  93.75 |    90.91 |  100.00 | 
ℹ ---------------------------------------------------------------
ℹ end of coverage report

The report says lines 4 and 5 never ran: the branch that runs when node is not a string. Coverage percentage is not a target, it is an indicator — it shows which behavior was never tested, not that tested behavior was tested correctly.

Summary

  • A unit test tests a single module in isolation; this isolation requires keeping the logic in a module separate from the interface.
  • When dependencies (clock, output stream) are passed in from outside, the test verifies the full record without touching real time or real output.
  • When building the server is separated from starting to listen, the service opens up to integration testing; the test asks for port zero and closes the server at the end.
  • The strict assertion form leaves out the pitfalls of loose equality; throw tests also specify the expected error type.
  • The coverage report shows which behavior was never tested; it does not show that what was tested was tested correctly.

Next Step

Tests say expected behavior is preserved; they do not say why unexpected behavior happens. When the process slows down, memory grows, or it gets stuck somewhere unexpected, other tools are needed. The next lesson takes up the runtime’s inspector interface, CPU profiling, and heap snapshots.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close