到目前為止我們用 加一個 來檢查程式。它能用,但有三個問題: 所有檢查擠在一個方法裡,第一個失敗就會讓後面的全部跳過;沒有統一的報告; 而且很難「對同一份契約測多種實作」。JUnit 是 Java 世界的標準測試框架,正好解決這三件事。
為什麼要寫自動化測試?
回歸保護(regression):每次修改後一鍵重跑,立刻知道有沒有弄壞以前能動的東西。
可執行的規格:測試把介面註解裡的前置/後置條件變成會跑的程式,比文字更精確、永不過時。
設計回饋:難以測試的程式通常也難以使用;先寫測試會逼你把介面設計好。
黑箱測試(black-box)只透過公開介面操作物件,只依據契約判斷對錯,完全不看實作。 同一份黑箱測試可以套用到該介面的每一個實作。
白箱測試(white-box)知道實作細節(例如「陣列滿了會倍增」),可以針對特定分支設計輸入,但換一種實作就失效。 本節以黑箱測試為主,並會標出唯一一個白箱例外。
JUnit 5 入門
一個 JUnit 測試就是一個標了 的方法。JUnit 會自動找到所有這樣的方法、逐一執行、統計結果:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*; // assertEquals、assertTrue…
class CalculatorTest {
@Test
void additionWorks() {
assertEquals(4, 2 + 2); // (預期值, 實際值)
}
}
常用的斷言(都在 ):
@lL0.55@ 斷言 & 意義
& 兩者 ;預期值在前,失敗訊息才會正確。
/ & 條件為真/假。
/ & 是/不是 。
& 兩個陣列長度相同且逐元素相等( 比陣列只比參照,永遠不要用)。
& 執行 lambda 時必須拋出 E(或其子類別);回傳該例外供進一步檢查。
& 無條件失敗,用於「不該執行到這裡」。
每個斷言都可以多接一個字串參數當失敗訊息:。
JUnit 對每一個 方法都會 一個新的測試類別物件來執行。 所以測試之間不會共享欄位狀態,順序也不重要——這是刻意的:一個測試留下的垃圾不該影響另一個。 標了 的方法會在每個測試前執行,適合放「準備一份乾淨的受測物件」的程式碼。
案例: 的黑箱測試
Lecture 07A 的主角是「賓客名單」——一個集合語意的 ADT:一個人要嘛會來、要嘛不會來, 同一個人 兩次等於 一次。
public interface GuestList {
void add(String name); // 記錄某位賓客會來
void remove(String name); // 記錄某位賓客不來了(原本不在名單上則什麼都不做)
boolean isComing(String name); // 該賓客目前是否會來
int count(); // 目前會來的人數(不重複計算)
String[] coming(); // 會來的名字(防禦性複製)
}
測試只認識這個介面。為了讓同一套測試能套用到多種實作,我們把測試類別寫成抽象的, 把「要測哪一種實作」交給一個抽象的工廠方法:
abstract class GuestListTest {
abstract GuestList create(); // 由子類別決定要測哪一種實作
private GuestList gl;
@BeforeEach
void setUp() {
gl = create(); // 每個測試開始前都拿到一份全新的空名單
}
@Test
@DisplayName("新名單是空的")
void emptyListHasNobody() {
assertFalse(gl.isComing("Fabian"));
assertEquals(0, gl.count());
assertEquals(0, gl.coming().length);
}
@Test
@DisplayName("集合語意:同一個人 add 兩次等於 add 一次")
void addingTwiceIsIdempotent() {
gl.add("Fabian");
gl.add("Fabian");
assertTrue(gl.isComing("Fabian"));
assertEquals(1, gl.count(), "重複加入不應被重複計算");
gl.remove("Fabian");
assertFalse(gl.isComing("Fabian"), "移除一次之後必須完全消失");
assertEquals(0, gl.count());
}
@Test
@DisplayName("coming() 回傳的是防禦性複製,改它不影響名單")
void comingReturnsDefensiveCopy() {
gl.add("Fabian");
gl.add("Nicholas");
String[] snapshot = gl.coming();
snapshot[0] = "Intruder"; // 惡意(或不小心)改動回傳的陣列
assertFalse(gl.isComing("Intruder"), "封裝:外部改動不得影響內部狀態");
assertEquals(2, gl.count());
}
@Test
@DisplayName("assertThrows 示範:null 名字不合法")
void nullNameIsRejected() {
IllegalArgumentException e =
assertThrows(IllegalArgumentException.class, () -> gl.add(null));
assertNotNull(e.getMessage());
}
// ……共 9 個測試:remove 只影響該人、從空名單 remove、移除後再加、1000 人觸發倍增……
}
class LazyGuestListTest extends GuestListTest { // 對「懶惰」實作跑整套測試
@Override GuestList create() { return new GuestArrayList(); }
}
class StrictGuestListTest extends GuestListTest { // 對「嚴格」實作跑同一套測試
@Override GuestList create() { return new GuestArrayUniqueList(); }
}
每一個測試都對應契約裡的一句話,或一個邊界:
空:新名單沒有人、、 長度 0。
單一與多個:add 一人、兩人;remove 只影響被移除的那一位。
重複:同一人 add 兩次仍算一人;remove 一次後完全消失(這條最容易讓實作出錯)。
不存在:對空名單或不在名單上的人 remove,不能出錯。
來回:移除後再加回來。
大量:1000 人——確保內部陣列放大時不丟資料(黑箱看不到倍增,但「很多」總是值得測)。
封裝:改動 的回傳值不得影響名單。
例外: 檢查 。注意:介面沒規定 null 行為,所以這一條其實是白箱測試,只對我們的實作成立。
兩種實作,一套測試
「懶惰」實作 的 不檢查重複,直接塞進陣列;重複要等 時一次清光, 則只在名字「第一次出現」時計數:
public class GuestArrayList implements GuestList {
String[] data = new String[2]; // package-private:子類別可直接存取
int used = 0; // data[0..used-1] 是「用過」的格子(可能含 null 洞與重複)
@Override
public void add(String name) {
if (name == null) throw new IllegalArgumentException("賓客名字不可為 null");
if (this.used == this.data.length) { // 滿了:換一個兩倍大的陣列
String[] old = this.data;
this.data = new String[this.used * 2];
for (int i = 0; i < this.used; i++) this.data[i] = old[i]; // O(n) 複製
}
this.data[this.used] = name;
this.used = this.used + 1;
}
@Override
public int count() {
int n = 0;
for (int i = 0; i < this.used; i++) {
if (this.data[i] != null && indexOf(this.data[i]) == i) n = n + 1; // 只數第一次出現
}
return n;
}
// remove:掃完整個陣列,把所有同名都設成 null;isComing:線性搜尋;coming:複製到新陣列
}
「嚴格」實作 繼承它,只改 (先查 ), 因而多了一條不變量「非 null 的名字互不相同」,於是 找到第一個就能停、 不必查重。 兩者對外行為完全一致——是 LSP 合規的子型別——所以 18 個測試(9 × 2 種實作)全部通過:
Test run finished after 185 ms
[ 5 containers found ]
[ 5 containers successful ]
[ 18 tests found ]
[ 18 tests started ]
[ 18 tests successful ]
[ 0 tests failed ]
兩種實作的內部差很多(一個有洞和重複、一個沒有),但測試一行都不用改。 這件事只有在測試完全不碰實作細節時才成立——變數宣告成 ,不要宣告成 ; 永遠不要在測試裡讀 或 。
測試驅動開發
先寫測試,再寫程式,以極短的循環反覆:
紅:為下一個小功能寫一個測試,執行——它必須失敗(否則測試沒測到東西)。
綠:寫最少的程式讓測試通過。允許醜、允許寫死。
重構:整理程式(去重複、改名、抽方法),過程中測試必須一直保持綠色。
用 走一遍 TDD 的前三圈:
紅:寫 。此時 連類別都沒有,編譯失敗也算「紅」。 綠:建立類別, 直接 、 直接 。通過。
紅:寫 (add 之後 isComing 為 true)。寫死的 失敗。 綠:加上陣列與 , 存進去, 線性搜尋。
紅:寫 。 回傳 2,失敗—— 這個測試逼我們決定:是在 時擋重複(嚴格),還是在 / 時處理(懶惰)?兩條路都通,於是有了兩種實作。 重構:兩種實作共用的程式碼抽到父類別。
你永遠只需要專注「讓這一個測試通過」,問題被切成小塊。
寫完時測試覆蓋率自然接近 100%,而且每個測試都曾經失敗過——你知道它們真的有用。
「最少程式」原則防止過度設計;「重構」步驟確保程式不會越寫越亂。
在作業與專案中,TDD 讓你隨時都有一個可以交出去的版本——只是功能還不全。
防禦性複製
若直接 ,測試 會失敗:呼叫者改陣列就等於改名單內部。 規則很簡單:
當一個方法要回傳內部的可變物件(陣列、、……)時,回傳一份副本; 當一個方法接收外界的可變物件並要長期保存時,也先複製一份再存。 不可變的東西(、、record)不需要複製。
String[] copy = new String[count()]; // 全新陣列
// ……把非 null 且第一次出現的名字複製進去……
return copy; // 呼叫者改 copy 不影響 data
// 第 3 節的另一個例子:
public List<String> getLog() { return new ArrayList<>(this.log); }
陣列倍增:為什麼滿了要「乘二」而不是「加一」?
在陣列滿時把容量加倍。每次放大都要 \(\bigO{n}\) 複製,聽起來很貴, 但因為容量呈 \(2, 4, 8, 16, \ldots\) 成長,放大的次數只有 \(\log_2 n\) 次, 把總複製成本平均到每一次 上,每次只多付常數——這叫攤銷 \(\bigO{1}\)。 若改成每次只加一格,每次 都要複製整個陣列,\(n\) 次 就變成 \(\bigO{n^2}\)。 完整的分析(含圖)在第 [sec:amortised] 節,因為雜湊表的擴容用的是一樣的道理。
在命令列執行 JUnit
IntelliJ 會自動處理 JUnit;若想在終端機執行,下載 JUnit 的獨立執行檔(一個 jar)即可:
$ cd code/05_junit_tdd
$ curl -LO https://repo1.maven.org/maven2/org/junit/platform/junit-platform-console-standalone/1.12.2/junit-platform-console-standalone-1.12.2.jar
$ javac -cp junit-platform-console-standalone-1.12.2.jar *.java
$ java -jar junit-platform-console-standalone-1.12.2.jar execute --class-path . --scan-class-path
練習
為第 [sec:adt] 節的 寫一個抽象的 ,至少包含: 新紀錄 ;add 三則後 ; 後 。 用兩個子類別分別測 與 。 思考:「add 四則後 是多少?」對兩種實作答案不同——這代表什麼?介面契約該怎麼改?
環狀緩衝區會回 3,鏈結串列會回 4。這表示 的契約沒有說清楚「size 是否可能小於 add 的次數」。 兩種修法:(a) 把契約寫成「 回傳目前保存的則數,實作可以丟棄舊訊息」, 測試改成 ; 或 (b) 把「有容量上限」抽成另一個介面 。 黑箱測試常常就是這樣暴露契約的漏洞——這是它最有價值的副作用。
用 TDD 實作一個 類別:、、, 以及「 永遠不會超過建構子給的上限,超過時 拋出 」。 嚴格遵守:每次只寫一個測試、先看到它失敗、再寫最少的程式。記錄你總共跑了幾圈。