REST APIのテストを書こう

beginner | 90分 で読める | 2025.12.20

公式ドキュメント

このチュートリアルで学ぶこと

  • JestとSupertestのセットアップ
  • GETエンドポイントのテスト
  • POSTエンドポイントのテスト
  • 認証が必要なAPIのテスト
  • データベースのモック
  • テストのベストプラクティス

前提条件: Node.js 18以上、npm/yarn/pnpmがインストールされていること。Expressの基本的な知識があると理解しやすいです。

ソフトウェアテストとは?なぜ必要なのか

テストの歴史

ソフトウェアテストの概念は1950年代に遡りますが、現代的なテスト駆動開発(TDD)はKent Beckによって1990年代後半に体系化されました。

「テストは品質を測るものではない。テストは品質を作り込むものだ」— Kent Beck

なぜテストを書くのか

  1. リグレッション防止: 既存機能の破壊を早期発見
  2. ドキュメント: テストはコードの使い方を示す
  3. リファクタリングの安心感: テストがあれば大胆に改善できる
  4. 設計の改善: テスト可能なコードは良い設計になりやすい

テストピラミッド

Martin Fowlerが提唱した「テストピラミッド」は、テストの種類とバランスを示すモデルです:

flowchart TB
 subgraph Pyramid["テストピラミッド"]
 direction TB
 E2E["E2Eテスト(少ない)<br/>ブラウザ自動操作"]
 Integration["結合テスト(中程度)<br/>APIテスト、コンポーネントテスト"]
 Unit["単体テスト(多い)<br/>関数、クラス単位"]
 end

 E2E --> Integration --> Unit
種類実行速度保守コスト信頼性カバレッジ
E2E遅い高い脆い広い
結合中程度中程度中程度中程度
単体速い低い安定狭い

参考: Martin Fowler - Test Pyramid

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

Jest設定ファイルは必ずプロジェクトルートに配置してください。パスが異なるとテストファイルが検出されません。

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;

ポイント: テスト用にresetUsers関数をエクスポートしておくことで、各テストの独立性を保てます。

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テスト

実践メモ: .send()メソッドでリクエストボディを指定し、.expect()でステータスコードとContent-Typeを同時にアサートできます。

データ作成のテストを追加します。

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)

ポイント: AAAパターンはテストの可読性を飛躍的に高めるフォーマットです。Arrange(準備)→ Act(実行)→ Assert(検証)の3段階で書きましょう。

テストを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

Jest 29 のカバレッジレポート例(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%のカバレッジが目標ではありません。重要なビジネスロジックを優先的にテストしましょう。カバレッジ閾値はcoverageThresholdで設定できます。

よくある間違いとアンチパターン

注意: 以下の3つは初心者が最も陥りやすいアンチパターンです。テストが壊れやすくなる原因になります

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);
});

ポイント: beforeEachでデータリセット、jest.clearAllMocks()でモックリセットを必ず行いましょう。

3. 過度なモック

// 悪い例: 全てをモック(テストの意味がない)
jest.mock('./userService');
jest.mock('./database');
jest.mock('./validator');

// 良い例: 外部依存のみモック
jest.mock('./externalApiClient');

まとめ

APIテストを書くことで、以下のメリットが得られます:

  • リグレッション(既存機能の破壊)を防止
  • APIの仕様をコードで文書化
  • リファクタリング時の安心感
  • CI/CDパイプラインでの自動検証

まずは簡単なGET/POSTテストから始めて、徐々にカバレッジを広げていきましょう。

参考リンク

公式ドキュメント

ベストプラクティス・記事

書籍

  • 「テスト駆動開発」(Kent Beck著) - TDDの原典
  • 「リファクタリング」(Martin Fowler著) - テストとリファクタリングの関係

ツール

  • Jest - JavaScriptテストフレームワーク
  • Vitest - Vite用高速テストフレームワーク
  • Postman - API開発・テストツール
  • Insomnia - REST/GraphQLクライアント

参考リソース

← 一覧に戻る
PR
PR
PR
PR