Node.js: テストとデバッグ
最終更新:2026-08-26
ボブはECサイト向けにRESTful APIを開発していたが、新機能を追加するたびに別のどこかが壊れていた。Postmanによる手動テストは1回のデプロイごとに30分もかかり、それでも境界ケースを見逃していた。彼はユニットテストにJest、API統合テストにSupertestを導入した。今ではnpm testで50件のテストが3秒で実行され、すべてのデプロイは自動化されたCIチェックに支えられている。さらに構造化ロギングにwinstonを追加したため、バグがすり抜けてもログが正確に原因を教えてくれる。
このレッスンで学ぶこと:
- Jestのユニットテスト(describe / it / expect / mock)
- SupertestによるAPI統合テスト(HTTPリクエストのシミュレーション)
- Node.js標準のデバッガ(node inspect / Chrome DevTools)
- winstonによる構造化ロギング(transports / format / rotation)
- テストカバレッジとTDDのワークフロー
1. テストの概要
(1) なぜテストを書くのか
| テストなし | テストあり |
|---|---|
| 手動検証、毎回30分 | 自動化、3秒 |
| 1つ変えるとすべて壊れる | 1つ変えても、テストが何が壊れたか教えてくれる |
| デプロイ前の不安 | グリーンライト、自信を持ってデプロイ |
| リファクタリングが怖い | テストという安全網の上でリファクタリング |
▶ サンプル:(2) テストピラミッド
graph TD
A["Unit Tests<br/>Many / Fast / Cheap"] --> B["Integration Tests<br/>Moderate / Slower"]
B --> C["E2E Tests<br/>Few / Slow / Expensive"]
style A fill:#4CAF50,color:#fff
style B fill:#FF9800,color:#fff
style C fill:#F44336,color:#fff
- ユニットテスト: 個々の関数・モジュールをテスト。高速で最も数が多い
- 統合テスト: モジュール間の連携(API + データベースなど)をテスト。中程度の速度
- E2Eテスト: ユーザー操作をシミュレート。最も低速で数が少ない
(3) Node.jsテストツールの比較
| ツール | 種別 | 特徴 | 最適な用途 |
|---|---|---|---|
| Jest | ユニット + 統合 | ゼロ設定、モック/カバレッジ内蔵、スナップショットテスト | 汎用 |
| Mocha | ユニット + 統合 | 柔軟、プラグイン豊富、chai/sinonが必要 | カスタム要件 |
| Vitest | ユニット | Viteエコシステム、ネイティブESM、非常に高速 | Viteプロジェクト |
| node:test | ユニット | Node.js内蔵、依存ゼロ | シンプルなプロジェクト |
| Supertest | HTTPテスト | HTTPリクエストのシミュレーション、Expressルートのテスト | API統合テスト |
| Playwright | E2E | ブラウザ自動化、マルチブラウザ対応 | フロントエンドE2E |
このレッスンでは、Node.jsコミュニティで最も主流なテスト手法であるJest + Supertestの組み合わせを使用します。
2. Jestによるユニットテスト
▶ サンプル:(1) インストールとセットアップ
npm install --save-dev jest
package.jsonにテストスクリプトを追加します:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
Jestはデフォルトでゼロ設定で動作し、*.test.jsまたは*.spec.jsファイルを自動的に検出します。
▶ サンプル:(2) 基本構文:describe / it / expect
// math.js — テスト対象のモジュール
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
function multiply(a, b) {
return a * b;
}
function divide(a, b) {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
module.exports = { add, subtract, multiply, divide };
// math.test.js
const { add, subtract, multiply, divide } = require('./math');
describe('Math utilities', () => {
test('add should sum two numbers', () => {
expect(add(2, 3)).toBe(5);
expect(add(-1, 1)).toBe(0);
});
test('subtract should return the difference', () => {
expect(subtract(5, 3)).toBe(2);
});
test('multiply should return the product', () => {
expect(multiply(3, 4)).toBe(12);
});
test('divide should throw on zero divisor', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
});
実行:
npm test
Output:
PASS ./math.test.js
Math utilities
✓ add should sum two numbers (2 ms)
✓ subtract should return the difference
✓ multiply should return the product
✓ divide should throw on zero divisor (1 ms)
Tests: 4 passed, 4 total
Time: 0.5s
(3) よく使うマッチャー
| マッチャー | 意味 | 例 |
|---|---|---|
toBe |
厳密等価(===) | expect(1 + 1).toBe(2) |
toEqual |
深い等価 | expect(obj).toEqual({ a: 1 }) |
toBeTruthy / toBeFalsy |
真偽チェック | expect(flag).toBeTruthy() |
toBeNull / toBeUndefined |
null / undefined | expect(val).toBeNull() |
toThrow |
例外をスロー | expect(fn).toThrow() |
toContain |
配列に含まれる | expect([1, 2, 3]).toContain(2) |
toMatch |
正規表現一致 | expect('hello').toMatch(/ell/) |
toHaveLength |
長さチェック | expect('abc').toHaveLength(3) |
resolves / rejects |
Promiseの結果 | expect(promise).resolves.toBe(5) |
toHaveBeenCalled |
モックが呼び出された | expect(fn).toHaveBeenCalled() |
▶ サンプル:(4) 非同期テスト
// async-functions.js
function fetchUser(id) {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ id, name: `User_${id}` });
}, 100);
});
}
async function getUserEmail(id) {
const user = await fetchUser(id);
return `${user.name.toLowerCase()}@example.com`;
}
module.exports = { fetchUser, getUserEmail };
// async-functions.test.js
const { fetchUser, getUserEmail } = require('./async-functions');
describe('Async function tests', () => {
test('fetchUser returns user object', async () => {
const user = await fetchUser(1);
expect(user).toEqual({ id: 1, name: 'User_1' });
});
test('getUserEmail returns formatted email', async () => {
const email = await getUserEmail(42);
expect(email).toBe('user_42@example.com');
});
test('fetchUser resolves within 200ms', async () => {
await expect(fetchUser(1)).resolves.toHaveProperty('id', 1);
});
});
(5) モッキング
モックは外部依存(データベース、HTTPリクエスト、ファイルシステム)を隔離し、テストが現在のモジュールのロジックだけに集中できるようにします。
// email-service.js
class EmailService {
send(to, subject, body) {
// 実際のSMTP呼び出し — ユニットテストには遅すぎる
console.log(`Sending email to ${to}: ${subject}`);
return true;
}
}
class UserService {
constructor(emailService) {
this.emailService = emailService;
}
register(name, email) {
// ビジネスロジック:検証 + 歓迎メールの送信
if (!email.includes('@')) throw new Error('Invalid email');
this.emailService.send(email, 'Welcome', `Hello ${name}!`);
return { name, email, status: 'registered' };
}
}
module.exports = { EmailService, UserService };
// email-service.test.js
const { EmailService, UserService } = require('./email-service');
describe('UserService', () => {
let mockEmailService;
let userService;
beforeEach(() => {
mockEmailService = {
send: jest.fn().mockReturnValue(true),
};
userService = new UserService(mockEmailService);
});
test('register should call emailService.send', () => {
const result = userService.register('Alice', 'alice@example.com');
expect(result).toEqual({
name: 'Alice',
email: 'alice@example.com',
status: 'registered',
});
expect(mockEmailService.send).toHaveBeenCalledWith(
'alice@example.com',
'Welcome',
'Hello Alice!'
);
});
test('register should throw on invalid email', () => {
expect(() => userService.register('Bob', 'invalid')).toThrow('Invalid email');
expect(mockEmailService.send).not.toHaveBeenCalled();
});
test('register should send exactly one email', () => {
userService.register('Charlie', 'charlie@example.com');
expect(mockEmailService.send).toHaveBeenCalledTimes(1);
});
});
▶ サンプル:Jestの基本アサーション
test('basic matchers', () => {
expect(2 + 2).toBe(4);
expect({ name: 'Alice' }).toEqual({ name: 'Alice' });
expect([1, 2, 3]).toContain(2);
expect('hello world').toMatch(/world/);
expect(() => { throw new Error('fail'); }).toThrow('fail');
});
よく使われるJestのマッチャーを示します:プリミティブにはtoBe、オブジェクトにはtoEqual、配列にはtoContain、文字列にはtoMatch、エラーテストにはtoThrowを使用します。
3. SupertestによるAPI統合テスト
▶ サンプル:(1) インストールと仕組み
npm install --save-dev supertest
SupertestはExpressアプリケーションをメモリ上で起動し、HTTPリクエストを送信してレスポンスを検証します。実際のポート待受は不要です。
flowchart LR
A["Test File"] -->|"request(app)"| B["Express App<br/>(in-memory)"]
B -->|"response"| A
A -->|"assert"| C["Jest expect"]
▶ サンプル:(2) Express APIのテスト
// app.js — Expressアプリ(listenせずにエクスポート)
const express = require('express');
const app = express();
app.use(express.json());
let products = [
{ id: 1, name: 'Laptop', price: 999.99 },
{ id: 2, name: 'Phone', price: 499.99 },
];
app.get('/api/products', (req, res) => {
res.json(products);
});
app.get('/api/products/:id', (req, res) => {
const product = products.find(p => p.id === parseInt(req.params.id));
if (!product) return res.status(404).json({ error: 'Product not found' });
res.json(product);
});
app.post('/api/products', (req, res) => {
const { name, price } = req.body;
if (!name || price == null) {
return res.status(400).json({ error: 'Name and price are required' });
}
const newProduct = { id: products.length + 1, name, price };
products.push(newProduct);
res.status(201).json(newProduct);
});
module.exports = app;
補足:
app.jsはappをエクスポートするだけで、モジュール内でapp.listen()を呼び出しません。listenはserver.jsに置くため、Supertestがappオブジェクトを直接テストできます。
// app.test.js
const request = require('supertest');
const app = require('./app');
describe('Products API', () => {
test('GET /api/products should return product list', async () => {
const response = await request(app).get('/api/products');
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
expect(response.body[0]).toHaveProperty('name', 'Laptop');
});
test('GET /api/products/:id should return a single product', async () => {
const response = await request(app).get('/api/products/1');
expect(response.status).toBe(200);
expect(response.body.id).toBe(1);
});
test('GET /api/products/:id should return 404 for missing product', async () => {
const response = await request(app).get('/api/products/999');
expect(response.status).toBe(404);
expect(response.body).toHaveProperty('error', 'Product not found');
});
test('POST /api/products should create a new product', async () => {
const response = await request(app)
.post('/api/products')
.send({ name: 'Tablet', price: 299.99 });
expect(response.status).toBe(201);
expect(response.body).toHaveProperty('name', 'Tablet');
expect(response.body).toHaveProperty('id', 3);
});
test('POST /api/products should return 400 for missing fields', async () => {
const response = await request(app)
.post('/api/products')
.send({ name: 'Incomplete Product' });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
});
▶ サンプル:(3) 認証付きAPIのテスト
// protected-routes.test.js
const request = require('supertest');
const app = require('./app');
describe('Protected API routes', () => {
let token;
beforeAll(async () => {
// ログインしてJWTトークンを取得
const response = await request(app)
.post('/api/auth/login')
.send({ username: 'alice', password: 'secret123' });
token = response.body.token;
});
test('should access protected route with valid token', async () => {
const response = await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${token}`);
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('username', 'alice');
});
test('should reject access without token', async () => {
const response = await request(app).get('/api/profile');
expect(response.status).toBe(401);
});
test('should reject access with invalid token', async () => {
const response = await request(app)
.get('/api/profile')
.set('Authorization', 'Bearer invalid_token_here');
expect(response.status).toBe(401);
});
});
▶ サンプル:APIヘルスチェックテスト
const request = require('supertest');
const express = require('express');
const app = express();
app.get('/health', (req, res) => res.json({ status: 'ok' }));
describe('GET /health', () => {
it('returns 200 with status ok', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
});
});
Supertestを使った最小限のAPIヘルスチェックテストです。基本的なExpressアプリを作成し、GETリクエストを送信して、JSONレスポンスのボディとステータスコードを検証します。
4. Node.jsデバッグ
(1) node inspect + Chrome DevTools
Node.jsには標準のデバッガが内蔵されており、追加のツールは不要です。
node inspect app.js
その後、Chromeでchrome://inspectを開き、「Open dedicated DevTools for Node」をクリックします。
よく使うデバッグ操作:
| 操作 | DevToolsショートカット | コマンドライン |
|---|---|---|
| 続行 | F8 | c |
| ステップオーバー | F10 | n |
| ステップイン | F11 | s |
| ステップアウト | Shift+F11 | o |
| ブレークポイント設定 | 行番号をクリック | setBreakpoint() |
▶ サンプル:(2) コードにブレークポイントを設定する
// debug-demo.js
function calculateDiscount(price, memberLevel) {
debugger; // ここでインスペクタの実行が一時停止する
let discount = 0;
if (memberLevel === 'gold') discount = 0.2;
else if (memberLevel === 'silver') discount = 0.1;
const finalPrice = price * (1 - discount);
return finalPrice;
}
console.log(calculateDiscount(100, 'gold'));
console.log(calculateDiscount(100, 'silver'));
console.log(calculateDiscount(100, 'unknown'));
node inspect debug-demo.js
▶ サンプル:(3) consoleデバッグのヒント
// consoleデバッグのメソッド
const users = [
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' },
{ id: 3, name: 'Charlie', role: 'admin' },
];
// テーブル表示
console.table(users);
// 実行時間の計測
console.time('database-query');
// ... クエリ処理 ...
console.timeEnd('database-query');
// スタックトレース
console.trace('Where is this called from?');
// グループ化された出力
console.group('User Details');
console.log('Name: Alice');
console.log('Role: admin');
console.groupEnd();
5. winstonによる構造化ロギング
(1) なぜconsole.logではなくwinstonを使うのか
| console.log | winston |
|---|---|
| ログレベルなし | debug/info/warn/errorをサポート |
| ファイル出力なし | Console + File + HTTPなどのtransportをサポート |
| フォーマットなし | JSON / タイムスタンプ / カスタムフォーマットをサポート |
| ログローテーションなし | winston-daily-rotate-fileで自動ローテーション |
| 本番非推奨 | 本番レベルの選択肢 |
▶ サンプル:(2) インストールと基本セットアップ
npm install winston
npm install winston-daily-rotate-file
// logger.js
const winston = require('winston');
const DailyRotateFile = require('winston-daily-rotate-file');
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'my-app' },
transports: [
// エラーログ — 別ファイル
new DailyRotateFile({
filename: 'logs/error-%DATE%.log',
datePattern: 'YYYY-MM-DD',
level: 'error',
maxSize: '20m',
maxFiles: '14d',
}),
// 全ログ — 統合ファイル
new DailyRotateFile({
filename: 'logs/combined-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxSize: '20m',
maxFiles: '14d',
}),
],
});
// 開発時:コンソールにも色付きで出力
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}));
}
module.exports = logger;
▶ サンプル:(3) Expressアプリでの利用
// app-with-logger.js
const express = require('express');
const logger = require('./logger');
const app = express();
app.use(express.json());
// リクエストロギング用ミドルウェア
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
logger.info('HTTP request', {
method: req.method,
url: req.url,
status: res.statusCode,
duration: `${duration}ms`,
});
});
next();
});
app.get('/api/health', (req, res) => {
logger.debug('Health check requested');
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.get('/api/users/:id', (req, res) => {
const userId = req.params.id;
logger.info('User lookup', { userId });
if (isNaN(userId)) {
logger.warn('Invalid user ID format', { userId });
return res.status(400).json({ error: 'Invalid user ID' });
}
// ユーザー検索のシミュレーション
const user = { id: parseInt(userId), name: 'Alice' };
logger.info('User found', { userId: user.id, name: user.name });
res.json(user);
});
app.use((err, req, res, next) => {
logger.error('Unhandled error', { error: err.message, stack: err.stack });
res.status(500).json({ error: 'Internal server error' });
});
module.exports = app;
ログファイルの出力例:
{"level":"info","message":"HTTP request","service":"my-app","timestamp":"2026-07-13 10:30:00","method":"GET","url":"/api/users/1","status":200,"duration":"15ms"}
{"level":"warn","message":"Invalid user ID format","service":"my-app","timestamp":"2026-07-13 10:30:05","userId":"abc"}
{"level":"error","message":"Unhandled error","service":"my-app","timestamp":"2026-07-13 10:31:00","error":"Cannot read property 'name' of undefined","stack":"TypeError: Cannot read property..."}
▶ サンプル:winstonによる構造化ロギング
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [new winston.transports.Console()],
});
logger.info('Server started', { port: 3000, env: 'development' });
logger.warn('Memory usage high', { used: '850MB' });
logger.error('Database connection failed', { db: 'main', retry: 3 });
winstonによる構造化JSONロギングを示します。異なるログレベル(info、warn、error)と、各ログエントリに付加されるコンテキストメタデータを使用します。
6. テストカバレッジとTDD
(1) テストカバレッジ
Jestにはカバレッジレポート用のIstanbulが同梱されています:
npm run test:coverage
Output:
----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files | 90.5 | 85.71 | 100 | 90.5 |
math.js | 100 | 100 | 100 | 100 |
app.js | 88.24 | 83.33 | 100 | 88.24 | 45-48
----------|---------|----------|---------|---------|-------------------
カバレッジの各次元の説明:
| 次元 | 意味 | 推奨目標 |
|---|---|---|
| ステートメント | 実行されたステートメントの割合 | ≥ 80% |
| ブランチ | 実行された条件分岐の割合 | ≥ 75% |
| 関数 | 呼び出された関数の割合 | ≥ 85% |
| 行 | 実行されたコード行の割合 | ≥ 80% |
100%カバレッジを追い求めても費用対効果は薄れます。80%のカバレッジで大半のバグを検出できます。
▶ サンプル:(2) TDDワークフロー(Red-Green-Refactor)
flowchart LR
A["🔴 Red<br/>Write failing test"] --> B["🟢 Green<br/>Write minimal code to pass"]
B --> C["🔵 Refactor<br/>Improve code while tests pass"]
C --> A
- Red: 最初にテストを書く(機能がまだ存在しないため失敗する)
- Green: テストを通過させる最小限のコードを書く
- Refactor: テストが通ったままコードを改善する
▶ サンプル:TDDによる文字列ユーティリティ関数
// string-utils.test.js — 最初にテストを書く(Red)
const { capitalize, truncate, slugify } = require('./string-utils');
describe('StringUtils', () => {
describe('capitalize', () => {
test('should capitalize first letter', () => {
expect(capitalize('hello')).toBe('Hello');
});
test('should handle empty string', () => {
expect(capitalize('')).toBe('');
});
test('should handle already capitalized', () => {
expect(capitalize('Hello')).toBe('Hello');
});
});
describe('truncate', () => {
test('should truncate long strings', () => {
expect(truncate('Hello World', 5)).toBe('Hello...');
});
test('should not truncate short strings', () => {
expect(truncate('Hi', 10)).toBe('Hi');
});
});
describe('slugify', () => {
test('should convert to URL slug', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
test('should handle special characters', () => {
expect(slugify('C# & .NET!')).toBe('c-and-net');
});
});
});
// string-utils.js — テストの後に実装を書く(Green)
function capitalize(str) {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1);
}
function truncate(str, maxLength) {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength) + '...';
}
function slugify(str) {
return str
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
module.exports = { capitalize, truncate, slugify };
7. 総合例:タスク管理APIの完全なテストスイートを作成する
▶ サンプル:Tasks APIのテストスイート
ステップ1 — アプリケーションコード
// tasks-app.js
const express = require('express');
const app = express();
app.use(express.json());
let tasks = [];
let nextId = 1;
function resetTasks() {
tasks = [];
nextId = 1;
}
app.get('/api/tasks', (req, res) => {
const { status, priority } = req.query;
let filtered = tasks;
if (status) filtered = filtered.filter(t => t.status === status);
if (priority) filtered = filtered.filter(t => t.priority === priority);
res.json(filtered);
});
app.get('/api/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) return res.status(404).json({ error: 'Task not found' });
res.json(task);
});
app.post('/api/tasks', (req, res) => {
const { title, priority = 'medium' } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
const task = { id: nextId++, title, priority, status: 'pending' };
tasks.push(task);
res.status(201).json(task);
});
app.patch('/api/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) return res.status(404).json({ error: 'Task not found' });
const { title, status, priority } = req.body;
if (title) task.title = title;
if (status) task.status = status;
if (priority) task.priority = priority;
res.json(task);
});
app.delete('/api/tasks/:id', (req, res) => {
const index = tasks.findIndex(t => t.id === parseInt(req.params.id));
if (index === -1) return res.status(404).json({ error: 'Task not found' });
const deleted = tasks.splice(index, 1);
res.json(deleted[0]);
});
module.exports = { app, resetTasks };
ステップ2 — 完全なテストスイート
// tasks-app.test.js
const request = require('supertest');
const { app, resetTasks } = require('./tasks-app');
beforeEach(() => {
resetTasks();
});
describe('Tasks API — CRUD operations', () => {
test('should create a task', async () => {
const response = await request(app)
.post('/api/tasks')
.send({ title: 'Write tests', priority: 'high' });
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
id: 1,
title: 'Write tests',
priority: 'high',
status: 'pending',
});
});
test('should reject task without title', async () => {
const response = await request(app)
.post('/api/tasks')
.send({ priority: 'high' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Title is required');
});
test('should list all tasks', async () => {
await request(app).post('/api/tasks').send({ title: 'Task 1' });
await request(app).post('/api/tasks').send({ title: 'Task 2' });
const response = await request(app).get('/api/tasks');
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
});
test('should filter tasks by status', async () => {
await request(app).post('/api/tasks').send({ title: 'Pending task' });
const createRes = await request(app).post('/api/tasks').send({ title: 'Done task' });
await request(app).patch(`/api/tasks/${createRes.body.id}`).send({ status: 'done' });
const response = await request(app).get('/api/tasks?status=done');
expect(response.body).toHaveLength(1);
expect(response.body[0].status).toBe('done');
});
test('should update a task', async () => {
const createRes = await request(app).post('/api/tasks').send({ title: 'Old title' });
const response = await request(app)
.patch(`/api/tasks/${createRes.body.id}`)
.send({ title: 'New title', status: 'done' });
expect(response.status).toBe(200);
expect(response.body.title).toBe('New title');
expect(response.body.status).toBe('done');
});
test('should delete a task', async () => {
const createRes = await request(app).post('/api/tasks').send({ title: 'To delete' });
const response = await request(app).delete(`/api/tasks/${createRes.body.id}`);
expect(response.status).toBe(200);
const listRes = await request(app).get('/api/tasks');
expect(listRes.body).toHaveLength(0);
});
test('should return 404 for non-existent task', async () => {
const response = await request(app).get('/api/tasks/999');
expect(response.status).toBe(404);
});
});
実行:
npm test
Output:
PASS ./tasks-app.test.js
Tasks API — CRUD operations
✓ should create a task (15 ms)
✓ should reject task without title (3 ms)
✓ should list all tasks (4 ms)
✓ should filter tasks by status (8 ms)
✓ should update a task (5 ms)
✓ should delete a task (5 ms)
✓ should return 404 for non-existent task (2 ms)
Tests: 7 passed, 7 total
Time: 0.8s
❓ よくある質問
:memory:など)や専用のテスト用データベースを使用できます。E2Eテストではテスト環境のデータベースを使用します。infoに設定すると、info以上のみが出力されます。node --inspect-brk node_modules/.bin/jest --runInBandを実行し、Chrome DevToolsでデバッグします。--runInBandはJestを単一スレッドで実行し、ブレークポイントを機能させます。http.createServer(app)でインメモリサーバーを作成します。ポートは占有されず、テスト終了時に自動的に閉じられます。📖 まとめ
- テストピラミッド:ユニットテスト(多・速・安)→ 統合テスト(中程度)→ E2Eテスト(少・遅・高コスト)
- Jestのゼロ設定ユニットテスト:describe/it/expect + 外部依存のモック
- SupertestはExpress APIをメモリ上でテスト:request(app).get/post/patch/delete
- Node.js標準の
node inspectデバッガとChrome DevToolsの視覚的ブレークポイント - winstonによる構造化ロギング:複数レベル + 複数transport + ログローテーション
- 80%以上のテストカバレッジで十分。TDDワークフローRed→Green→Refactorでコード品質が向上
📝 練習問題
- 次の関数に対するJestのユニットテストを書きましょう:
function isPalindrome(str) { return str === str.split('').reverse().join(''); }。通常の回文、非回文、空文字列、大文字小文字混在のケースをカバーしてください。 - Express API(
/api/booksのCRUD)を作成し、Supertestで少なくとも5件の統合テスト(作成、取得、更新、削除、404)を書きましょう。 - 既存のExpressアプリにwinstonのロギングミドルウェアを追加し、全リクエストのメソッド/URL/ステータス/所要時間を記録し、日次ログローテーションを設定しましょう。
- TDDで
formatCurrency(amount, currency)関数を開発しましょう。まず失敗するテストを書き(例:formatCurrency(1234.5, 'USD')→'$1,234.50')、その後実装を書きます。 jest --coverageを実行し、カバレッジが80%未満のファイルを特定し、目標達成のためのテストケースを追加しましょう。