昨天介紹了開始撰寫測試之前必須要知道的二三事之後,想必大家已經對如何開始撰寫測試有了一些概念,但測試不是「有拜有保佑」,有寫就好。所以我們除了要知道如何開始撰寫測試之外,也要知道如何寫出優秀的測試。
我認為要優秀的測試會具備以下三個特質:
雖說我們寫測試的目的是為了證明我們的程式碼沒有問題,但不代表我們的測試程式碼值得信賴。
換句話說,如果我們寫出的測試有問題,怎麼證明我們的程式碼沒問題?因此,如何撰寫出令人值得信賴的程式碼就是一個很重要的課題。
測試跟我們的程式碼同樣需要維護,而通常這會是很多人之所以「沒辦法」寫測試的原因,每當需求有變動且時間緊迫、資源短缺的情況下,測試就會被拋棄。
但如果我們能夠撰寫出易於維護的測試,就算時間緊迫、資源短缺,也能夠持續讓測試保護我們的程式碼。
優秀的測試程式碼,是可以當成說明書來看的。透過閱讀測試程式碼,我們可以很快地了解被測試的程式具備了哪些功能、要怎麼使用。而且如果測試有問題,我們也能夠可以用最短的時間發現問題的根源。
甚至可以這麼說:一旦測試程式失去了可讀性,也不用想它能夠多易於維護與多值得信賴了。
因此,要如何讓我們的測試具備上述三個特質呢?
我認為要撰寫出值得信賴的測試要從以下幾個方向著手:
我們寫測試是用來驗證我們程式中的邏輯是否正確,一旦我們在寫測試的時候也有邏輯,那是不是還要寫其他的程式來驗證我們的測試?在測試裡,我們不關心過程,只要結果,所以我們不需要在測試裡面寫邏輯,任何的 switch
、 if-else
、 for/while loop
、 try-catch
甚至是四則運算都不應該出現在測試裡,直接把結果寫上去即可。
很多時候在我們的程式裡同時做很多事情,這些事情就是我們要測試、驗證的關注點。
以我們前面撰寫過的程式碼來舉例:
accountValueChange(accountControl: FormControl): void {
this.account = accountControl.value;
this.validationCheck(accountControl.errors, 'account');
}
這個函式做了兩件事情:
accountControl
的值指定給 account
。accountControl
的 errors
來判斷要將什麼樣子的錯誤訊息指定給 accountErrorMessage
。程式碼請參考第二天的文章:Template Driven Forms 實作 - 以登入為例
如果我們將這兩件事情的驗證都寫在同一個測試案例裡,當測試執行時,一旦第一件事情有錯,就不會再驗證第二件事情。
如此一來,我們怎麼知道第二件事情到底是對還是錯?
所以當我們在測試這個函式時,就至少要用兩個測試案例來驗證上述做的兩件事情,以保證我們的測試案例有確實測試到每一件事情。
有的時候我們自己一個人悶著頭寫,很容易沉浸在自己的世界、無法發現自己的錯誤,這時候我們就需要別人來幫忙我們用更客觀一點的角度來發現我們的不足。
其實幫你 Code review 的人不用一定是比你厲害的人,古語有云:「三人必有我師焉」,每個人都是獨特的,很多時候你沒發現的錯誤、你沒想到的問題、你沒有過的想法,都可以在這時候互相交流,就算幫你 Code review 的人比你差,這也是一個教他的好時機。
要撰寫出易於維護的測試也一樣可以從以下幾個方向著手:
一般來說,我們會將方法宣告為 private
或是 protected
時,一定是基於很多設計上或安全上的考量,所以我們也只會測試公開的方法。而且宣告為 private
或是 protected
的方法一定不會單獨存在,它們一定會被某個公開方法呼叫(如果沒有就表示這個方法根本就沒人在使用,可以刪掉了),所以當我們測試公開方法時,一定會測到那個被呼叫到的 private
或是 protected
的方法。
這時一定會有人問說:「那我真的很想要測試那個宣告為 private
或是 protected
的方法的話要怎麼辦?」。
如果真的很想要測試那個宣告為 private
或是 protected
的方法,我們可以:
我個人比較偏好第二種跟第三種,因為這樣可以讓抽出來的這些方法可以被共用,在後續維護上也比較彈性。
正如本文一開始所說的,程式碼需要維護,測試也需要維護;同樣地,程式碼需要重構,測試也需要。
不過測試的重構跟一般程式碼重構的重點稍稍有點不一樣,雖然大體上一樣是要減少重複的程式碼,但前面小節有提到「不要在測試裡寫邏輯」,以及後續會提到「動作與驗證要分開」以提升可讀性,所以在重構時要特別注意。
想想看,你的測試有沒有以下的情況:
如果你的測試有以上任何一種情況,都表示你沒有做好測試隔離。
測試隔離這名字聽起來很專業,其實講白話一點就是讓每個測試案例都是獨立的,不跟其他的測試案例有依賴、或是順序上的關係。每一個測試案例都要能單獨運作,每一個測試案例都要從初始化開始,一直到驗證完、清除或是還原狀態為止,如此才不會影響到其他的測試案例。
那到底要怎麼樣撰寫可讀性高的測試呢?其實大致上就跟我們開發的時候所要求的差不多,畢竟開發者寫的程式碼並不是給電腦看的,而是給人看的。
所以除了 Clean Code 一書裡提到的部分之外,對測試來說還需要注意以下兩點:
好的測試案例與測試集合的命名,可以讓我們在讀測試程式碼或是測試結果時達到事半功倍的效果。舉例來說,如果我們要測試登入系統的帳號欄位,一個不好的測試案例與測試集合的命名可能會是這樣子的:
describe('LoginComponent', () => {
it('Test account input - positive', () => {
// ...
});
it('Test account input - negative', () => {
// ...
});
});
雖然可以知道這兩個測試是一個是驗證正向的情境,另一個是驗證負向的情境,但實際上還要去細看測試案例裡面程式碼在寫什麼才會知道當下這個測試案例驗證的是什麼樣的情境,可讀性較差。
而好的測試案例與測試集合的命名可能會是這樣子的:
describe('LoginComponent', () => {
describe('accountValueChange', () => {
it('should set value into property "account"', () => {
// ...
});
it('should assign the error message "此欄位必填" to property "accountErrorMessage" when the value is the empty string', () => {
// ...
});
it('should assign the error message "格式有誤,請重新輸入" to property "accountErrorMessage" when the value is not the correct pattern', () => {
// ...
});
it('should assign the empty string to property "accountErrorMessage" when the value is the correct pattern', () => {
// ...
});
});
});
有沒有覺得這樣比較好讀呢?
語言當然不一定要用英文啦,用中文也行,看團隊、主管或者是公司的規範。
為了可讀性,讓別人可以很好閱讀且很快速地理解我們所寫的內容,所以我們不會為了節省程式碼的空間,而把程式碼都擠在一起,導致看的人還要去動腦思考,降低效率。
例如我們要驗證登入系統的帳號欄位在值改變時,有沒有將 input 欄位的值指派給 Component 的屬性 account
,所以我們有程式碼可能會這樣子寫:
it('should assign the value to property "account"', () => {
const accountControl = new FormControl('abc123@mail.com');
component.accountValueChange(accountControl);
expect(component.account).toBe(accountControl.value);
});
乍看之下其實沒什麼太大的問題,也不是很難的程式碼,但如果這樣寫會更好一點:
it('should assign the value to property "account"', () => {
const account = 'abc123@mail.com';
const accountControl = new FormControl(account);
component.accountValueChange(accountControl);
expect(component.account).toBe(account);
});
又或者是這樣:
it('should assign the value to property "account"', () => {
const accountControl = new FormControl('abc123@mail.com');
component.accountValueChange(accountControl);
const account = accountControl.value;
expect(component.account).toBe(account);
});
簡單來說就是一步一步來,將動作跟驗證分開,減少一些閱讀時的負擔,會讓整個程式碼更好閱讀。
此外,在撰寫測試時,有個 3A 原則的方式非常推薦大家使用。
這是在測試的世界裡,非常著名的方法。可以說是只要照著這個方法寫,滿簡單就能寫出不錯的測試。
而這個 3A 分別指的是:
以上面的程式碼為例, 3A 是這樣分的:
it('should assign the value to property "account"', () => {
// Arrange
const account = 'abc123@mail.com';
const accountControl = new FormControl(account);
// Act
component.accountValueChange(accountControl);
// Assert
expect(component.account).toBe(account);
});
這樣看起來是不是更好讀了呢?
雖然已經說了那麼多,但當程式已經實作好之後再來補測試其實是還滿辛苦的,因此有一種開發方式叫做測試驅動開發。
測試驅動開發,也就是所謂的 TDD (Test-driven development) 。
這個方式有一個流程,如下圖所示:
這樣子的作法有滿多好處的,像是:
雖然聽起來很簡單、好處很多,但在這流程中還是要注意以下三點:
此外,我建議大家在寫按照這個方式開發時,注意以下幾件事情:
今天的重點主要是分享何謂優秀的測試與如何撰寫出優秀的測試這兩點上,後面所分享測試驅動開發是提供一種更好寫測試的開發方法給大家參考。
雖然我已經將如何寫測試、如何寫出好的測試都分享給大家了,但羅馬不是一天造成的,沒有人一開始就能寫得出很好的測試。唯有不斷地練習與學習,才能越寫越輕鬆、越寫越快樂。
總之,坐而言不如起而行,撰寫測試對於專業的軟體工程師來說絕對是一件利大於弊的事情,因此,從今天就開始寫測試吧!
此外,非常推薦大家閱讀書籍:「單元測試的藝術」,裡面對於「什麼是優秀的測試」與「如何撰寫優秀的測試」的部份會講得更加詳細與完整。
對於我今天所分享的部份,如果我有講錯或是大家有任何想要補充的部分,都非常歡迎留言在下面或訊息我讓我知道噢!
將動作跟驗證分開
學到了新東西!
--
我開發的時間大概有 50% 在寫測試,一開始覺得很麻煩,但是後來發現測試寫得好,原本的程式就可以盡可能的重構而不怕最終結果出錯。