タイトル: 単体テスト
SEOタイトル: 単体テスト (Unit Test) 完全ガイド - TDD / AAA / モック / カバレッジ
| この記事の要点 |
|
単体テストとは
単体テスト (Unit Test) はコードの最小単位 (関数・メソッド・クラス) を、入力と期待される出力のセットで自動検証するテストです。GUI を介さずプログラムから直接実行し、CI で常時回します。
| テストの種類 | 対象 | 速度 | 頻度 |
|---|---|---|---|
| 単体テスト | 関数・メソッド・クラス | ★ 数ミリ秒 | ★ 多数 (千〜万) |
| 結合テスト | 複数モジュール連携 | 数秒 | 中 |
| E2E テスト | UI → DB まで全体 | 数十秒〜分 | 少数 (重要シナリオ) |
テストピラミッド
/\
/E2E\ 少数 (10〜数十)、重要シナリオのみ
/------\
/ 結合 \ 中量 (数十〜数百)
/----------\
/ 単体 \ ★ 多数 (千〜万)、コアの品質保証
/--------------\
下の方が速く・安く・たくさん書ける。上の方は本物の挙動を確認できるが遅い・壊れやすい。
JUnit (Java)
// JUnit 5
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
Calculator calc;
@BeforeEach
void setUp() {
calc = new Calculator();
}
@Test
@DisplayName("正の数の足し算")
void add_positiveNumbers() {
// Arrange (不要)
// Act
int result = calc.add(1, 2);
// Assert
assertEquals(3, result);
}
@Test
void divide_byZero_throws() {
assertThrows(ArithmeticException.class, () -> calc.divide(1, 0));
}
@ParameterizedTest
@CsvSource({
"1, 2, 3",
"5, 5, 10",
"-1, 1, 0"
})
void add_parameterized(int a, int b, int expected) {
assertEquals(expected, calc.add(a, b));
}
}
pytest (Python)
# test_calculator.py
import pytest
from calculator import Calculator
@pytest.fixture
def calc():
return Calculator()
def test_add_positive(calc):
assert calc.add(1, 2) == 3
def test_divide_by_zero(calc):
with pytest.raises(ZeroDivisionError):
calc.divide(1, 0)
# パラメータ化
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(5, 5, 10),
(-1, 1, 0),
])
def test_add_parameterized(calc, a, b, expected):
assert calc.add(a, b) == expected
# 実行: pytest -v
# カバレッジ: pytest --cov=calculator --cov-report=html
PHPUnit (PHP)
// tests/CalculatorTest.php
use PHPUnit\Framework\TestCase;
class CalculatorTest extends TestCase
{
private Calculator $calc;
protected function setUp(): void
{
$this->calc = new Calculator();
}
public function testAddPositive(): void
{
$this->assertEquals(3, $this->calc->add(1, 2));
}
public function testDivideByZeroThrows(): void
{
$this->expectException(\DivisionByZeroError::class);
$this->calc->divide(1, 0);
}
/**
* @dataProvider additionProvider
*/
public function testAddParameterized(int $a, int $b, int $expected): void
{
$this->assertEquals($expected, $this->calc->add($a, $b));
}
public static function additionProvider(): array
{
return [
[1, 2, 3],
[5, 5, 10],
[-1, 1, 0],
];
}
}
// 実行: vendor/bin/phpunit
// Laravel: php artisan test
Jest (JavaScript / TypeScript)
// calculator.test.ts
import { Calculator } from './calculator';
describe('Calculator', () => {
let calc: Calculator;
beforeEach(() => {
calc = new Calculator();
});
test('正の数の足し算', () => {
expect(calc.add(1, 2)).toBe(3);
});
test('0 で割ると例外', () => {
expect(() => calc.divide(1, 0)).toThrow();
});
test.each([
[1, 2, 3],
[5, 5, 10],
[-1, 1, 0],
])('add(%i, %i) === %i', (a, b, expected) => {
expect(calc.add(a, b)).toBe(expected);
});
});
// 実行: npx jest
// カバレッジ: npx jest --coverage
TDD (Test Driven Development)
Red → Green → Refactor のサイクルでテストを先に書く開発手法。
- Red: まだ実装が無い状態でテストを書く → 失敗 (赤)
- Green: テストが通る最小限の実装 → 成功 (緑)
- Refactor: テストを保ったまま設計を改善
# Step 1: Red - テストを書く (まだ実装なし)
def test_fizzbuzz():
assert fizzbuzz(1) == "1"
assert fizzbuzz(3) == "Fizz"
assert fizzbuzz(5) == "Buzz"
assert fizzbuzz(15) == "FizzBuzz"
# Step 2: Green - 最小限の実装でパス
def fizzbuzz(n):
if n % 15 == 0: return "FizzBuzz"
if n % 3 == 0: return "Fizz"
if n % 5 == 0: return "Buzz"
return str(n)
# Step 3: Refactor - 必要なら整理
AAA (Arrange-Act-Assert) パターン
1 つのテストを 準備 / 実行 / 検証 の 3 ブロックに整理する書き方。可読性が高くデバッグが楽。
@Test
void calculateDiscount_premiumUser_gets20Percent() {
// Arrange (準備)
User user = new User("Alice", UserType.PREMIUM);
Order order = new Order(1000);
DiscountCalculator calc = new DiscountCalculator();
// Act (実行)
int discount = calc.calculate(user, order);
// Assert (検証)
assertEquals(200, discount);
}
モック / スタブ / フェイク
外部依存 (DB / API / ファイル / 時間) を本物で使うとテストが遅く・不安定になります。テストダブルに置き換えます。
| 種類 | 役割 |
|---|---|
| Stub | 「呼ばれたらこの値を返す」固定応答 |
| Mock | 「ちゃんと呼ばれたか」を検証できる |
| Fake | 軽量な代替実装 (in-memory DB など) |
| Spy | 本物を呼びつつ呼び出し記録 |
// Mockito (Java)
@Test
void sendWelcomeEmail() {
EmailService email = mock(EmailService.class);
UserService service = new UserService(email);
service.register("alice@example.com");
// 「sendWelcome が正しい引数で 1 度だけ呼ばれた」を検証
verify(email, times(1)).sendWelcome("alice@example.com");
}
// when でスタブ
when(repo.findById(1L)).thenReturn(Optional.of(user));# pytest + unittest.mock
from unittest.mock import Mock, patch
def test_send_welcome_email():
email = Mock()
service = UserService(email)
service.register("alice@example.com")
email.send_welcome.assert_called_once_with("alice@example.com")
# モジュール関数のパッチ
@patch("myapp.requests.get")
def test_fetch_user(mock_get):
mock_get.return_value.json.return_value = {"id": 1, "name": "Alice"}
user = fetch_user(1)
assert user["name"] == "Alice"
コードカバレッジ
テストがコードのどこを通過したかの指標。
| 指標 | 意味 |
|---|---|
| ライン (Line) カバレッジ | 各行が 1 度でも実行されたか |
| ブランチ (Branch) カバレッジ | if / switch の各分岐を通ったか |
| 関数 (Function) カバレッジ | 各関数が呼ばれたか |
| 条件 (Condition) カバレッジ | 各条件式の真偽両方を通ったか |
カバレッジが高い ≠ バグがない。アサーション無しのテストもカバレッジは上がる。目安はライン 70-80% / ブランチ 60% 以上。100% を目指すよりも、重要なロジックを重点的に。
# Java (Maven + JaCoCo)
mvn test jacoco:report
open target/site/jacoco/index.html
# Python
pip install pytest-cov
pytest --cov=myapp --cov-report=html
open htmlcov/index.html
# PHP
vendor/bin/phpunit --coverage-html coverage/
# JS / TS
npx jest --coverage
open coverage/lcov-report/index.html
CI 統合 (GitHub Actions)
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install -r requirements.txt
- run: pytest --cov=myapp --cov-report=xml
- uses: codecov/codecov-action@v4
with:
files: ./coverage.xml
Flaky テストの回避
Flaky (フレーキー) テスト = 実行ごとに成否がぶれるテスト。CI の信頼性を破壊するので必ず潰します。
- 時刻に依存 → クロックを注入してモック (Clock / Carbon::setTestNow)
- ランダム値 → seed 固定
- 並行実行の競合 → テスト間の状態共有を排除
- 外部 API 呼び出し → モック化
- テストの順序依存 → 各テストで独立した状態を準備
- sleep で待っている → イベントベース or polling で安定化
FAQ
Q: 何をテストすべき?
A: 分岐があるロジックと境界値を優先。単純な getter/setter や framework に丸投げの部分は省略可。
Q: テストが書きづらいコードに出会ったら
A: それは設計のシグナル。依存を引数で受け取る (DI)、純粋関数に分離する、責務を分けるとテストしやすくなる。
Q: テストを書く時間がない
A: テストは長期的な開発速度を上げる。バグ調査・リファクタ・新機能追加すべてが楽になる。重要モジュールから少しずつ。