半熟前端

軟體工程師 / 台灣人 / 在前端的路上一邊探索其他領域的可能性

軟體工程

關於寫測試的一些想法(前端)

最近這個月的專案開發,讓我對寫測試這件事有了完全不同的想法。

在前端的部分來說,過去我也很喜歡寫測試,不光是寫單元測試(Unit Test)而已,因為用 React 開發的關係,也會一併測試元件功能,像是模擬點擊、使用者互動等等。如果用 Redux 來管理副作用(side-effect)的話,也會一併撰寫 Redux 邏輯相關的測試。因為在 Redux 的世界中可以保持 pure,所以測試寫起來也相對簡單。

舉例來說好了,一個讀取文章功能,如果我們把它拆分成 3 個 Action,分別是 FETCH_ARTICLEFETCH_SUCCESSFETCH_FAILED,並且在使用者按下按鈕時觸發。那麼程式寫起來可能會像是這樣子:

const Article = ({
  fetchArticle,
  status,
  article,
}) => {
  if (status === 'idle') {
		return <button onClick={fetchArticle}>click me</button>
  } else if (status === 'loading') {
    return <Loading />
  }
  
  if (status === 'error') {
    return <Error />
  }
  return isLoading ? null : <article>{article}</article>
};

const mapStateToProps = state => ({
	article: state.article,  
  status: state.article.status,
});

const mapDispatchToProps = {
  fetchArticle,
}

export default connect(mapStateToProps, mapDispatchToProps)(Article);

測試的部分,我們可以拆成幾個來看:

describe('<Article/>', () => {
  it('should render button when isLoading', () => {
    expect(...); // button exists 
  });
  
  it('should call fetchArticle if button is clicked', () => {
    find(button).simulate('click');
    expect(...) // fetchArticle should be triggered
  })
  
  it('should render article isLoading == false', () => {
    expect(...); // button doesn't exists and article get rendered
  });
});

Redux 的部分就更簡單了:

describe('actions', () => {
  it('should return correct type', () => {
    expect(fetchArticle()).toBe({
	    type: 'FETCH_ARTICLE',
  	});
  });
	
  it('should return correct type and response', () => {
    expect(fetchSuccess(content)).toBe({
	    type: 'FETCH_ARTICLE_SUCCESS',
      payload: content,
  	});
  });
  
  it('should return correct type', () => {
    expect(fetchSuccess(err)).toBe({
	    type: 'FETCH_ARTICLE_FAILED',
      payload: err,
  	});
  });
});

describe('reducers', () => {
  it('should return correct state', () => {
    expect(reducer(fetchArticle(), initialState), {
      status: 'loading',
      article: null
    });
  });
  
  it('should return correct state', () => {
    expect(reducer(fetchArticleSuccess(content), initialState), {
      status: 'loaded',
      article: content
    });
  });
  
  it('should return correct state', () => {
    expect(reducer(fetchArticleFailed(err), initialState), {
      status: 'error',
			error: err,
    })
  });
});

我會說這樣看起來似乎是個還不錯的測試,事實上已經算是蠻理想的了,但是最近逐漸發現,就算很努力把這些測試寫完整,總是還會有沒有考慮到 case 出現,而且相當頻繁,也讓我開始思考測試這件事。

從上述的簡單場景來看,可以思考一些小問題:

  • 如果 article 回傳值不正確怎麼辦?如果這時候存取 article 的某個欄位時會不會怎麼樣?
  • 在 loading 的狀態當中,會不會發生網路不穩、超時、參數錯誤、伺服器錯誤、瀏覽器掛載等等之類非預期的錯誤,全部都歸類成同一個 error 處理是適當的嗎?
  • 按鈕是否會有重複點擊的問題,要不要使用 disabled 或其他方式預防使用者重複點擊按鈕?
  • Article 需不需要做斷行,如果要斷要怎麼斷?要讓使用者橫向滾動還是直向滾動
  • Button 及各個部分的 wording 是否正確?

這些都是在以上測試中沒有 cover 到的部分,你可能會問,那我們就逐步加上去不就好了嗎?但是加上測試的同時,我們也正在修改元件,雖然修改起來會比較安心一點,當初的我也是這樣想,但修改完之後總是又有意想不到的場景出現,然後淪陷到一個 QA 修不完,部署 delay,最後大家搞得焦頭爛額的情形。

在前端有太多需要與 UI 互動的情景,透過一般的測試很難做到這個行為。

後來我發現了一件事,單純寫 test case 並沒有辦法變魔術,讓你從本來就沒有考慮到的場景(狀態)突然冒出來。而且就算如此,在前端當中,如果你不實際模擬使用者行為,就很容易找到各種瀏覽器、每個裝置、各種在真實世界中出現的事情(像是網路不穩定)。

