1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

1年目エンジニアの後輩に伝えたい。私が当時経験した単体テストの実装方法

Posted at

はじめに

1年目エンジニアの皆さん。テストコードの実装を怖がっていませんか?
何をすれば良いか?どこをテストすれば良いか?
私の実装したテストコードは完全に観点を網羅できているのか?
私は正直怖かったです。私が教えて欲しかったことを、ここでまとめることにしました。

なぜ単体テストが必要なのか

単体テストを書く前に、まずその必要性を理解することが重要です。

単体テストのメリット

  1. バグの早期発見: コードを書いた直後にテストを実行することで、問題を素早く発見できる
  2. リファクタリングの安全性: 既存の機能を壊していないことを確認しながら、コードを改善できる
  3. 仕様の明文化: テストコード自体が、その関数やクラスの仕様書として機能する
  4. 開発速度の向上: 手動でのテストが不要になり、長期的には開発効率が上がる

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. 基本から始める: 簡単な関数のテストから始めて、徐々に複雑なケースに挑戦する
  2. 網羅的に考える: ハッピーパス、エッジケース、異常系をバランスよくテストする
  3. 読みやすさを重視: テスト名やテスト構造を工夫し、仕様書として機能するテストを書く
  4. 継続的に改善: テストの重複を減らし、メンテナンスしやすいテストを目指す

社会人1年目で学んだこれらの基礎は、その後のキャリアでも役立ち続けています。単体テストは単なる「おまけ」ではなく、品質の高いソフトウェアを作るための重要な要素です。ぜひ、日々の開発にテストを取り入れて、その価値を実感してみてください。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?