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

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テストから始めて、徐々にカバレッジを広げていきましょう。

参考リンク

公式ドキュメント

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

書籍

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

ツール

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