最近的專案當中,改變我最多的就是對於寫測試的想法。別誤會,我目前還是盡可能地在撰寫測試,但想要專注在更有意義的測試,而不是寫一些看似無關痛癢的 test case 自娛娛人。

所以寫測試對現在的我來說雖然已經是必須,但並非一開始就要埋頭寫測試。

最重要的一步還是死纏爛打地釐清各種需求。真的就是這樣,別無他法。

有太多次 QA Issue 跑出來,都只是因為我們當初沒有徹底了解需求,以及雙方對需求的理解程度有落差,才導致 QA 出現。

舉例來說,剛剛的獲取 Article,就可以從什麼時間點要打 API、API 可能的回應有哪些、針對各種 API 狀況需不需要做額外處理、UI 上的內容需不需要做特殊的處理(像剛剛提到的斷行或其他 UI 考量)、重複點擊等等,這些都確認完之後再開始寫測試也不急。

原因很簡單,使用者看到的 UI,是他們跟這個應用程式互動的媒介,但在實際上我們卻太少做這種 E2E 測試(不過現在這種概念逐漸被改正中,值得慶幸),導致測試寫得再多,還是會在 UI 上滑鐵盧。因為要做一個完整的 E2E 測試有難度,有時甚至需要 Backend 的合作(像是準備可供模擬的資料庫跟環境等等),盡可能地讓測試環境達到真實。

另外一點就是,如果要寫測試,不要花太多時間在什麼 Unit Test 上,當然該測得還是要測一下,但 expect(1+1).toBe(2) 這種明顯到不行的東西就不用太執著了。

在這方面我很推薦 Cypress 跟 Puppteer 這兩套做 integration test 的工具(Puppteer 也可以拿來做其他事就是了),功能相當完整,應有盡有。有了這些工具,麻煩不再是你逃避的藉口了。

留意在 useEffect 呼叫 API 的程式碼

雖然 useEffect 就是給你拿來執行 side effect,但是一不小心就很容易變成惡夢。如果你的 API 的回傳結果會更動到元件內部的狀態,那就要記得在 unmount 時取消 API,不然可能會造成潛在的 memory leak。

為什麼呢?如果你在 API 還沒有完成請求之前,就把 component unmount 的話,那麼 API 回傳後,正在執行 callback 時,就會噴 warning,因為事實上這個 callback 正在修改一個已經 unmount 的元件狀態。

另外就是放在 useEffect 的 API(如果就直接 fetch()),真的很難測試...,一個元件寫測試要 mock 到哭天喊地才能開始,還要小心翼翼比對是否跟 API 回應對齊。真的不要貪圖一時方便而埋下禍害。

狀態爆爆樂

另外,有很多 QA Bug 的來源,來自於狀態管理的複雜度。因為在迭代初期,狀態還沒有那麼多的時候,可能透過簡單的 useState 就能打遍天下,但是當狀態越來越多,if statement 噴得到處都是,這時候光是加入一個新的狀態,都能讓你瞬間懷疑人生,尤其是看到像這種程式碼的時候:

if (isLoading && !isEditing && profile && isNotEmpty && isLoggedIn) {
  // fuck my logic
}

如果只有一個地方或許還好,但如果 component 到處都是這種 code,那麼十之八九會噴出好幾個 QA。測試寫得太多也沒有辦法掩蓋狀態管理的複雜度,也沒辦法讓爛扣變成好叩。就算你當下的測試的確覆蓋了所有狀態,但加入新的狀態時還是有可能發生錯誤。

也因此怎麼管理狀態(優雅的那種)可以說是前端大哉問。寫 redux 雖然總是令人煩躁,但是這種手法可以幫助人們寫出更容易維護的程式碼,更重要的是把複雜的狀態透過 reducer 統一管理。

在 React 當中,我們可以透過 useReducer 來避免狀態爆炸的問題,因為 reducer 本質上其實就像一個狀態機,可以透過 reducer function 來查看目前所有的狀態變化。雖然這樣還不夠,因為狀態並不像 enum,他更像是一個 tree 或是 graph,狀態的變化是有相依性的,例如 idle 狀態不會直接跳到 success 狀態,而是一定會先跳到 loading 在跳到 success;或是 failed 狀態不會跳到 idle 狀態,而是只能回到 loading 狀態,再根據回傳結果決定要跳到哪個狀態。

一般的 reducer 做不到這件事情,他並沒有辦法偵測關於相依性的狀態,也就是說除了仰賴工程師的紀律與節操別無他法,但我們都知道工程師的節操是世界上最不能相信的東西,所以一定會出錯。

更好的方法就是用更能夠描述狀態的語言來 model 狀態,最近逐漸竄紅的 xstate 就還蠻值得一看,任何在狀態管理的場景,幾乎都有 cover 到,有興趣的話可以研究研究。