
メディアサプライチェーン技術部開発第2グループの長谷川です。
2023年4月にリリースされた Node.js v20 から、組み込みのテストランナーとして node:test が安定版機能となりました。新しく開発に取り組んだバックエンドのNode.jsのプロジェクトでユニットテストを整備するにあたり、外部ライブラリに依存せずに手軽にテストを書ける組み込みテストランナーを使用してみた経験から機能の概要、実際にテストコードを書く方法、主要なオプションなどをご紹介します。バックエンドの Node.js プロジェクトにテストを導入したい方の参考になれば幸いです。
node:test とは
node:testはNode.js組み込みのテストツールです。node:testはv18(2022年4月)で試験的に導入され、v20(2023年4月)で安定版となりました。直近のv24(2025年5月)でも新たな機能が追加されるなど、開発が続いています。従来、JavaScript のテストには JestやMocha、Vitest といった外部ライブラリの利用が主流でした。一方で、node:testは外部ライブラリに依存せず、シンプルではありますが基本的な機能を備えた軽量なテストモジュールです。
基本的な使い方
基本的な使い方は、テストコードを**/*.test.js あるいは **/*.spec.js として作成します。node:testモジュールと、値の検証を行うためのnode:assertモジュールを使ってテストコードを書き、ルートディレクトリでテストコマンドnode --testを実行すると、対象となるファイルが一括でテストされます。
import test from 'node:test'; import assert from 'node:assert'; import { Calculator } from '../calculator.js'; test('最初のテスト: 2つの数値を足し算する', () => { const calc = new Calculator(); const result = calc.add(2, 3); assert.strictEqual(result, 5, '2 + 3 は 5 であるべき'); });
test() 関数の基本的な使い方と、テストの成功・失敗の判定ルールは以下の通りです。
test(name, fn): 第一引数にテスト名、第二引数にテスト処理を記述するコールバック関数を取ります。
成功: テスト関数が例外をスローせずに正常に完了した場合。
失敗: テスト関数内で例外がスローされた場合(assertの検証失敗など)。
実践的な例: CRUDアプリケーションのテスト
例えば、Express.jsなどのフレームワークで使用されることを想定し、簡単なCRUDを行う以下のようなバックエンドのコードベースを例として考えてみます。
.
├── package.json
├── src
│ ├── controllers
│ │ └── employee.controller.js
│ ├── entities
│ │ └── employee.entity.js
│ ├── repositories
│ │ └── employee.repository.js
│ └── services
│ └── employee.service.js
└── test
├── controllers
│ └── employee.controller.test.js
└── services
└── employee.service.test.js
employee.repository.js
// DBの代わりにインメモリのデータストアを使用 const db = new Map(); let nextId = 1; // データをリセットするテスト用の関数 const reset = () => { db.clear(); nextId = 1; }; const save = (employee) => { if (employee.id) { // IDがあれば更新 if (!db.has(employee.id)) { throw new Error(`Employee with id ${employee.id} not found.`); } db.set(employee.id, employee); return employee; } else { // IDがなければ新規作成 employee.id = nextId++; db.set(employee.id, employee); return employee; } }; const findById = (id) => { return db.get(id); }; const findAll = () => { return Array.from(db.values()); }; const deleteById = (id) => { if (!db.has(id)) { return false; } db.delete(id); return true; }; module.exports = { save, findById, findAll, deleteById, reset // テスト用にエクスポート };
employee.service.js
const employeeRepository = require('../repositories/employee.repository'); const Employee = require('../entities/employee.entity'); const createEmployee = (employeeData) => { const { name, position } = employeeData; if (!name || !position) { throw new Error('Missing required fields: name, position'); } const employee = new Employee(name, position); return employeeRepository.save(employee); }; const getEmployeeById = (id) => { return employeeRepository.findById(id); }; const getAllEmployees = () => { return employeeRepository.findAll(); }; const updateEmployee = (id, employeeData) => { const employee = employeeRepository.findById(id); if (!employee) { return null; } // employeeDataのプロパティで上書きする Object.assign(employee, employeeData); return employeeRepository.save(employee); }; const deleteEmployee = (id) => { return employeeRepository.deleteById(id); }; module.exports = { createEmployee, getEmployeeById, getAllEmployees, updateEmployee, deleteEmployee };
employee.controller.js
const employeeService = require('../services/employee.service'); const create = (req, res) => { try { const employee = employeeService.createEmployee(req.body); res.status(201).json(employee); } catch (error) { res.status(400).json({ message: error.message }); } }; const getAll = (req, res) => { const employees = employeeService.getAllEmployees(); res.status(200).json(employees); }; const getById = (req, res) => { const id = parseInt(req.params.id, 10); const employee = employeeService.getEmployeeById(id); if (employee) { res.status(200).json(employee); } else { res.status(404).json({ message: 'Employee not found' }); } }; const update = (req, res) => { const id = parseInt(req.params.id, 10); const employee = employeeService.updateEmployee(id, req.body); if (employee) { res.status(200).json(employee); } else { res.status(404).json({ message: 'Employee not found' }); } }; const remove = (req, res) => { const id = parseInt(req.params.id, 10); const success = employeeService.deleteEmployee(id); if (success) { res.status(204).send(); // No Content } else { res.status(404).json({ message: 'Employee not found' }); } }; module.exports = { create, getAll, getById, update, remove };
employee.service.jsに対しては以下のようなテストコードが想定されます。
employee.service.test.js
const { test, beforeEach, describe } = require('node:test'); const assert = require('node:assert'); const employeeService = require('../../src/services/employee.service'); const employeeRepository = require('../../src/repositories/employee.repository'); // describeでテストスイートをグループ化 describe('EmployeeService', () => { // 各テストの前にデータベースの状態をリセットする beforeEach(() => { employeeRepository.reset(); }); test('should create a new employee', () => { const employeeData = { name: 'Alice', position: 'Developer' }; const employee = employeeService.createEmployee(employeeData); assert.strictEqual(employee.id, 1); assert.strictEqual(employee.name, 'Alice'); assert.deepStrictEqual(employeeService.getEmployeeById(1), employee); }); test('should update an existing employee', () => { const created = employeeService.createEmployee({ name: 'Bob', position: 'Manager' }); const updatedData = { name: 'Robert' }; const updated = employeeService.updateEmployee(created.id, updatedData); assert.strictEqual(updated.id, created.id); assert.strictEqual(updated.name, 'Robert'); assert.strictEqual(updated.position, 'Manager'); // 更新されなかったプロパティは維持される }); test('should return null when updating a non-existent employee', () => { const result = employeeService.updateEmployee(999, { name: 'ghost' }); assert.strictEqual(result, null); }); test('should delete an employee', () => { const employee = employeeService.createEmployee({ name: 'Charlie', position: 'Designer' }); const success = employeeService.deleteEmployee(employee.id); assert.strictEqual(success, true); assert.strictEqual(employeeService.getEmployeeById(employee.id), undefined); }); });
beforeEachフックを使うことで、各テストが実行される前にデータベースの状態をリセットし、テスト間の状態が影響し合わないように独立性を保っています。
次に、employee.controller.jsに対しては以下のようなテストコードが想定されます。
employee.controller.test.js
const { test, mock } = require('node:test'); const assert = require('node:assert'); // テスト対象のController const employeeController = require('../../src/controllers/employee.controller'); // モックする対象のService const employeeService = require('../../src/services/employee.service'); // レスポンスオブジェクトのモック // status()とjson()が呼び出されたことを記録する const mockResponse = () => { const res = {}; res.status = mock.fn(() => res); res.json = mock.fn(() => res); res.send = mock.fn(() => res); return res; }; test('GET /employees/:id - should return an employee if found', () => { // 準備: ServiceのgetEmployeeByIdが特定の値を返すように設定 const fakeEmployee = { id: 1, name: 'Test User' }; mock.method(employeeService, 'getEmployeeById', () => fakeEmployee); const req = { params: { id: '1' } }; const res = mockResponse(); // 実行 employeeController.getById(req, res); // 検証: // 1. Serviceのメソッドが正しい引数で呼び出されたか assert.strictEqual(employeeService.getEmployeeById.mock.calls.length, 1); assert.strictEqual(employeeService.getEmployeeById.mock.calls[0].arguments[0], 1); // 2. レスポンスが正しいステータスコードとJSONで呼び出されたか assert.strictEqual(res.status.mock.calls[0].arguments[0], 200); assert.deepStrictEqual(res.json.mock.calls[0].arguments[0], fakeEmployee); // 後片付け mock.restoreAll(); }); test('GET /employees/:id - should return 404 if not found', () => { // 準備: Serviceがnull (undefined) を返すように設定 mock.method(employeeService, 'getEmployeeById', () => undefined); const req = { params: { id: '999' } }; const res = mockResponse(); // 実行 employeeController.getById(req, res); // 検証: assert.strictEqual(res.status.mock.calls[0].arguments[0], 404); assert.deepStrictEqual(res.json.mock.calls[0].arguments[0], { message: 'Employee not found' }); mock.restoreAll(); }); test('POST /employees - should create an employee', () => { const newEmployeeData = { name: 'Newbie', position: 'Intern', salary: 30000 }; const createdEmployee = { id: 10, ...newEmployeeData }; mock.method(employeeService, 'createEmployee', () => createdEmployee); const req = { body: newEmployeeData }; const res = mockResponse(); // 実行 employeeController.create(req, res); // 検証 assert.strictEqual(res.status.mock.calls[0].arguments[0], 201); assert.deepStrictEqual(res.json.mock.calls[0].arguments[0], createdEmployee); mock.restoreAll(); });
ControllerのテストではService内のロジックそのものはテストしないため、mockを使ってServiceを置き換えています。
テストコードを用意できたら、node --testコマンドを使って一括テスト、またはnode --test test/employee.controller.test.jsのように特定のファイルのみを対象とすることもできます。
上記のコードベースの場合、node --testでテストを実行すると、以下のようにレポートが出力されます。
✔ GET /employees/:id - should return an employee if found (2.615208ms) ✔ GET /employees/:id - should return 404 if not found (0.36125ms) ✔ POST /employees - should create an employee (0.782667ms) ▶ EmployeeService ✔ should create a new employee (1.482375ms) ✔ should update an existing employee (0.738916ms) ✔ should return null when updating a non-existent employee (0.332875ms) ✔ should delete an employee (0.6115ms) ✔ EmployeeService (5.760542ms) tests 7 suites 1 pass 7 fail 0 cancelled 0 skipped 0 todo 0 duration_ms 206.21825
主なオプション
--test-reporter <reporter>: テストレポートの出力形式を指定します。好みや、CI/CDツールとの連携など、用途に応じてレポーターを切り替えることができます。
デフォルトのspecの他、各テストケースの実行が完了するごとに、成功すれば .(ドット)、失敗すれば X を出力するdotや、テスト出力形式の標準規格で出力されるtapなどがあります。tapオプションを使えばCI/CDパイプラインなどとの連携による自動化も容易になります。
--coverage: テストがソースコードのどの部分をどれだけ網羅しているかを示すテストカバレッジを計測します。テストが不足しているロジックを行番号付きで特定できるため、テストの品質向上に役立ちます。
上記のサンプルコードに対してカバレッジを計測してみると以下のような結果が出力されました。
---------------------------------------------------------------------------------- file | line % | branch % | funcs % | uncovered lines ---------------------------------------------------------------------------------- src | | | | controllers | | | | employee.controller.js | 62.50 | 80.00 | 40.00 | 9-10 14-15 29-35 39-45 entities | | | | employee.entity.js | 100.00 | 100.00 | 100.00 | repositories | | | | employee.repository.js | 89.36 | 77.78 | 80.00 | 14-15 30 35-36 services | | | | employee.service.js | 92.68 | 85.71 | 80.00 | 7-8 18 ---------------------------------------------------------------------------------- all files | 82.43 | 82.61 | 68.75 | ---------------------------------------------------------------------------------- end of coverage report
今回は GET と POST の API しかテストコードを書いていないので、PUT や DELETE の API に対応する部分が uncovered lines として表示されていることが分かります。
まとめ
ここまで、Node.jsの標準テストモジュールnode:testの概要を紹介しました。依存関係を極力減らしたい場合や、シンプルなバックエンドのテストなど、使用に適したケースはまだ限定的ですが、単体テストの整備する際node:testも選択肢の一つになり得るのではないでしょうか。万能ではありませんが、スナップショットテストのような新機能が追加されるなど現在も開発が進んでおり、今後の機能追加にも期待です。