[Testing] Testing Strategy

Cyan Ho
15 min readOct 9, 2021
Photo by Element5 Digital on Unsplash

測試依據目的與方法的不同,分成許多類型,像是 unit test(單元測試)、integration test(整合測試)、acceptance test(驗收測試)、end-to-end test(端對端測試)、manual test(手動測試)等等。

本篇文章以開發者的角度出發,聚焦在與開發過程緊密相連的兩種測試類型 — unit test 和 integration test,深入探討它們各自的特性、適合的場景以及運用策略,幫助開發者評估並選用適配於當下場景的測試類型,來讓測試發揮最大的價值。

本篇文章建立在熟悉測試知識與實作的基礎上,對於還不熟悉測試的讀者,首推神書 單元測試的藝術,和該書譯者 91 的 30 天快速上手 TDD 系列文,系列文前半部分會為讀者打下測試的概念與基礎,後半部分的 TDD 非本文所需知識,可以依照自己的需求學習。

名詞定義

在本篇文章中,將採用以下定義來區別 unit test 和 integration test:

Unit Test

不需要依賴外部系統的測試,亦即不需要設定程式本身以外的環境,就能執行的測試,測試結果只會受到程式本身影響。

Integration Test

需要依賴外部系統的測試,常見的外部系統像是檔案系統、資料庫、執行程式的環境(如瀏覽器、程式引擎),此測試類型在執行之前,必須先將相依的系統環境設定到位,才能開始運作,測試結果會同時受到程式本身與外部系統的狀態影響。

測試價值

測試的核心價值在於提升對程式的信心。

好的測試

在測試成功時,可以很有信心地相信程式是如預期的方式在運作;在測試失敗時,可以透過失敗的測試案例,很明確地指出哪些程式產生非預期的結果。

好的測試會反饋正確的資訊,並且提升對程式的信心。

不好的測試

在測試成功時,心裡會感到不踏實,雖然測試通過了,卻還是會懷疑程式是否真的如預期的方式運作;在測試失敗時,不一定代表程式真的有問題,可能只是無關行為的實作細節調整而導致測試失敗,長久下來反而成為開發的絆腳石,最後乾脆把測試關掉或移除掉。

不好的測試會反饋不正確的資訊,並且降低對程式的信心。

撰寫測試需要花費時間與心力,在開發上是額外要被考慮到的成本,用錯誤的方式撰寫測試會付出很多成本,卻得不到相應的效益。因此,如何根據場景選擇合適的測試類型進行測試,讓投入測試的成本發揮最大的效益,是一件非常重要的事,也是下一章要探討的主題 — 測試策略。

測試策略

測試金字塔(Testing Pyramid)呈現了不同顆粒度的測試類型應該保持的數量比例關係,如下圖所示:

Testing Pyramid

金字塔越上層的測試類型的顆粒度越粗,亦即該類型的測試涉及的範圍越大,例如同時需要前端、後端、第三方服務參與。金字塔各層的面積則代表著數量,面積越大代表該層測試類型的測試數量應該越多。

總結來說,測試金字塔告訴我們,越上層的測試涉及範圍越大、成本越高、速度越慢,所以數量應該越少;越下層的測試涉及範圍越小、成本越低、速度越快,所以數量應該越多。

Unit test 擁有執行快速、實作簡單、小而聚焦的優點,因此測試金字塔建議 unit test 要成為專案裡佔比最多的測試類型,但在實務上並不是所有場景都適合使用 unit test,堅持使用 unit test 有時候反而會寫出不好的測試。

以下使用實際的例子來探討對於不同類型程式的測試策略,為了不糾結於特定語言的語法和特性,例子中的程式範例將以較為抽象的方式撰寫,擺脫不重要的細節,讓程式著重在表達情境與測試的意圖。

情境一:業務邏輯

業務邏輯是應用程式裡最核心的部分,也是邏輯最複雜的地方,稍有差錯就會對系統造成很大的傷害。

Unit test 執行快速、小而聚焦的特性,非常適合用來拆解複雜的業務邏輯,列舉、模擬及驗證各種可能發生的邏輯分支,包括正常流程、異常處理、邊界條件等等。

以一個虛構的銀行服務為例,該銀行提供了一個需要收取手續費的存款服務,當用戶進行存款時,會根據用戶的級別(Normal、VIP)來收取不同的手續費,以下為驗證該銀行存款業務邏輯的測試虛擬碼:

suite("Bank") {
deposit_fee = {
User.Normal: 0.1,
User.VIP: 0.01
}

test("Normal user deposits 100 should receive 90") {
bank = given_a_bank(deposit_fee)
normal_user = given_a_normal_user()

bank.deposit_from(normal_user, 100)

assert bank.balance_of(normal_user) is 90
}
test("VIP user deposits 100 should receive 99") {
bank = given_a_bank(deposit_fee)
vip_user = given_a_vip_user()

bank.deposit_from(vip_user, 100)

assert bank.balance_of(vip_user) is 99
}
}

