このチュートリアルで学ぶこと
- JestとSupertestのセットアップ
- GETエンドポイントのテスト
- POSTエンドポイントのテスト
- 認証が必要なAPIのテスト
- データベースのモック
- テストのベストプラクティス
前提条件: Node.js 18以上、npm/yarn/pnpmがインストールされていること。Expressの基本的な知識があると理解しやすいです。
ソフトウェアテストとは?なぜ必要なのか
テストの歴史
ソフトウェアテストの概念は1950年代に遡りますが、現代的なテスト駆動開発(TDD)はKent Beckによって1990年代後半に体系化されました。
「テストは品質を測るものではない。テストは品質を作り込むものだ」— Kent Beck
なぜテストを書くのか
- リグレッション防止: 既存機能の破壊を早期発見
- ドキュメント: テストはコードの使い方を示す
- リファクタリングの安心感: テストがあれば大胆に改善できる
- 設計の改善: テスト可能なコードは良い設計になりやすい
テストピラミッド
Martin Fowlerが提唱した「テストピラミッド」は、テストの種類とバランスを示すモデルです:
flowchart TB
subgraph Pyramid["テストピラミッド"]
direction TB
E2E["E2Eテスト(少ない)<br/>ブラウザ自動操作"]
Integration["結合テスト(中程度)<br/>APIテスト、コンポーネントテスト"]
Unit["単体テスト(多い)<br/>関数、クラス単位"]
end
E2E --> Integration --> Unit
| 種類 | 実行速度 | 保守コスト | 信頼性 | カバレッジ |
|---|---|---|---|---|
| E2E | 遅い | 高い | 脆い | 広い |
| 結合 | 中程度 | 中程度 | 中程度 | 中程度 |
| 単体 | 速い | 低い | 安定 | 狭い |
APIテストの位置づけ
APIテストは「結合テスト」に分類され、以下のメリットがあります:
- UIの変更に影響されない
- E2Eより高速で安定
- 実際のHTTPリクエストをテスト
- フロントエンドなしでバックエンドをテスト可能
Step 1: プロジェクトのセットアップ
まず、テスト対象となるシンプルなExpressアプリケーションを作成します。
プロジェクト作成
mkdir api-testing-tutorial
cd api-testing-tutorial
npm init -y
npm install express
npm install -D jest supertest @types/jest @types/supertest typescript ts-jest
package.json(scripts部分)
{
"scripts": {
"start": "node dist/index.js",
"build": "tsc",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
Jest設定
jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.test.ts'
],
// テスト前後のセットアップ
setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
// カバレッジ閾値(オプション)
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
公式ドキュメント: Jest Configuration
Step 2: テスト対象のAPIを作成
src/app.ts
import express, { Express, Request, Response, NextFunction } from 'express';
const app: Express = express();
app.use(express.json());
// インメモリのデータストア
interface User {
id: number;
name: string;
email: string;
}
let users: User[] = [
{ id: 1, name: '田中太郎', email: 'tanaka@example.com' },
{ id: 2, name: '佐藤花子', email: 'sato@example.com' }
];
// エラーハンドリングミドルウェア
const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal Server Error' });
};
// GET /api/users - ユーザー一覧
app.get('/api/users', (req: Request, res: Response) => {
res.json(users);
});
// GET /api/users/:id - ユーザー詳細
app.get('/api/users/:id', (req: Request, res: Response) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
return res.status(404).json({ error: 'ユーザーが見つかりません' });
}
res.json(user);
});
// POST /api/users - ユーザー作成
app.post('/api/users', (req: Request, res: Response) => {
const { name, email } = req.body;
// バリデーション
if (!name || !email) {
return res.status(400).json({ error: '名前とメールアドレスは必須です' });
}
if (!email.includes('@')) {
return res.status(400).json({ error: 'メールアドレスの形式が正しくありません' });
}
const newUser: User = {
id: users.length + 1,
name,
email
};
users.push(newUser);
res.status(201).json(newUser);
});
// PUT /api/users/:id - ユーザー更新
app.put('/api/users/:id', (req: Request, res: Response) => {
const userId = parseInt(req.params.id);
const userIndex = users.findIndex(u => u.id === userId);
if (userIndex === -1) {
return res.status(404).json({ error: 'ユーザーが見つかりません' });
}
const { name, email } = req.body;
users[userIndex] = { ...users[userIndex], name, email };
res.json(users[userIndex]);
});
// DELETE /api/users/:id - ユーザー削除
app.delete('/api/users/:id', (req: Request, res: Response) => {
const userId = parseInt(req.params.id);
const userIndex = users.findIndex(u => u.id === userId);
if (userIndex === -1) {
return res.status(404).json({ error: 'ユーザーが見つかりません' });
}
users.splice(userIndex, 1);
res.status(204).send();
});
app.use(errorHandler);
// テスト用にデータをリセットする関数
export const resetUsers = () => {
users = [
{ id: 1, name: '田中太郎', email: 'tanaka@example.com' },
{ id: 2, name: '佐藤花子', email: 'sato@example.com' }
];
};
export default app;
Step 3: 基本的なGETテスト
src/app.test.ts
import request from 'supertest';
import app, { resetUsers } from './app';
describe('Users API', () => {
// 各テスト前にデータをリセット
beforeEach(() => {
resetUsers();
});
// GET /api/users のテスト
describe('GET /api/users', () => {
it('should return all users', async () => {
const response = await request(app)
.get('/api/users')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toBeInstanceOf(Array);
expect(response.body.length).toBeGreaterThan(0);
});
it('should return users with correct properties', async () => {
const response = await request(app)
.get('/api/users')
.expect(200);
const user = response.body[0];
expect(user).toHaveProperty('id');
expect(user).toHaveProperty('name');
expect(user).toHaveProperty('email');
});
});
// GET /api/users/:id のテスト
describe('GET /api/users/:id', () => {
it('should return a user by id', async () => {
const response = await request(app)
.get('/api/users/1')
.expect(200);
expect(response.body.id).toBe(1);
expect(response.body).toHaveProperty('name');
});
it('should return 404 for non-existent user', async () => {
const response = await request(app)
.get('/api/users/999')
.expect(404);
expect(response.body.error).toBe('ユーザーが見つかりません');
});
it('should handle invalid id format', async () => {
const response = await request(app)
.get('/api/users/invalid')
.expect(404);
});
});
});
テスト実行
npm test
# 出力例
# PASS src/app.test.ts
# Users API
# GET /api/users
# ✓ should return all users (25 ms)
# ✓ should return users with correct properties (8 ms)
# GET /api/users/:id
# ✓ should return a user by id (5 ms)
# ✓ should return 404 for non-existent user (4 ms)
Step 4: POSTテスト
データ作成のテストを追加します。
describe('POST /api/users', () => {
it('should create a new user', async () => {
const newUser = {
name: '山田次郎',
email: 'yamada@example.com'
};
const response = await request(app)
.post('/api/users')
.send(newUser)
.expect('Content-Type', /json/)
.expect(201);
expect(response.body).toMatchObject(newUser);
expect(response.body).toHaveProperty('id');
});
it('should return 400 when name is missing', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'test@example.com' })
.expect(400);
expect(response.body.error).toContain('必須');
});
it('should return 400 when email is missing', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'テストユーザー' })
.expect(400);
expect(response.body.error).toContain('必須');
});
it('should return 400 for invalid email format', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'テストユーザー', email: 'invalid-email' })
.expect(400);
expect(response.body.error).toContain('形式');
});
});
Step 5: PUT/DELETEテスト
describe('PUT /api/users/:id', () => {
it('should update an existing user', async () => {
const updatedData = {
name: '田中太郎(更新)',
email: 'tanaka-updated@example.com'
};
const response = await request(app)
.put('/api/users/1')
.send(updatedData)
.expect(200);
expect(response.body.name).toBe(updatedData.name);
expect(response.body.email).toBe(updatedData.email);
});
it('should return 404 for non-existent user', async () => {
await request(app)
.put('/api/users/999')
.send({ name: 'test', email: 'test@example.com' })
.expect(404);
});
});
describe('DELETE /api/users/:id', () => {
it('should delete an existing user', async () => {
await request(app)
.delete('/api/users/1')
.expect(204);
// 削除されたことを確認
await request(app)
.get('/api/users/1')
.expect(404);
});
it('should return 404 for non-existent user', async () => {
await request(app)
.delete('/api/users/999')
.expect(404);
});
});
Step 6: テストパターンとベストプラクティス
AAAパターン(Arrange-Act-Assert)
テストを3つのフェーズで構成します:
it('should create a new user', async () => {
// Arrange: テストデータを準備
const newUser = {
name: '山田次郎',
email: 'yamada@example.com'
};
// Act: テスト対象を実行
const response = await request(app)
.post('/api/users')
.send(newUser);
// Assert: 結果を検証
expect(response.status).toBe(201);
expect(response.body).toMatchObject(newUser);
});
テストの独立性を保つ
describe('Users API', () => {
// 各テスト前にデータをリセット
beforeEach(() => {
resetUsers();
});
// 各テスト後にクリーンアップ(必要に応じて)
afterEach(() => {
// モックのリセットなど
jest.clearAllMocks();
});
// 全テスト後のクリーンアップ
afterAll(async () => {
// DB接続のクローズなど
});
});
説明的なテスト名
// 良い例: 具体的で意図が明確
it('should return 404 when user does not exist', () => {});
it('should create user and return 201 status', () => {});
it('should validate email format and reject invalid emails', () => {});
// 悪い例: 意図が不明確
it('test1', () => {});
it('works', () => {});
it('success', () => {});
命名規則: 「should + 期待される動作」の形式が一般的です。
境界値のテスト
describe('Validation', () => {
it('should accept name with 1 character (minimum)', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'A', email: 'test@example.com' })
.expect(201);
});
it('should accept name with 100 characters (maximum)', async () => {
const longName = 'A'.repeat(100);
const response = await request(app)
.post('/api/users')
.send({ name: longName, email: 'test@example.com' })
.expect(201);
});
it('should reject empty name', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: '', email: 'test@example.com' })
.expect(400);
});
});
Step 7: 認証付きAPIのテスト
JWTトークンを使う認証付きAPIのテスト例:
describe('Protected API', () => {
const validToken = 'Bearer valid-jwt-token';
const invalidToken = 'Bearer invalid-token';
it('should return 401 without authorization header', async () => {
await request(app)
.get('/api/protected/resource')
.expect(401);
});
it('should return 401 with invalid token', async () => {
await request(app)
.get('/api/protected/resource')
.set('Authorization', invalidToken)
.expect(401);
});
it('should return 200 with valid token', async () => {
await request(app)
.get('/api/protected/resource')
.set('Authorization', validToken)
.expect(200);
});
});
Step 8: モックの活用
外部依存(データベース、外部API)をモック化します。
データベースのモック
// src/services/userService.ts
import { db } from '../db';
export const userService = {
findAll: async () => db.users.findMany(),
findById: async (id: number) => db.users.findUnique({ where: { id } }),
create: async (data: { name: string; email: string }) => db.users.create({ data })
};
// src/services/userService.test.ts
import { userService } from './userService';
import { db } from '../db';
// dbモジュールをモック
jest.mock('../db', () => ({
db: {
users: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn()
}
}
}));
describe('UserService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return all users', async () => {
const mockUsers = [
{ id: 1, name: 'User 1', email: 'user1@example.com' }
];
(db.users.findMany as jest.Mock).mockResolvedValue(mockUsers);
const result = await userService.findAll();
expect(db.users.findMany).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockUsers);
});
});
外部APIのモック
import axios from 'axios';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('External API integration', () => {
it('should fetch data from external API', async () => {
const mockData = { data: { id: 1, title: 'Test' } };
mockedAxios.get.mockResolvedValue(mockData);
const result = await fetchExternalData();
expect(mockedAxios.get).toHaveBeenCalledWith('https://api.example.com/data');
expect(result).toEqual(mockData.data);
});
it('should handle API errors', async () => {
mockedAxios.get.mockRejectedValue(new Error('Network Error'));
await expect(fetchExternalData()).rejects.toThrow('Network Error');
});
});
テストカバレッジ
カバレッジレポートの生成
npm test -- --coverage
---------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
---------------------|---------|----------|---------|---------|
All files | 95.24 | 88.89 | 100 | 95.24 |
app.ts | 95.24 | 88.89 | 100 | 95.24 |
---------------------|---------|----------|---------|---------|
カバレッジの種類
| 種類 | 説明 |
|---|---|
| Statements | 文の実行率 |
| Branches | 条件分岐のカバー率 |
| Functions | 関数の呼び出し率 |
| Lines | 行の実行率 |
注意: 100%のカバレッジが目標ではありません。重要なビジネスロジックを優先的にテストしましょう。
よくある間違いとアンチパターン
1. テスト間の依存
// 悪い例: テストの順序に依存
it('should create user', async () => {
await request(app).post('/api/users').send({ name: 'Test', email: 'test@example.com' });
});
it('should have 3 users', async () => {
// 上のテストが実行されていることを前提としている
const response = await request(app).get('/api/users');
expect(response.body.length).toBe(3);
});
// 良い例: 各テストが独立
beforeEach(() => {
resetUsers(); // データをリセット
});
it('should create user', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'Test', email: 'test@example.com' });
expect(response.status).toBe(201);
});
2. 実装の詳細をテスト
// 悪い例: 内部実装をテスト
it('should call database with correct SQL', async () => {
await userService.findById(1);
expect(db.query).toHaveBeenCalledWith('SELECT * FROM users WHERE id = 1');
});
// 良い例: 振る舞いをテスト
it('should return user by id', async () => {
const user = await userService.findById(1);
expect(user.id).toBe(1);
});
3. 過度なモック
// 悪い例: 全てをモック(テストの意味がない)
jest.mock('./userService');
jest.mock('./database');
jest.mock('./validator');
// 良い例: 外部依存のみモック
jest.mock('./externalApiClient');
まとめ
APIテストを書くことで、以下のメリットが得られます:
- リグレッション(既存機能の破壊)を防止
- APIの仕様をコードで文書化
- リファクタリング時の安心感
- CI/CDパイプラインでの自動検証
まずは簡単なGET/POSTテストから始めて、徐々にカバレッジを広げていきましょう。
参考リンク
公式ドキュメント
- Jest公式ドキュメント - テストフレームワーク
- Supertest GitHub - HTTPアサーションライブラリ
- Testing Library - テストベストプラクティス
ベストプラクティス・記事
- Martin Fowler - Test Pyramid - テストピラミッドの解説
- Google Testing Blog - Googleのテストに関するブログ
- Kent C. Dodds - Testing JavaScript - JavaScriptテストの総合ガイド
書籍
- 「テスト駆動開発」(Kent Beck著) - TDDの原典
- 「リファクタリング」(Martin Fowler著) - テストとリファクタリングの関係
ツール
- Jest - JavaScriptテストフレームワーク
- Vitest - Vite用高速テストフレームワーク
- Postman - API開発・テストツール
- Insomnia - REST/GraphQLクライアント