
Architecture Journal
AI 時代的自動化測試與驗收(三):Unit test(下)換一種實作,測試還能留下嗎?
系列定位:上一篇講完 unit test 的概念(定義、不變量、mock 紀律、取捨)。這篇先用同一組測試替換
discount_price的實作,確認測試保護的是行為而不是寫法;再補充同一組 case 在 Python、Java、C++ 裡的多元寫法,也看「100% line coverage 仍然漏掉邊界」這件事怎麼發生。本文的核心範例都實際跑過,跑測命令與輸出一併附上。
這篇想回答什麼
上一篇的結論可以濃縮成一句:unit test 在釘不變量,而且 AI 時代稀缺的是「判斷哪些不變量值得保護」,不是「生不生成得出來」。
這篇不談新概念,把同一句話用程式碼落地。想回答的問題是:
- 如果只替換成另一種正確實作,同一組 unit test 是否仍然通過?
- 怎麼判斷測試保護的是行為,而不是目前的寫法?
- parameterized test 和 table-driven 到底是同一個東西,還是不同東西?
- 為什麼 line coverage 到 100%,規則還是可能漏掉?
- 「該替換什麼、不該 mock 什麼」寫成 Java 是什麼樣子?
共同範例:discount_price 的規則
這一集的範例很簡單,一個折扣計算函式:
price必須大於等於 0rate必須在 0 到 1 之間- 回傳
price × rate,依 half-up(四捨五入)規則取到小數點第二位
這條規則的不變量,可以拆成兩類:
- 計算規則:給定合法輸入,結果要等於期望值
- 輸入約束:非法輸入要被拒絕,不能默默算出一個奇怪數字
對應的 case 表長這樣:
| 名稱 | price | rate | 期望 |
|---|---|---|---|
| 零折扣 | 100.0 | 0.0 | 0.0 |
| 全額 | 100.0 | 1.0 | 100.0 |
| 半價 | 100.0 | 0.5 | 50.0 |
| 四分之一 | 100.0 | 0.25 | 25.0 |
| 捨入 | 10.0 | 0.333333 | 3.33 |
| 五入(半數) | 1.0 | 0.005 | 0.01 |
| 零價格 | 0.0 | 0.5 | 0.0 |
| 負價格(應拒絕) | -1.0 | 0.5 | 拋錯 |
| 折扣超上限(應拒絕) | 100.0 | 1.01 | 拋錯 |
| 負折扣(應拒絕) | 100.0 | -0.1 | 拋錯 |
先記住這個表。後面會先用 Python 示範測試與實作的替換,再補充 Java、C++ 如何表列同一組 case。這也是實作時最重要的分界:先決定規則和期望值,再決定測試框架怎麼表達;不要先讓 AI 讀著函式內容,自動猜一組看起來合理的 assert。
先把規則拆成可執行的測試契約
在開始寫測試前,先把每條規則寫成「前置條件、動作、結果」。這一步看起來像文件工作,實際上是在避免測試被實作牽著走。
| 規則 | 測試輸入 | 驗證方式 |
|---|---|---|
| 合法價格可以計算 | price >= 0、0 <= rate <= 1 | 比對固定的期望金額 |
| 捨入規則固定 | 10.0、0.333333 | 期望值直接寫成 3.33 |
| 負價格不合法 | price = -1.0 | 驗證拋出指定例外 |
| 折扣率超出上限不合法 | rate = 1.01 | 驗證拋出指定例外 |
| 折扣率低於下限不合法 | rate = -0.1 | 驗證拋出指定例外 |
每一列都應該能回答:「這個 case 如果失敗,代表哪條業務規則被破壞?」答不出來的 case,不要因為 AI 生成了就保留。
這個例子使用 double/float 是為了讓三種語言的差異容易看懂。正式的金額邏輯要先決定精度和捨入政策,再選 Decimal、BigDecimal、整數最小貨幣單位,或團隊定義好的 money type;不能因為測試通過,就把二進位浮點數當成金額模型。測試也要驗證這個政策,而不是只驗證某次浮點運算的結果。
第一個實作:Python 的 @pytest.mark.parametrize
pytest 的做法最直覺:用 @pytest.mark.parametrize 把 case 直接列在測試函式上。[1]
# discount.py
from decimal import Decimal, ROUND_HALF_UP
def discount_price(price, rate):
if price < 0:
raise ValueError("price")
if rate < 0 or rate > 1:
raise ValueError("rate")
result = Decimal(str(price)) * Decimal(str(rate))
return float(result.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
# test_discount.py
import pytest
from discount import discount_price
@pytest.mark.parametrize("price,rate,expected", [
(100.0, 0.0, 0.0), # 零折扣
(100.0, 1.0, 100.0), # 全額
(100.0, 0.5, 50.0), # 半價
(100.0, 0.25, 25.0), # 四分之一
(10.0, 0.333333, 3.33), # 捨入
(1.0, 0.005, 0.01), # 五入(半數)
(0.0, 0.5, 0.0), # 零價格
])
def test_discount_price(price, rate, expected):
assert discount_price(price, rate) == expected
@pytest.mark.parametrize("price,rate", [
(-1.0, 0.5), # 負價格
(100.0, 1.01), # 折扣超上限
(100.0, -0.1), # 負折扣
])
def test_invalid_input(price, rate):
with pytest.raises(ValueError):
discount_price(price, rate)
跑測結果:
$ pytest -q
.......... [100%]
10 passed in 0.03s
pytest 的特點:case 就是一張 list,加一行等於加一個 case;-q 安靜模式只給總數,失敗時會完整列出是哪一組參數。對 AI workflow 來說這很好用,agent 要補 case,就是往這張表裡加 row。
最小可用的執行流程如下:
$ python -m venv .venv
$ .venv/bin/python -m pip install pytest pytest-cov
$ .venv/bin/python -m pytest -q
Windows PowerShell 可把最後兩行改成:
PS> .\.venv\Scripts\python -m pip install pytest pytest-cov
PS> .\.venv\Scripts\python -m pytest -q
文章中的 discount.py 和 test_discount.py 放在同一個目錄即可。實際專案則應把測試放在 tests/,用 python -m pytest 執行,避免直接呼叫環境裡不確定是哪一份的 pytest。
換一種實作,測試還能留下嗎?
前面的 test_discount.py 先不要改。現在只替換 discount.py 裡的實作,改用「先換算成 cents,再做 half-up rounding」的寫法:
# discount.py(替換版本)
from decimal import Decimal, ROUND_FLOOR
def discount_price(price, rate):
if price < 0:
raise ValueError("price")
if rate < 0 or rate > 1:
raise ValueError("rate")
amount_in_cents = Decimal(str(price)) * Decimal(str(rate)) * 100
cents = (amount_in_cents + Decimal("0.5")).to_integral_value(rounding=ROUND_FLOOR)
return float(cents / 100)
測試檔案完全不動,再跑一次:
$ pytest -q
.......... [100%]
10 passed in 0.03s
這個結果才是 unit test 值得提供的信心:實作的結構變了,既有測試仍然通過。它沒有證明所有可能的實作都正確,但至少表示測試沒有把目前這組寫法當成唯一答案。
多元實作方式:Java 的 @ParameterizedTest
Java 端用 JUnit 5(Jupiter)的 @ParameterizedTest。有兩段:被測的 domain 類別,跟測試類別。[2]
// Discount.java
import java.math.BigDecimal;
import java.math.RoundingMode;
public final class Discount {
private Discount() {}
public static double price(double price, double rate) {
if (price < 0) throw new IllegalArgumentException("price");
if (rate < 0 || rate > 1) throw new IllegalArgumentException("rate");
return BigDecimal.valueOf(price)
.multiply(BigDecimal.valueOf(rate))
.setScale(2, RoundingMode.HALF_UP)
.doubleValue();
}
}
// DiscountTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
class DiscountTest {
@ParameterizedTest
@CsvSource({
"100.0, 0.0, 0.0", // 零折扣
"100.0, 1.0, 100.0", // 全額
"100.0, 0.5, 50.0", // 半價
"100.0, 0.25, 25.0", // 四分之一
"10.0, 0.333333, 3.33", // 捨入
"1.0, 0.005, 0.01", // 五入(半數)
"0.0, 0.5, 0.0" // 零價格
})
void discountComputes(double price, double rate, double expected) {
assertEquals(expected, Discount.price(price, rate), 0.0001);
}
@ParameterizedTest
@MethodSource("invalidInputs")
void rejectsInvalidInput(double price, double rate) {
assertThrows(IllegalArgumentException.class, () -> Discount.price(price, rate));
}
static Stream<Arguments> invalidInputs() {
return Stream.of(
Arguments.of(-1.0, 0.5), // 負價格
Arguments.of(100.0, 1.01), // 折扣超上限
Arguments.of(100.0, -0.1) // 負折扣
);
}
}
用 JUnit Platform Console Launcher 跑(--select-class 指定測試類別):
[ 10 tests found ]
[ 10 tests successful ]
[ 0 tests failed ]
Java 的習慣跟 Python 不太一樣:case 通常用 @CsvSource(字串表格)或 @MethodSource(回傳 Stream<Arguments>)給。@CsvSource 對簡單純值最省,但參數一複雜、要帶物件或運算式,就會換成 @MethodSource,這也代表 case 可以程式化產生,AI 補 case 時兩邊都很好用。
如果使用 Maven,最小的執行入口是:
$ mvn -q test
junit-jupiter 的 dependency 和 Surefire plugin 應放在專案的 pom.xml,不要把下載 jar、classpath 和測試命令散落在文章或 CI script 裡。這裡只展示測試本身,所以省略完整的 build descriptor;真正放進 repository 時,測試命令應該是新成員拿到專案後可以直接複製執行的命令。
多元實作方式:C++ 的手寫 table loop
C++ 標準函式庫沒有內建的參數化測試。最常見的慣例,是把 case 列成一個 struct 陣列,再用迴圈跑,壞了就印出 case 名稱並失敗。[4]
#include <cmath>
#include <cstdlib>
#include <iostream>
#include <stdexcept>
double discountPrice(double price, double rate) {
if (price < 0) throw std::invalid_argument("price");
if (rate < 0 || rate > 1) throw std::invalid_argument("rate");
// price 與 rate 都是非負數,因此 std::round 在這裡等同 half-up。
return std::round(price * rate * 100.0) / 100.0;
}
namespace {
void testDiscountPrice() {
struct Case { const char* name; double price; double rate; double expected; };
const Case cases[] = {
{"零折扣", 100.0, 0.0, 0.0},
{"全額", 100.0, 1.0, 100.0},
{"半價", 100.0, 0.5, 50.0},
{"四分之一", 100.0, 0.25, 25.0},
{"捨入", 10.0, 0.333333, 3.33},
{"五入(半數)", 1.0, 0.005, 0.01},
{"零價格", 0.0, 0.5, 0.0},
};
for (const auto& c : cases) {
const double got = discountPrice(c.price, c.rate);
if (std::abs(got - c.expected) > 1e-9) {
std::cerr << c.name << ": expected " << c.expected << ", got " << got << "\n";
std::exit(1);
}
}
}
void testRejectsInvalid() {
struct Case { const char* name; double price; double rate; };
const Case cases[] = {
{"負價格", -1.0, 0.5},
{"折扣超上限", 100.0, 1.01},
{"負折扣", 100.0, -0.1},
};
for (const auto& c : cases) {
bool threw = false;
try { discountPrice(c.price, c.rate); }
catch (const std::invalid_argument&) { threw = true; }
if (!threw) {
std::cerr << c.name << ": expected invalid_argument\n";
std::exit(1);
}
}
}
}
int main() {
testDiscountPrice();
testRejectsInvalid();
std::cout << "all 10 cases pass\n";
}
$ clang++ -std=c++17 -O1 -Wall -Wextra -o discount discount.cpp && ./discount
all 10 cases pass
為什麼值得為 C++ 多寫一段?因為它點出一個其他語言不會問你的問題:參數化不是框架內建的,得自己捲。這正是「table-driven」這個概念的原始形態,case 表自己維護,壞了自己印訊息。如果你真的要上 GoogleTest,它有 INSTANTIATE_TEST_SUITE_P 可以做正式的參數化,[3] 但原理跟這個手寫迴圈一樣:一張 case 表,配一個 runner。
手寫 runner 適合展示原理,不一定適合長期維護。正式專案通常讓測試框架負責:
- 產生每個 case 的名稱,讓 CI 能指出是哪一列失敗。
- 統一 assertion、timeout、fixture 和報告格式。
- 把測試掛進既有的 build system,而不是讓
main()自己決定成功或失敗。
選框架不是目的;只要失敗訊息能指回「哪個不變量、哪個輸入」就夠了。
同一個教訓:100% line coverage 仍然漏掉邊界
Dave Cheney 那篇經典的〈Prefer table driven tests〉,示範的就是這個教訓:line coverage 到 100%,不代表邊界被測過。[4] 我在 Python 裡重現一次給你看。
假設實作漏了輸入驗證:
def discount_price(price, rate):
return round(price * rate * 100) / 100
測試只列 happy path(零折扣、全額、半價、四分之一):
@pytest.mark.parametrize("price,rate,expected", [
(100.0, 0.0, 0.0),
(100.0, 1.0, 100.0),
(100.0, 0.5, 50.0),
(100.0, 0.25, 25.0),
])
def test_happy(price, rate, expected):
assert discount_price(price, rate) == expected
結果:全綠,而且 line coverage 100%。
$ pytest test_naive.py::test_happy -q --cov=naive --cov-report=term-missing
.... [100%]
Name Stmts Miss Cover
naive.py 2 0 100%
TOTAL 2 0 100%
4 passed in 0.05s
但這張表沒驗 rate 的上限。把 rate = 1.01 當作拒絕 case 加進去:
@pytest.mark.parametrize("price,rate", [
(100.0, 1.01),
])
def test_rate_ceiling(price, rate):
with pytest.raises(ValueError):
discount_price(price, rate)
立刻爆炸:
$ pytest test_naive.py -q
....F [100%]
_________________________ test_rate_ceiling[100.0-1.01] _________________________
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE <class 'ValueError'>
1 failed, 4 passed in 0.05s
line coverage 量的是「哪些行被執行過」,不是「哪些規則被驗證過」。happy path 全走過,所以每行都執行到了,但 rate > 1 這個不變量從頭到尾沒人問過。在 Java 或 C++ 裡也會看到一模一樣的洞,只是失敗訊號不同:pytest 說 DID NOT RAISE,JUnit 的 assertThrows 會回報預期例外沒有發生,而 C++ 的手寫 try/catch 則必須自己設定失敗訊息與 exit code。
因此,coverage 比較適合當作「找漏測區域」的導航,不適合當作品質的終點。至少要同時看三件事:重要規則是否都有 case、分支是否真的走過、測試在故意改壞實作時會不會失敗。第三件事就是 mutation testing 的核心。
用 mutation testing 檢查測試能不能抓出錯誤
最簡單的 mutation 不需要工具。把實作中的上限判斷故意改壞:
if rate < 0 or rate >= 1: # 把合法的 rate = 1.0 誤判成非法
raise ValueError("rate")
再跑原本的測試,全額 case 應該立刻失敗。如果把 rate > 1 這段驗證整段刪掉,折扣超上限 case 也應該失敗。只要其中任一個故意製造的錯誤沒有讓測試失敗,就代表測試雖然可能有很高的 line coverage,卻沒有真的保護輸入契約。
在團隊流程裡,可以再用 mutation testing 工具自動做同樣的事:工具會暫時修改比較運算子、刪掉分支或替換回傳值,再觀察測試是否失敗。mutation score 不必盲目追求 100%,但重要 domain 規則被改壞時,測試應該要失敗;如果沒有,這就是下一個該補的 case。
該替換什麼、不該 mock 什麼(Java 實作)
上一篇講了理論,這裡給一段實際的。假設折扣要套匯率,PriceService 依賴一個外部 RateProvider:
interface RateProvider {
double rate(String from, String to);
}
// Fake:固定答案,測「邏輯在匯率 = 30 時算得對不對」
class FixedRateProvider implements RateProvider {
@Override public double rate(String from, String to) { return 30.0; }
}
class PriceService {
private final RateProvider rates;
PriceService(RateProvider rates) { this.rates = rates; }
double priceInTwd(double usd) { return usd * rates.rate("USD", "TWD"); }
}
class PriceServiceTest {
@Test
void priceUsesFixedRate() {
PriceService svc = new PriceService(new FixedRateProvider());
assertEquals(9_000.0, svc.priceInTwd(300.0), 0.0001);
}
}
這個 Fake 是替換「外部世界」:匯率是我們控制不了的,給它一個固定值,測的是我們自己的邏輯在匯率 30 時算得對不對。這叫 state verification,Meszaros 和 Fowler 都歸類在 Stub/Fake 這側。[5]
對照組是過度 mock。如果改用 Mockito 寫成「驗證有沒有呼叫」:
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Test
void priceCallsRateProvider() {
RateProvider mock = mock(RateProvider.class);
when(mock.rate("USD", "TWD")).thenReturn(30.0);
PriceService svc = new PriceService(mock);
svc.priceInTwd(300.0);
verify(mock).rate("USD", "TWD"); // 在測「有沒有呼叫」,不是在測結果
}
差別在最後那行 verify:它在鎖「呼叫方式」,不在鎖「行為結果」。實作改成把 rates 快取起來、換個介面方法名,這個測試就會失敗,但業務行為完全沒變。當然,如果「呼叫一次外部服務」本身就是對外可觀察的業務承諾,interaction verification 仍然可能是合理的;這裡要避免的是把所有內部協作都鎖成契約。Fowler 在 Mocks Aren’t Stubs 裡明講,mockist 測試跟實作耦合,會妨礙重構。[6]
這段不代表 verify 永遠不能用。若真正的規則是「付款成功後必須送出一則不可重複的事件」,那麼送事件本身就是可觀察行為,驗證它可能是必要的。判斷標準不是「看到 mock 就刪掉」,而是先問:被驗證的互動是不是需求的一部分?如果只是目前實作選擇的呼叫順序或內部 method,就不值得寫進 unit test。
用同一個範例識破 AI 生成的鏡射測試
上一篇的三個審查問題,套到這個範例上看最清楚。
AI 很容易生成這種「鏡射實作」的測試:
def test_discount_price_uses_round():
# 這在測實作細節:用了 round、乘了 100,不是測規則
assert discount_price(10.0, 0.333333) == round(10.0 * 0.333333 * 100) / 100
第一個問題就問倒它:「把實作換成另一種正確寫法,這個測試還過嗎?」如果正確寫法改用 Decimal 或整數分,測試照樣過,那這個測試就是多餘的;如果正確寫法一改測試就失敗,它就是在鎖實作,不是鎖規則。
反過來,好的測試長這樣:case 有名稱、有邊界、有非法輸入,而且期望值是寫死的數字,不是從實作算回來的。就像前面那張 discount_price 的表,3.33 是規則的期望,不是 round(0.333333 * 10 * 100) / 100 的結果。審查時直接問:「期望值是不是抄自實作?」是,就重寫。
給 AI 的實作指令:先給規格,再給程式碼
如果讓 AI 直接讀 production code 再要求「補 unit test」,它很容易複製實作路徑。比較穩定的做法,是先把不變量和 case 表放進 prompt,並明確要求不要從 production code 推導期望值:
請根據以下業務規則產生 parameterized unit tests。
規則:
1. price 必須 >= 0。
2. rate 必須介於 0 和 1,包含兩端。
3. 合法輸入的結果四捨五入到小數點後兩位。
要求:
- 先列出 happy path、邊界、非法輸入各有哪些 case。
- 每個 expected value 必須是根據規格人工計算的固定值,不能呼叫 production code 推導。
- 每個 case 說明它保護哪條規則。
- 最後才產生 pytest/JUnit/GoogleTest 的測試碼。
- 不要驗證 private method、內部呼叫順序或目前使用的演算法名稱。
接著把 AI 產生的 case 表交給人 review,再讓它轉成不同語言。這樣跨語言搬移的是規格,不是某個框架的測試寫法;未來換語言或重構 production code,測試仍然有同一個判準。
放進 CI:快測試先擋,完整檢查再補
unit test 要能配合 agent 的短迴圈,也要能在 CI 留下較完整的證據。可以分成兩個命令:
# 本機與 agent 每次改動後
python -m pytest -q
# CI 的完整檢查
python -m pytest -q --cov=discount --cov-branch --cov-report=term-missing
Java 以 mvn -q test,C++ 則交給既有的 CMake/CTest 或 GoogleTest runner。不要把 --cov-fail-under=100 當成唯一門檻;如果團隊把數字設成硬門檻,開發者和 AI 都可能為了補行數而新增低價值測試。比較好的 gate 是:測試必須全綠、關鍵模組不能退步、重要 mutation 不可存活,coverage 只用來提醒哪裡可能漏測。
結尾
Python、Java、C++ 的段落,主要是在展示同一張 case table 如何落到不同語言與工具裡。它們本身不能證明 unit test 寫得好,因為測試的寫法也跟著換了。
真正值得看的證據,是前面把 discount_price 換成另一種正確實作後,原本的 Python 測試沒有修改,仍然全部通過。測試保護的是輸入約束、計算結果與 rounding 規則,而不是 Decimal、round 或某個 method 的呼叫順序。
也記得 coverage 不替規則背書。綠燈只代表「表上列了的人都通過」,不代表「表上沒有的人也安全」。AI 時代補測試很快,但把該列進表的邊界補齊,那個判斷仍然是人做的。
下一篇往上走一層:整合測試。unit test 可以讓單一規則在實作替換後仍然有回饋,但元件接在一起時,資料庫、訊息系統、序列化這些真實依賴會怎麼背叛你,是另一套故事。
參考資料
- [1] pytest,〈How to parametrize fixtures and test functions〉 https://docs.pytest.org/en/stable/how-to/parametrize.html
- [2] JUnit 5 User Guide,〈Parameterized Tests〉 https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests
- [3] GoogleTest,〈GoogleTest Primer〉與
INSTANTIATE_TEST_SUITE_Phttps://google.github.io/googletest/ - [4] Dave Cheney,〈Prefer table driven tests〉 https://dave.cheney.net/2019/05/07/prefer-table-driven-tests
- [5] Martin Fowler,〈Test Double〉 https://martinfowler.com/bliki/TestDouble.html
- [6] Martin Fowler,〈Mocks Aren’t Stubs〉 https://martinfowler.com/articles/mocksArentStubs.html