在實務上,功能的實現除了業務邏輯之外,還需要依賴其他系統的資料,例如銀行的 deposit_fee 要透過資料庫取得。在這個情境下,雖然可以使用 integration test 來進行測試(建立測試資料庫將 deposit_fee 資訊存入,讓測試直接讀取資料庫),在測試案例不多時,可能感受不到和 unit test 的差異,但是隨著功能演進邏輯越來越複雜時,會漸漸發現用 integration test 列舉每個邏輯分支的成本會變大,其中包括執行速度變慢、不容易模擬特殊情境等問題。

因此,當發現(或預期)負責處理業務邏輯的程式片段逐漸複雜時,應該透過 mocking 技術將外部依賴隔離開來,並使用 unit test 聚焦在程式核心邏輯的驗證,讓核心功能和邊界案例能夠有效率地被測試覆蓋,提升對程式的信心。

情境二:可控制的外部系統

可控制的外部系統泛指由團隊自己設置、維護和開發的外部系統,資料庫(database)可說是最常見的例子。這類型的系統能夠在測試環境中,複製出一套和 production 環境相同的系統配置,以更貼近現實的方式來協助軟體測試。

接續上一小節的銀行例子,假設現在有一個代理物件 deposit_fee_querier 負責協助銀行從 SQL 資料庫取得 deposit_fee,若是使用 unit test 來測試 deposit_fee_querier,很有可能會寫出像下面這樣的測試:

suite("Deposit Fee Querier") {
test("Query deposit fee") {
mock_db = given_a_mock_db()
deposit_fee_querier = given_a_deposit_fee_querier(mock_db)

deposit_fee_querier.query()

assert mock_db is queried with "
SELECT user_type.key, deposit_fee.fee
FROM deposit_fee
JOIN user_type ON user_type.id = deposit_fee.user_type_id
WHERE user_type.key IN ('Normal', 'VIP');
"
}
}

當這個測試通過時,只代表了產品程式使用了與測試相同的 SQL 語法查詢資料庫,不禁會讓人懷疑,這段 SQL 語法丟到資料庫執行後,真的會是我們想要的結果嗎?

當這個測試失敗時,只代表了產品程式沒有使用測試指定的 SQL 語法查詢資料庫,但是同樣的查詢結果可以有不同的 SQL 寫法,也有可能實作裡使用了 ORM 套件,不禁會讓人懷疑,產品程式是真的有問題嗎?

測試的結果讓人感到不確定,它就是一個不好的測試,撰寫測試付出的成本並沒有得到相應的效益,儘管它執行快速,但是它無法提升我們對程式的信心;而且過度指定實作方式,會讓測試變得脆弱,任何產品程式的重構都可能會導致測試失敗,大大增加未來維護成本。

在這個例子中,真正的查詢邏輯是實現在資料庫裡,唯有讓資料庫參與測試,才能夠驗證結果的正確性,進而對測試產生信心。假設現在測試環境裡已經配置了與 production 環境相同的資料庫,使用 integration test 改寫上述測試如下:

suite("Deposit Fee Querier") {
db = connect_to_db() // connect to real database
before_each_test {
db.clean()
}
test("Query deposit fee") {
db.save(
deposit_fee = {
User.Normal: 0.1,
User.VIP: 0.01
}
)
deposit_fee_querier = given_a_deposit_fee_querier(db)

deposit_fee = deposit_fee_querier.query()

assert deposit_fee is {
User.Normal: 0.1,
User.VIP: 0.01
}
}
}

當改寫的 integration test 通過後,甚至不需要知道 deposit_fee_querier 的實作細節,例如 SQL 如何撰寫、或是使用了哪個 ORM 套件,就能很有信心地確定 deposit_fee 查詢功能是如預期的方式運作,未來不管 deposit_fee_querier 實作細節如何重構,只要測試保持通過,就能確定其功能依舊是符合預期的。

值得注意的是,在使用 integration test 時要確保每個測試使用到的資料庫狀態各自獨立,若測試開始或結束前沒有將資料庫狀態清空,測試可能會被其他測試遺留的狀態影響,導致測試案例失敗,儘管產品程式本身是正確的。

在可控制的系統情境下,使用 integration test 能夠幫助開發者將測試案例聚焦在行為本身而非實作細節,雖然相較於 unit test 付出了執行速度變慢的成本,卻能因此換得具有保護力、維護成本低、並且能夠提升信心的好的測試。

情境三:不可控制的外部系統

不可控制的外部系統泛指由第三方提供的服務,例如社群登入(FB、Google Login)、第三方金流服務等等,這些服務無法在測試環境中複製出來,也因此衍生出不同的測試策略。

如果第三方服務無法設定方便對接的測試環境,可以使用 unit test 針對介接第三方服務的資料轉換邏輯進行測試,驗證系統實作相容於第三方服務的介面。在此使用與情境二相同的例子,將資料庫改成第三方服務 fee_service,並使用 unit test 改寫測試如下:

