2022年8月7日

程式是人寫出來的,測試也是

本文章首次刊登於 Web 長島計畫《第三期:測試,這不是測試

軟體測試是什麼

軟體開發的過程中,除了功能本身的開發外,還有一個很重要的環節是「測試」。有的人聽到「寫測試」,直覺反應是「好麻煩」,或覺得這只是個浪費時間的事。如果程式一開始就寫的好,為什麼還需要花時間來寫測試呢?
當然,如果能確定程式是「正確無誤的」,就可以省去寫測試的時間。然而,實際情況下,程式是人寫出來的;是人寫的,就有機會犯錯,只是錯的多或少罷了。
如果把整個「軟體開發」的過程比喻做「寫考卷」,開發者就像是寫考卷的學生。不管你有沒有讀書,都還是可以作答並交出考卷。至於要怎麼知道答案寫的正不正確、能夠得到多少分數,就得要靠「對答案」才能知道了。「對答案」這個動作,其實就是「測試」在做的事。測試是在核對開發者開發出來的功能,是不是確實符合 PM 撰寫的產品規格。
相信讀者一定有過這樣的經驗:我們認為這題答案一定是 A,但解答卻是 D。寫程式也是一樣,你可能會認為,程式這樣寫應該就「對了」。然而,程式執行後到底會不會有問題,還是得要做了「測試」才知道。
實際上,平常在開發功能的過程中,開發者就一直在做測試了。
假設你正在開發一個提示視窗。你預期使用者點了問號的按鈕後,這個視窗就會彈出在畫面上。於是,在開發好這個功能後,你會實際去點這個按鈕。點下去之後,如果這個提示視窗也真的有跳出來,你才會認為,你成功完成這個功能了。在這個前端的例子中,你「預期」點了按鈕應該要有反應,且程式執行後,也「確實」得到你預期的結果。後端的例子可以是這樣的:在開發好 API 後,你會透過工具來實際看看發出請求後的「實際結果」,確認這些結果是否和你的「預期結果」相同。
這個比較「預期結果」和「實際結果」是否相同的過程,就是測試的核心概念。這裡的一個重點是,如果連你自己也不知道預期的正確結果是什麼,那根本沒辦法開始進行測試
如果連你自己也不知道預期的正確結果是什麼,那根本沒辦法開始進行測試

用程式測試程式:寫測試

既然說「比較『預期結果』和『實際結果』是否相同的過程」就已經是在進行測試,為什麼我們還需要額外花時間寫測試呢?其中最大的差別就在於「手動」和「自動」。
開發者之所以要「寫測試」,就是通過程式的方式,把手動測試這個繁瑣的流程給自動化。如此,就可以在每次產品上線前,讓程式自動跑一次完整的測試,而不需要手動測試每一項功能。剩下來的時間,不論是要用來開發新功能、外出買一杯珍珠奶茶、或是轉頭和同事聊聊天,都要比每次重複手動執行測試開心得多。
除此之外,測試檔本身就是一份帶有記錄的文件。開發者可以寫下當初開發時沒留意到的情境,當成測試案例,避免未來有其他開發者重蹈覆轍。
接著就讓我們來看一下,通常測試實際是怎麼寫的。

實際的測試範例

