iT邦幫忙

2021 iThome 鐵人賽

DAY 5
0
Modern Web

Angular 深入淺出三十天:表單與測試系列 第 5

Angular 深入淺出三十天:表單與測試 Day05 - 如何寫出優秀的測試?

Day5

昨天介紹了開始撰寫測試之前必須要知道的二三事之後,想必大家已經對如何開始撰寫測試有了一些概念,但測試不是「有拜有保佑」,有寫就好。所以我們除了要知道如何開始撰寫測試之外,也要知道如何寫出優秀的測試

什麼是優秀的測試?

我認為要優秀的測試會具備以下三個特質:

  • 值得信賴
  • 易於維護
  • 可讀性高

值得信賴

雖說我們寫測試的目的是為了證明我們的程式碼沒有問題,但不代表我們的測試程式碼值得信賴。

換句話說,如果我們寫出的測試有問題,怎麼證明我們的程式碼沒問題?因此,如何撰寫出令人值得信賴的程式碼就是一個很重要的課題。

易於維護

測試跟我們的程式碼同樣需要維護,而通常這會是很多人之所以「沒辦法」寫測試的原因,每當需求有變動且時間緊迫、資源短缺的情況下,測試就會被拋棄。

但如果我們能夠撰寫出易於維護的測試,就算時間緊迫、資源短缺,也能夠持續讓測試保護我們的程式碼。

可讀性高

優秀的測試程式碼,是可以當成說明書來看的。透過閱讀測試程式碼,我們可以很快地了解被測試的程式具備了哪些功能、要怎麼使用。而且如果測試有問題,我們也能夠可以用最短的時間發現問題的根源。

甚至可以這麼說:一旦測試程式失去了可讀性,也不用想它能夠多易於維護與多值得信賴了。

因此,要如何讓我們的測試具備上述三個特質呢?

撰寫值得信賴的測試

我認為要撰寫出值得信賴的測試要從以下幾個方向著手:

  • 避免在測試中裡寫邏輯
  • 每次只測試一個關注點
  • Code review

避免在測試中裡寫邏輯

我們寫測試是用來驗證我們程式中的邏輯是否正確,一旦我們在寫測試的時候也有邏輯,那是不是還要寫其他的程式來驗證我們的測試?在測試裡,我們不關心過程,只要結果,所以我們不需要在測試裡面寫邏輯,任何的 switchif-elsefor/while looptry-catch 甚至是四則運算都不應該出現在測試裡,直接把結果寫上去即可。

每次只測試一個關注點

很多時候在我們的程式裡同時做很多事情,這些事情就是我們要測試、驗證的關注點

以我們前面撰寫過的程式碼來舉例:

accountValueChange(accountControl: FormControl): void {
  this.account = accountControl.value;
  this.validationCheck(accountControl.errors, 'account');
}

這個函式做了兩件事情:

  1. accountControl 的值指定給 account
  2. accountControlerrors 來判斷要將什麼樣子的錯誤訊息指定給 accountErrorMessage

程式碼請參考第二天的文章:Template Driven Forms 實作 - 以登入為例

如果我們將這兩件事情的驗證都寫在同一個測試案例裡,當測試執行時,一旦第一件事情有錯,就不會再驗證第二件事情。

如此一來,我們怎麼知道第二件事情到底是對還是錯?

所以當我們在測試這個函式時,就至少要用兩個測試案例來驗證上述做的兩件事情,以保證我們的測試案例有確實測試到每一件事情。

Code review

有的時候我們自己一個人悶著頭寫,很容易沉浸在自己的世界、無法發現自己的錯誤,這時候我們就需要別人來幫忙我們用更客觀一點的角度來發現我們的不足。

其實幫你 Code review 的人不用一定是比你厲害的人,古語有云:「三人必有我師焉」,每個人都是獨特的,很多時候你沒發現的錯誤、你沒想到的問題、你沒有過的想法,都可以在這時候互相交流,就算幫你 Code review 的人比你差,這也是一個教他的好時機。

撰寫易於維護的測試

要撰寫出易於維護的測試也一樣可以從以下幾個方向著手:

  • 只測試公開的方法
  • 測試也需要重構
  • 測試隔離
  • 比較物件

只測試公開的方法

一般來說,我們會將方法宣告為 private 或是 protected 時,一定是基於很多設計上或安全上的考量,所以我們也只會測試公開的方法。而且宣告為 private 或是 protected 的方法一定不會單獨存在,它們一定會被某個公開方法呼叫(如果沒有就表示這個方法根本就沒人在使用,可以刪掉了),所以當我們測試公開方法時,一定會測到那個被呼叫到的 private 或是 protected 的方法。

這時一定會有人問說:「那我真的很想要測試那個宣告為 private 或是 protected 的方法的話要怎麼辦?」。

如果真的很想要測試那個宣告為 private 或是 protected 的方法,我們可以:

  1. 直接將該方法改為公開方法
  2. 將該方法抽到新的類別裡
  3. 把方法改成靜態方法

