AI 代工替我們寫的測試程式碼,已經涵蓋了目前完成的使用者故事的測試項目。分別是這兩項:
除了我自己擔任 QA(品保員) 實際操作覺得 OK,驗證程式的功能無誤,測試是另一個「更為可靠」的 QA 。不只驗收的速度快,幾毫秒的眨眼之間即完成,而且可以保證每次驗收的情境與流程都不變。我們可能測一測還會忘記其中幾步,或是搞錯什麼步驟,而自動化測試幾乎不會有這個問題
且剛好今天的記帳 App 是先打造前端的操作流程,才有 UI 可以讓使用者操作,若今天打造的是後端等沒有可操作畫面的服務,通常測試就會是程式功能的「第一線用戶」,得先讓程式至少通過理想情境(Happy Path)的測試,才有信心說程式功能已經完成了。
測試相當重要,是我們產品最忠實,不離不棄的第一個用戶。
驗收項目的測試是有了,但似乎不夠好懂,但你可能會疑問,照剛剛所說,測試只不過是「程式的用戶」,應該不算是終端產品的一部份吧?又不會拿給客戶用,有必要做重構嗎?
但其實還是要,測試是用來服務我們「開發者」用的,是功能的一部分,當然也就是產品的一部分。日後功能做了修改,測試因而壞掉之後,還是要回頭去改「測試的程式碼」,改程式當然也要看程式,所以囉,當然測試程式要好懂一點才好呀。
因此,測試也要重構,跟著產品的迭代同進同出。
先前是快速請 AI 幫我產測試,但回頭來看,有沒有「更科學可靠」的方式,從需求層面化身為驗收條件,接著變成測試項目呢?如此一來,測試應該就能相對好懂一點,知道每個測試相對應的需求是什麼。
自然是有辦法,這邊先簡單參考 BDD 的 Specifications by Example 作為驗收案例的一個方法。
而什麼是 Specifications by Example ?直翻的話是「規格範例」,嗯…聽君一席話,如聽一席話,有聽沒有懂 🤔…
將使用者故事(需求範例)化身為驗收條件(規格),Specifications by Example 做的就是把業務需求,直接化為規格。如此的好處是,需求是什麼樣的,測試就會是什麼樣,那如果測試「通過」,代表需求也是通過的了。
而業務需求的寫法,如同使用者故事那樣,有一套「模板」可以參考如何撰寫,以下是其中 3 個常有的必要條件描述:
那我們可以拿原本的測試,試著練習看看。就拿最經典的「新增一筆記帳金額」這個測試來 try try
it('adds new item to history when category is selected and confirms', () => {
render(<App />);
fireEvent.click(screen.getByText('1'));
fireEvent.click(screen.getByText('0'));
fireEvent.click(screen.getByText('OK'));
fireEvent.click(screen.getByText('飲食'));
fireEvent.click(screen.getByText('OK'));
expect(screen.getByText('飲食')).toBeInTheDocument();
expect(screen.getAllByText('$10')).toHaveLength(2);
});
原本程式碼這樣看起來有點難讀…,先直覺地按照邏輯分段一下,並暫時加個註解,以方便大家理解。
好了,調整過後長這樣:
it('adds new item to history when category is selected and confirms', () => {
// 渲染
render(<App />);
// 輸入金額並確認
fireEvent.click(screen.getByText('1'));
fireEvent.click(screen.getByText('0'));
fireEvent.click(screen.getByText('OK'));
// 選擇種類並確認
fireEvent.click(screen.getByText('飲食'));
fireEvent.click(screen.getByText('OK'));
// 確認種類和金額正確
expect(screen.getByText('飲食')).toBeInTheDocument();
expect(screen.getAllByText('$10')).toHaveLength(2);
});
這樣看起來清楚多了,每段程式碼做的事,好理解多了 👍。而且好像可以…套用上面提到的 given, when 和 then 來把各段抽成方法囉!? 💡
首先是 given ,為「前置條件」,通常是指準備資料,像是需要把後端拿回來的資料做 mocking,但今天沒有這樣的情況,因此不需要。
而 when ,則是指「某個行為被觸發」了。像是「渲染」組件 renderApp(<App />)
,就是「當渲染了 App 畫面」。所以可以先把部分抽成 whenRender() 的方法,一次先做一步驟。
function whenRender() {
render(<App />);
}
it('adds new item to history when category is selected and confirms', () => {
whenRender()
//...
重構之後,雖然只有改一個小東西,記得再跑一下測試,看看有沒有把測試改壞(這是一個習慣,或是你也可以用 watch 模式)。
看起來沒問題,看看還有沒有 when 的條件,往下看 fireEvent.click(…) ,翻成白話文就是「 when 點擊了包含 … 的元素」,觸發了這個元素的點擊,那確實是 when 沒錯。
於是乎,將 fireEvent 那幾組抽成方法,大概是如下這樣
function whenInputNumber(number: number) {
number.toString().split('').forEach(digit => {
fireEvent.click(screen.getByText(digit));
})
}
function whenClickOK() {
fireEvent.click(screen.getByText('OK'));
}
function whenSelectCategory(category: string) {
fireEvent.click(screen.getByText(category));
}
it('adds new item to history when category is selected and confirms', () => {
whenRender()
whenInputNumber(10);
whenClickOK();
whenSelectCategory('飲食');
whenClickOK();
expect(screen.getByText('飲食')).toBeInTheDocument();
expect(screen.getAllByText('$10')).toHaveLength(2);
});
最後,剩下 then 的條件,也就是「預期發生什麼事」,大家到這應該看得很明白了,沒錯,就是 expect(某些套件是用 assert…)這個地方。於是乎,也是行以「抽方法」。
我是用 Cursor Tab 提示 AI 我要抽方法。先輸入 thenCategoryShouldHave 在 whenClickOK() 的下方,AI 就知道我是要把該區塊抽成方法,等 Cursor IDE 的 auto-complete 提示跳出來,接續按下 Tab 就完成修改囉。
當然你也可以走「正規的」方法,請 IDE 幫我們 extract method,這樣也蠻快的。
//...
function thenCategoryShouldHave(category: string) {
expect(screen.getByText(category)).toBeInTheDocument();
}
function thenAmountShouldBe(amount: string) {
expect(screen.getAllByText(amount)).toHaveLength(2);
}
it('adds new item to history when category is selected and confirms', () => {
whenRender()
whenInputNumber(10);
whenClickOK();
whenSelectCategory('飲食');
whenClickOK();
thenCategoryShouldHave('飲食');
thenAmountShouldBe('$10');
});
如今總算把其中一個測試重構,改得更像人話一點。一次先調整一個測試,這樣壞了就知道是哪裡沒調整好。因此,今天就先舉例到這打住,如果忍不住想試試的小夥伴,可以先自己練習看看 🙂
接下來會延續套用這個方法,把其他測試寫得更清楚好懂喔!
不是說寫完「保護程式邏輯」就好了,測試一大用處也是作為程式文件留存,等之後回頭看這部分的程式時,就先看測試,請 AI 幫我「說故事」,會比直接看程式邏輯要來得直觀許多。
最後,順帶一提,用了 BDD 的一些方法,讓測試更好懂。BDD 不一定要搭配 TDD,而我們也確實沒有這麼做。