讓我們以 JavaScript 的 Jest 為例來看一段實際測試的程式碼。
假設我們寫了一個名為 sum 的函式,這個函式應該要把參數的值相加後回傳。如果 sum 這個函式帶入的參數是 1 和 2,應該預期可以得到 3。這時候針對 sum 這個函式,就可以寫出如下的測試:
it('sums numbers', () => {
  const actualResult = sum(1, 2);
  const expectedResult = 3;

  expect(actualResult).toEqual(expectedResult);
});
即使你不會寫 JavaScript,從變數的命名,應該也可以猜想這段程式執行的內容:
  • 這裡有一個名為 sum 的函式,我們把這個函式執行的結果存成變數 actualResult
  • 接著把預期的正確結果存成變數 expectedResult
  • 最後透過 expect 這個方法,來比較「實際執行的結果(actualResult)」是不是和預期的結果(expectedResult)相同(toEqual
上面這個過程就是最基本測試的概念。透過程式來測試寫好的程式,將可以不用再手動透過 console.log() 的方式把結果列印出,再比對原本寫的 sum 有沒有錯。以上這些動作,都可以透過程式自動幫我們進行檢測。
如果測試中預期結果和函式實際執行後的結果相同時,就會得到 PASS:
Screen Shot 2021-12-20 at 12.34.10 AM
相反的,如果 sum 這個函式的實作邏輯有錯誤,sum(1, 2) 得到的並不是 3 時,就會顯示 FAIL:
Screen Shot 2021-12-20 at 12.36.16 AM
這裡測試的結果告訴開發者,雖然我們預期會得到 3,但實際上得到的是 0,所以測試並不通過。
這裡雖然是以 JavaScript 來舉例,但實際上,測試的核心概念在不同語言間是通用的,都是去比較「預期的正確結果」和「程式執行後的實際結果」是否相同來判斷。
例如,以 Go 語言來說:
func TestAdd(t *testing.T) {
	actualResult := Sum(1, 2)
	expectedResult := 3

	if actualResult != expectedResult {
		t.Fail()
	}
}
同樣會看到需要去比較 actualResult 是否和 expectedResult 相同,如果不同的話,該測試就會 Fail。
如果讀者有在 LeetCode 刷題的經驗,實際上,當你提交答案時,背後運行的邏輯就和執行測試是一樣的。它已經預先列好了預期的正確結果(expectedResult),然後用你答案中所寫的函式去實際執行,看看得到的實際結果(actualResult)和預期的正確結果是否相同,來判斷你有沒有通過測試。這些測試,都可以透過程式實際執行,而不需要人工額外核對。

測試的類型

你可能好奇,透過上面這種方法,就可以針對各種不同情境,像是網頁 UI、API 的 request-response 進行測試嗎?
答案是肯定的。根據測試的對象和範疇不同,可以分成幾種不同類型的測試。像是上面的例子中,我們針對單一 sum 這個函式所進行的測試,一般我們就稱作「單元測試(unit testing)」。另一方面,如果是需要多個函式共同運作才能達到的測試,例如測試某個前端框架中的元件(component)、或是測試一個 API 回應的資料是否正確這類的,則會稱作「整合測試(integration testing)」。還有一種甚至使用程式的方式,來模擬使用者實際操作網頁的行為,就好像真的有一個使用者在操作網頁一般,看整個網站的功能能否正常運作;這種類型的測試則稱作「端對端測試(End to End Testing)」。
但不論是上面那一種,核心的概念都還是一樣的:
  • 都是透過程式,而不是人工手動,來測試原本的程式邏輯是否正確
  • 開發者都需要先有一個預期正確的結果,再拿實際執行後的結果,和預期的結果做比對

一定要寫測試嗎

答案是「不一定」。更精確來說,答案會隨時空背景而有不同。
撰寫測試的好處,除了確保「目前」程式的穩定性外,還有一個重點:我們想要省去「未來」重複測試時,所需的人力和時間成本。寫測試絕對是個好習慣,但寫測試的當下,就需要額外的時間和人力;如果未來程式邏輯有改動,測試也勢必需要再跟著調整。
所以,如果你只是初步有個想法,想要實作看看其他人使用的反應,未來程式邏輯改動的幅度還很大;又或者只是寫個簡單的小工具,未來也不會再添加新功能,我會認為,你未必需要在一開始就補齊測試。甚至,就算假設你的專案已經稍有規模,然而你的公司目前採取的商業模式,是以開發新功能以確保新客戶簽約為主,你也未必需要把有限的資源,投入在補齊測試的撰寫上。
要特別留意的重點是,測試這件事情絕對不是全有全無;不是說你要寫測試,就一定每隻程式都要寫到。相較於完全不寫測試,或者一寫就全部的程式都要有測試,或許可以先針對你有疑慮、或是規格比較確定的程式來撰寫測試。筆者認為,相較全有全無的做法,這樣的策略比較有彈性,而且才能夠走得長久。

測試不是考試分數,寫愈多不代表程式品質就越好

有些入門的開發者,在認識了測試的概念後,會誤以為測試寫越多,程式的品質就一定越好、bug 也一定會越少。因此,她們花了非常多的時間和篇幅在寫測試。然而,千萬切記,測試本身也程式,就和開發功能時所寫出來的程式一樣,絕對不是越多越好。比起亂槍打鳥,寫了一大堆不可能發生的測試案例,更好的策略,是用更精簡、清楚的方式,來完成預期的測試項目。
有時,甚至會看到「球員兼裁判」的荒謬情況,直接把程式執行的實際結果,當成「預期的正確結果」來進行測試。這種荒謬的測試方式,欠缺對於程式正確結果的思考。舉例來說,我們知道 sum(1, 2) 要得到 3,但現在這個程式卻回傳了 0。然而,如果寫測試的開發者沒有去思考正確的答案應該要是 3,而是直接拿 0 當作預期的結果,就會寫出這樣的測試:
it('sums numbers', () => {
  const actualResult = sum(1, 2); // 0
  const expectedResult = 0; // 直接拿 sum (1, 2) 的結果當作預期的正確結果

  expect(actualResult).toEqual(expectedResult);
});
這時候測試確實會 PASS 通過,且測試的覆蓋率也有提升,但這樣的測試有意義嗎?
寫測試和軟體開發一樣,都需要思考和確保程式品質,絕對不是越多越好。在網路上,也有許多文章在說明如何寫出有品質的測試。有興趣的讀者可再根據自己所使用的程式語言,去查找相關的 best practice 或 guideline。

測試的實踐

由於撰寫測試的確需要花上額外的時間,所以在許多公司中,會有品質保證團隊(Quality Assurance Team)。QA 團隊會整理和思考各種可能的測試情境,並且根據需求,撰寫不同類型的測試(大多是端對端測試)。另外,也會再搭配手動和自動化測試,確保產品在上線後能良好運作。
除了公司中有專職的 QA 團隊,來進行軟體測試外,還有一種開發流程稱作「測試驅動開發(Test-Driven Development,TDD)」。前面我們有提到,測試的核心概念就是去比較預期結果和實際結果。一般來說,我們會先把功能開發完後,才去寫測試來檢驗開發的功能是否符合預期,但 TDD 的流程則相反。
在 TDD 中會先撰寫測試,也就是先定義好預期的正確結果是什麼。不過,由於還沒有實際開發這個功能,所以可以預期的,一開始測試執行的結果一定是失敗的。然而,因為已經把預期的結果都定義好了,所以開發者接著要做的,就是開始實作這些功能,把原先失敗的測試,最終都轉變為成功後才算是完成開發。透過 TDD,能夠讓開發者養成先思考再實作的習慣,先釐清功能的需求、規格和介面後才能開始實作。
這篇文章中,說明了撰寫測試的許多好處,但要特別留意的是:透過測試的撰寫,可以有效避免程式可能的錯誤,但並不能保證程式一定沒有問題。畢竟,使用者實際的使用環境,往往比測試時複雜許多。除了 App 本身的穩定度之外,還會受限於 App 執行的環境,例如作業系統、網路速度、硬體規格等等。甚至,使用者的操作複雜且意想不到,也都可能導致意料之外的問題產生。這也就是為什麼,有些軟體升級後,馬上就會在推出一版更新(patch)來修正問題--因為實際的環境,往往比測試複雜更多。不過,這並不是不寫測試的理由。畢竟,儘管寫測試也許不能讓你的軟體完全沒問題,但不做測試的軟體,絕對有更高的機會,連正常的流程都走不完就壞了。
透過測試的撰寫,可以有效避免程式可能的錯誤,但並不能保證程式一定沒有問題