還記得昨天我們學會了例外處理測試,確保程式在錯誤情況下的穩定運行嗎?今天要面對一個更深層的問題:「我們的測試到底覆蓋了多少程式碼?」
測試覆蓋率(Test Coverage)就像是程式碼的健康檢查報告,告訴你哪些程式碼被測試了,哪些還沒有。它不是萬能的,但是一個非常有用的指標,幫助我們找出測試的盲點。
我們正在第九天的基礎測試概念學習:
基礎測試概念(第 1-10 天)
├── ✅ Day 1: 環境設置與第一個測試
├── ✅ Day 2: 測試斷言
├── ✅ Day 3: TDD 紅綠重構
├── ✅ Day 4: 測試結構
├── ✅ Day 5: 生命週期
├── ✅ Day 6: 參數化測試
├── ✅ Day 7: 測試替身基礎
├── ✅ Day 8: 例外處理測試
├── 📍 Day 9: 測試覆蓋率(今天)
└── Day 10: 重構技巧
今天結束後,你將學會:
📊 測試覆蓋率讓我們能夠量化測試完整性,發現潛在的測試盲點。
覆蓋率幫助我們:
更新 composer.json
:
{
"scripts": {
"test:coverage": "pest --coverage",
"test:coverage-html": "pest --coverage --coverage-html=coverage"
}
}
配置 php.ini
:
; 使用 PCOV(推薦)
extension=pcov.so
pcov.enabled=1
更新 pest.php
:
<?php
uses(Tests\TestCase::class)->in('Feature');
uses(Tests\TestCase::class)->in('Unit');
coverage()
->source(['app'])
->exclude([
'app/Console',
'app/Providers'
])
->thresholds(
lines: 80,
functions: 80,
branches: 70
);
讓我們用一個實際的例子來理解覆蓋率的重要性。
建立 app/Services/Calculator.php
<?php
namespace App\Services;
class Calculator
{
public function divide(float $a, float $b): float
{
if ($b === 0.0) {
throw new \InvalidArgumentException('Cannot divide by zero');
}
return $a / $b;
}
public function getDiscount(float $amount, bool $isVip = false): float
{
if ($isVip) {
return $amount * 0.2;
}
return $amount > 100 ? $amount * 0.1 : 0;
}
}
建立 tests/Unit/Day09/CoverageTest.php
<?php
use App\Services\Calculator;
describe('Calculator Coverage', function () {
beforeEach(function () {
$this->calculator = new Calculator();
});
it('divides numbers correctly', function () {
expect($this->calculator->divide(10.0, 2.0))->toBe(5.0);
});
it('throws exception for division by zero', function () {
expect(fn() => $this->calculator->divide(10.0, 0.0))
->toThrow(InvalidArgumentException::class);
});
it('calculates vip discount', function () {
expect($this->calculator->getDiscount(100.0, true))->toBe(20.0);
});
it('calculates regular discount for large amount', function () {
expect($this->calculator->getDiscount(150.0, false))->toBe(15.0);
});
it('returns zero discount for small amount', function () {
expect($this->calculator->getDiscount(50.0, false))->toBe(0.0);
});
});
# 產生覆蓋率報告
composer run test:coverage
# 產生 HTML 報告
pest --coverage --coverage-html=coverage
執行後會看到:
Tests: 5 passed
Coverage: 85.7% (lines), 100% (functions), 75% (branches)
Uncovered lines:
- Calculator.php:15 (處理負數情況)
- Calculator.php:20 (邊界條件)
覆蓋率報告標示:
# 產生詳細報告
pest --coverage --coverage-html=coverage
open coverage/index.html
優先改善:核心業務邏輯、錯誤處理、複雜分支
// 補強未覆蓋的邊界條件
it('handles edge cases', function () {
$calculator = new Calculator();
// 測試零值情況
expect($calculator->getDiscount(0.0, true))->toBe(0.0);
// 測試負值情況
expect($calculator->getDiscount(-10.0, false))->toBe(0.0);
// 測試極大值情況(使用 toBeGreaterThan 避免浮點數精度問題)
expect($calculator->getDiscount(999999.0, true))->toBeGreaterThan(199999.0);
});
// ❌ 為了覆蓋率寫無意義測試
it('covers getter', function () {
expect((new MyClass())->getValue())->toBe(null);
});
// ✅ 寫有價值的測試
it('calculates discount correctly', function () {
$calculator = new Calculator();
expect($calculator->getDiscount(100.0, true))->toBe(20.0);
expect($calculator->getDiscount(50.0, false))->toBe(0.0);
expect($calculator->getDiscount(200.0, false))->toBe(20.0);
});
避免:為數字而測試、忽略實際價值、測試實作細節
// ❌ 測試 getter/setter 無意義
it('tests simple getter', function () {
$model = new User();
$model->name = 'John';
expect($model->name)->toBe('John');
});
// ✅ 測試業務邏輯
it('validates user discount eligibility', function () {
$user = new User(['purchases' => 5]);
expect($user->isEligibleForDiscount())->toBeTrue();
$newUser = new User(['purchases' => 0]);
expect($newUser->isEligibleForDiscount())->toBeFalse();
});
// ❌ 只測試正常路徑
it('processes payment', function () {
$payment = new Payment(100.0);
expect($payment->process())->toBeTrue();
});
// ✅ 包含邊界測試
it('handles various payment amounts', function () {
// 最小金額
expect((new Payment(0.01))->process())->toBeTrue();
// 零金額
expect(fn() => (new Payment(0))->process())
->toThrow(InvalidArgumentException::class);
// 負數金額
expect(fn() => (new Payment(-10))->process())
->toThrow(InvalidArgumentException::class);
// 極大金額
expect((new Payment(999999.99))->process())->toBeTrue();
});
迷思:高覆蓋率 = 高品質
迷思:100% 覆蓋率是目標
透過今天的學習,我們掌握了:
測試覆蓋率讓我們能夠客觀衡量測試完整性,發現未測試的程式碼路徑,指導測試策略。記住,覆蓋率是品質的指標之一,但不是唯一指標!
今天的關鍵要點:
覆蓋率類型
工具配置
最佳實踐
明天是第十天,我們將學習重構技巧,了解如何在測試的保護下安全地改善程式碼結構。你會學到如何運用測試來支援重構,以及常見的重構模式。
今天我們深入探討了測試覆蓋率,這是 TDD 過程中重要的品質指標。我們學會了如何配置和使用 Pest 的覆蓋率工具,理解了不同類型的覆蓋率指標,並知道如何合理地運用覆蓋率來改善測試品質。
記住:覆蓋率是手段而非目的。用覆蓋率來指導測試策略,而不是盲目追求數字!明天我們將學習如何在測試的保護下進行重構。 🚀
挑戰作業:檢查你現有專案的測試覆蓋率,找出至少三個未被測試的函數,為它們補充測試案例。