到目前為止我們用 加一個 來檢查程式。它能用,但有三個問題: 所有檢查擠在一個方法裡,第一個失敗就會讓後面的全部跳過;沒有統一的報告; 而且很難「對同一份契約測多種實作」。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          ]

兩種實作的內部差很多(一個有洞和重複、一個沒有),但測試一行都不用改。 這件事只有在測試完全不碰實作細節時才成立——變數宣告成 ,不要宣告成 ; 永遠不要在測試裡讀 或 。

測試驅動開發

先寫測試,再寫程式,以極短的循環反覆:

  1. 紅:為下一個小功能寫一個測試,執行——它必須失敗(否則測試沒測到東西)。

  2. 綠:寫最少的程式讓測試通過。允許醜、允許寫死。

  3. 重構:整理程式(去重複、改名、抽方法),過程中測試必須一直保持綠色。

TDD 的紅-綠-重構循環。

用 走一遍 TDD 的前三圈:

  1. 紅:寫 。此時 連類別都沒有,編譯失敗也算「紅」。 綠:建立類別, 直接 、 直接 。通過。

  2. 紅:寫 (add 之後 isComing 為 true)。寫死的 失敗。 綠:加上陣列與 , 存進去, 線性搜尋。

  3. 紅:寫 。 回傳 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 實作一個 類別:、、, 以及「 永遠不會超過建構子給的上限,超過時 拋出 」。 嚴格遵守:每次只寫一個測試、先看到它失敗、再寫最少的程式。記錄你總共跑了幾圈。