はじめに
1年目エンジニアの皆さん。テストコードの実装を怖がっていませんか?
何をすれば良いか?どこをテストすれば良いか?
私の実装したテストコードは完全に観点を網羅できているのか?
私は正直怖かったです。私が教えて欲しかったことを、ここでまとめることにしました。
なぜ単体テストが必要なのか
単体テストを書く前に、まずその必要性を理解することが重要です。
単体テストのメリット
- バグの早期発見: コードを書いた直後にテストを実行することで、問題を素早く発見できる
- リファクタリングの安全性: 既存の機能を壊していないことを確認しながら、コードを改善できる
- 仕様の明文化: テストコード自体が、その関数やクラスの仕様書として機能する
- 開発速度の向上: 手動でのテストが不要になり、長期的には開発効率が上がる
Jestを使った単体テストの導入
JavaScriptのテストフレームワークはいくつかありますが、私が最初に学んだのはJestでした。Jestは設定が簡単で、多くの機能が標準で含まれているため、初心者にも扱いやすいフレームワークです。
基本的なテストの書き方
まずは簡単な関数のテストから始めてみましょう。
// math.js
export function add(a, b) {
return a + b;
}
export function divide(a, b) {
if (b === 0) {
throw new Error('0で除算することはできません');
}
return a / b;
}
この関数に対するテストは以下のように書きます:
// math.test.js
import { add, divide } from './math';
describe('add関数', () => {
test('2つの正の数を正しく加算する', () => {
expect(add(2, 3)).toBe(5);
});
test('負の数を含む加算も正しく行う', () => {
expect(add(-1, 3)).toBe(2);
expect(add(-5, -3)).toBe(-8);
});
test('0を含む加算を正しく行う', () => {
expect(add(0, 5)).toBe(5);
expect(add(5, 0)).toBe(5);
});
});
describe('divide関数', () => {
test('正常な除算を行う', () => {
expect(divide(10, 2)).toBe(5);
});
test('0で除算しようとするとエラーを投げる', () => {
expect(() => divide(10, 0)).toThrow('0で除算することはできません');
});
});
テストの基本構造
Jestのテストには以下の重要な要素があります:
- describe: テストをグループ化し、何のテストかを説明する
- test/it: 個別のテストケースを定義する
- expect: 実際の値と期待する値を比較する
- toBe/toEqual: マッチャーと呼ばれ、どのような比較を行うかを指定する
テスト設計で意識したこと
1. ハッピーパス・エッジケース・異常系の網羅
単体テストを書く際は、以下の3つの観点を必ず含めるようにしました:
ハッピーパス(正常系)
最も一般的な使用方法で、期待通りに動作するケースです。
test('ユーザー名とパスワードが正しい場合、認証に成功する', () => {
const result = authenticate('user123', 'password123');
expect(result.success).toBe(true);
});
エッジケース
境界値や極端な入力値のケースです。
test('空文字列の場合の処理', () => {
expect(processString('')).toBe('');
});
test('非常に長い文字列の場合の処理', () => {
const longString = 'a'.repeat(10000);
expect(() => processString(longString)).not.toThrow();
});
異常系
エラーが発生することが期待されるケースです。
test('nullが渡された場合、エラーを投げる', () => {
expect(() => processData(null)).toThrow('Invalid input');
});
2. 条件分岐の網羅
If文やswitch文がある場合、すべての分岐をテストするようにしました。
// 実装コード
function getGrade(score) {
if (score >= 90) return 'A';
if (score >= 80) return 'B';
if (score >= 70) return 'C';
if (score >= 60) return 'D';
return 'F';
}
// テストコード
describe('getGrade関数', () => {
test.each([
[95, 'A'],
[90, 'A'],
[85, 'B'],
[80, 'B'],
[75, 'C'],
[70, 'C'],
[65, 'D'],
[60, 'D'],
[59, 'F'],
[0, 'F']
])('スコア%iの場合、評価は%sになる', (score, expected) => {
expect(getGrade(score)).toBe(expected);
});
});
3. テストの独立性
各テストが他のテストに依存しないよう、セットアップとクリーンアップを適切に行います。
describe('UserService', () => {
let userService;
let mockDatabase;
beforeEach(() => {
// 各テストの前に実行される
mockDatabase = {
save: jest.fn(),
find: jest.fn()
};
userService = new UserService(mockDatabase);
});
afterEach(() => {
// 各テストの後に実行される
jest.clearAllMocks();
});
test('ユーザーを正しく保存する', async () => {
mockDatabase.save.mockResolvedValue({ id: 1, name: 'John' });
const result = await userService.createUser('John');
expect(mockDatabase.save).toHaveBeenCalledWith({ name: 'John' });
expect(result).toEqual({ id: 1, name: 'John' });
});
});
4. わかりやすいテスト名
テスト名は「何を」「どんな条件で」「どうなるべきか」が明確にわかるようにしました。
// 悪い例
test('test1', () => { ... });
test('エラーテスト', () => { ... });
// 良い例
test('メールアドレスが不正な形式の場合、バリデーションエラーを返す', () => { ... });
test('在庫が0の商品を購入しようとした場合、在庫不足エラーを投げる', () => { ... });
実践的なテストテクニック
モックの活用
外部依存(API、データベース、ファイルシステムなど)はモックを使ってテストします。
// APIをモックする例
import axios from 'axios';
jest.mock('axios');
test('ユーザー情報を正しく取得する', async () => {
const mockUserData = { id: 1, name: 'Alice' };
axios.get.mockResolvedValue({ data: mockUserData });
const user = await fetchUser(1);
expect(axios.get).toHaveBeenCalledWith('/api/users/1');
expect(user).toEqual(mockUserData);
});
非同期処理のテスト
Promiseやasync/awaitを使った非同期処理もテストできます。
test('非同期でデータを処理する', async () => {
const data = await processDataAsync([1, 2, 3]);
expect(data).toEqual([2, 4, 6]);
});
test('非同期処理でエラーが発生する', async () => {
await expect(processDataAsync(null)).rejects.toThrow('Invalid data');
});
学びと気づき
1. テストは仕様の理解を深める
テストを書くためには、その関数やクラスが「何をすべきか」を明確に理解する必要があります。曖昧な仕様に対してテストを書こうとすると、自然と仕様の不明点が浮かび上がってきます。
2. テストファーストで考える習慣
最初は実装してからテストを書いていましたが、徐々にテストを先に書く(TDD: Test-Driven Development)ようになりました。これにより、実装前に仕様を明確にでき、必要最小限のコードで機能を実現できるようになりました。
3. リファクタリングの安心感
既存のコードを改善する際、テストがあることで「機能を壊していないか」を即座に確認できます。この安心感があることで、積極的にコードの改善に取り組めるようになりました。
4. チーム開発での価値
他のメンバーが書いたコードを理解する際、テストコードを読むことで仕様を素早く把握できます。また、自分が書いたコードも、テストがあることで他のメンバーが安心して利用・修正できます。
まとめ
単体テストは最初は面倒に感じるかもしれませんが、慣れてくると開発に欠かせないツールになります。特に以下の点を意識することで、質の高いテストを書けるようになります:
- 基本から始める: 簡単な関数のテストから始めて、徐々に複雑なケースに挑戦する
- 網羅的に考える: ハッピーパス、エッジケース、異常系をバランスよくテストする
- 読みやすさを重視: テスト名やテスト構造を工夫し、仕様書として機能するテストを書く
- 継続的に改善: テストの重複を減らし、メンテナンスしやすいテストを目指す
社会人1年目で学んだこれらの基礎は、その後のキャリアでも役立ち続けています。単体テストは単なる「おまけ」ではなく、品質の高いソフトウェアを作るための重要な要素です。ぜひ、日々の開発にテストを取り入れて、その価値を実感してみてください。