suite("Deposit Fee Querier") {
test("Query deposit fee") {
mock_fee_service = given_a_mock_fee_service()
mock_fee_service.return({
"fees": [
{"type": "normal", "value": 0.1},
{"type": "vip", "value": 0.01}
]
})
deposit_fee_querier = given_a_deposit_fee_querier(mock_fee_service)
deposit_fee = deposit_fee_querier.query() assert mock_fee_service is queried with {
"type": "deposit"
}
assert deposit_fee is {
User.Normal: 0.1,
User.VIP: 0.01
}
}
}

如果第三方服務可以設定方便對接的測試環境,可以套用情境二的經驗,使用 integration test 直接介接 fee_service,進行不關心實作細節、更貼近使用案例的測試:

suite("Deposit Fee Querier") {
fee_service = given_a_fee_service()

before_each_test {
fee_service.reset()
}

test("Query deposit fee") {
fee_service.set_fees({
"type": "deposit",
"fees": [
{"type": "normal", "value": 0.1},
{"type": "vip", "value": 0.01}
]
})
deposit_fee_querier =
given_a_deposit_fee_querier(fee_service)
deposit_fee = deposit_fee_querier.query()

assert deposit_fee is {
User.Normal: 0.1,
User.VIP: 0.01
}
}
}

與情境二相同,使用 integration test 時,要確保每個測試使用的第三方服務狀態各自獨立,測試之間不能互相影響,如果實現第三方服務狀態隔離的成本很高,就需要思考進行 integration test 的成本是否會大於效益。

另外,在這個情境裡使用 integration test 還有一個好處,當第三方服務的介面或邏輯無預警地更新了,integration test 能夠主動發現異動,即時地驗證現行系統是否可以相容於新的改動;如果只使用 unit test,只能被動地等到問題在其他地方被發現後,才能將第三方服務的更新反應到測試案例中,再對程式進行修復。

在不可控制的外部系統情境下,unit test 和 integration test 都有各自發揮價值的地方。當介接第三方服務的資料轉換邏輯複雜時,使用 unit test 能夠聚焦在轉換邏輯的驗證,並且可以模擬在真實系統難以重現的邊界狀況;使用 integration test 能夠以更貼近使用案例的角度,不干涉實作細節,並且可以主動確認第三方服務規格,是否還相容於現行系統的運作方式。

了解各自的優點後,就能依照現實狀況評估效益與成本,決定使用的測試類型,例如:第三方系統提供測試環境接入,加上資料轉換邏輯簡單,團隊評估只需要 integration test 就能提供足夠的信心,因此短期內可以先節省建置 unit test 的成本。

情境四:義大利麵(Spaghetti)系統

義大利麵(Spaghetti)常常被用來形容職責糾纏不清、結構混亂的程式,義大利麵系統通常都會有一個特色,就是很難進行 unit test。

Photo by Immo Wegmann on Unsplash

這個情境常常會讓人陷入兩難的局面,為了進行測試,必須要先重構產品程式,但重構產品程式前,又必須先有測試保護,才能確保重構完成後沒有破壞原有的產品功能,最後乾脆以不變應萬變,繼續煮義大利麵。

面對這個狀況,先從 integration test 著手能在短期內發揮很大的效益,integration test 能夠以貼近使用案例的角度來描述系統行為,讓開發者可以暫時忽略雜亂的架構設計來保護產品功能。除此之外,integration test 能用較少的案例來覆蓋更大的程式範圍,對於沒有測試的系統,可以快速地提升測試覆蓋率,提升對程式的信心,進而增加重構架構的勇氣。

在情境二的例子裡,可以看到 integration test 如何以不關注實作細節的方式,對系統功能進行測試。

當 integration test 覆蓋程度足夠支撐想做的重構後,就可以開始進行重構,拆分程式職責並製造接縫(Seam)來分離業務邏輯和其他實作細節,每一個重構段落都能透過先前建立的 integration test 來確保功能沒有被改壞掉。

重構持續進行到程式架構逐漸清晰後,就可以開始加入 unit test,針對核心的業務邏輯進行更完整的覆蓋,回到情境一的狀況。

結語

每種測試類型都有其優缺點,沒有絕對的好壞,了解不同測試類型的表現,能夠幫助開發者在不同情境下,評估並選擇出合適的方式進行測試。

雖然好的測試可以帶來很多好處,但也不能忽略撰寫測試所需付出的成本,如何在可承擔的成本範圍裡,運用測試策略,優先保護產品核心的功能,是開發和測試並行的過程中,需要不斷去檢視和思考的課題。

你是否有寫過讓自己懷疑皺眉的測試?或是經歷過讓人困擾的測試情境?歡迎留言或私信我一起分享討論 😃

延伸閱讀

--

--