我個人比較偏好第二種跟第三種,因為這樣可以讓抽出來的這些方法可以被共用,在後續維護上也比較彈性。

測試也需要重構

正如本文一開始所說的,程式碼需要維護,測試也需要維護;同樣地,程式碼需要重構,測試也需要。

不過測試的重構跟一般程式碼重構的重點稍稍有點不一樣,雖然大體上一樣是要減少重複的程式碼,但前面小節有提到「不要在測試裡寫邏輯」,以及後續會提到「動作與驗證要分開」以提升可讀性,所以在重構時要特別注意。

測試隔離

想想看,你的測試有沒有以下的情況:

  1. 需要以某種順序執行
  2. 會在測試案例裡呼叫其他的測試案例
  3. 沒有重設共用的屬性、資料或者是狀態

如果你的測試有以上任何一種情況,都表示你沒有做好測試隔離

測試隔離這名字聽起來很專業,其實講白話一點就是讓每個測試案例都是獨立的,不跟其他的測試案例有依賴、或是順序上的關係。每一個測試案例都要能單獨運作,每一個測試案例都要從初始化開始,一直到驗證完、清除或是還原狀態為止,如此才不會影響到其他的測試案例。

撰寫可讀性高的測試

那到底要怎麼樣撰寫可讀性高的測試呢?其實大致上就跟我們開發的時候所要求的差不多,畢竟開發者寫的程式碼並不是給電腦看的,而是給人看的。

所以除了 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 分別指的是:

  • Arrange - 準備物件或者是進行必要的前置作業。
  • Act - 實際執行、操作物件。
  • Assert - 進行結果驗證

以上面的程式碼為例, 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)

這個方式有一個流程,如下圖所示:

心法

  1. 一開始要先寫測試不實作程式碼,這時測試會是紅燈的狀態
  2. 只實作足以讓測試通過的程式碼,這時測試就會通過變成綠燈
  3. 當反覆這樣子做了幾次之後,實作的程式碼變多了可能會需要重構
  4. 重構完之後,如果測試變成了紅燈,我們就再調整實作使其變成綠燈
  5. 重複循環這個過程

這樣子的作法有滿多好處的,像是:

  • 測試跟開發同步進行,有多少測試就寫多少程式碼
  • 由於測試先行,所以寫出來的程式碼都很好被測試
  • 由於有測試保護,在不斷重構的過程中並不會出現改 A 壞 B 的情況
  • 由於會不斷地重構,所以寫出來的程式碼會很好維護

雖然聽起來很簡單、好處很多,但在這流程中還是要注意以下三點:

  • 絕不跳過重構
  • 儘快變綠
  • 出錯後放慢腳步

此外,我建議大家在寫按照這個方式開發時,注意以下幾件事情:

  • 編寫測試時就僅僅關注測試,不想去如何實現
  • 先以調用方的角度來調用這塊代碼,並且從調用方的角度說出所期望的結果
  • 在編寫某個功能的代碼之前先編寫測試代碼,然後只編寫使測試通過的功能代碼
  • 所有的實現都是測試「逼」出來的,所有的實現代碼都是為了讓測試通過而編寫的

本日小結

今天的重點主要是分享何謂優秀的測試如何撰寫出優秀的測試這兩點上,後面所分享測試驅動開發是提供一種更好寫測試的開發方法給大家參考。

雖然我已經將如何寫測試、如何寫出好的測試都分享給大家了,但羅馬不是一天造成的,沒有人一開始就能寫得出很好的測試。唯有不斷地練習與學習,才能越寫越輕鬆、越寫越快樂。

總之,坐而言不如起而行,撰寫測試對於專業的軟體工程師來說絕對是一件利大於弊的事情,因此,從今天就開始寫測試吧!

此外,非常推薦大家閱讀書籍:「單元測試的藝術」,裡面對於「什麼是優秀的測試」與「如何撰寫優秀的測試」的部份會講得更加詳細與完整。

對於我今天所分享的部份,如果我有講錯或是大家有任何想要補充的部分,都非常歡迎留言在下面或訊息我讓我知道噢!


上一篇
Angular 深入淺出三十天:表單與測試 Day04 - 開始撰寫測試之前必須要知道的二三事
下一篇
Angular 深入淺出三十天:表單與測試 Day06 - 單元測試實作 - 登入系統 by Template Driven Forms
系列文
Angular 深入淺出三十天:表單與測試30

1 則留言

0
TD
iT邦新手 4 級 ‧ 2021-09-20 22:42:03

將動作跟驗證分開

學到了新東西!

--
我開發的時間大概有 50% 在寫測試,一開始覺得很麻煩,但是後來發現測試寫得好,原本的程式就可以盡可能的重構而不怕最終結果出錯。

Leo iT邦新手 3 級 ‧ 2021-09-20 22:43:35 檢舉

Hi TD

有 50% 在寫測試?!很優秀耶!!

是的沒錯,寫測試的好處太多太多了!

我要留言

立即登入留言