上一次我們測試的寫法,是先請 AI 幫我們大致產出來,修正測試項目,最後才做重構。發現這樣前前後後修改起來,也是蠻花時間的,而且需要花心思去額外比對,看測試有沒有涵蓋到真正需要驗證的功能。
那藉由 AI 的幫助下,測試有沒有其他更好且可靠的寫法,做到快速又可靠?
上次有摸到一點 BDD 的皮毛,那這次想要更徹底參考 BDD 的該念,從需求規格開始寫起,接著再產出測試。與其之後再重構,不如先產出幾個更好的測試。
首先,以白話文需求表述,以下是參考了 cucumber 的 feature 檔案寫法。第一個需求「畫面可以正常渲染,一開始會出現 2 個 $0 金額的文字」
畫面可以正常渲染,一開始會出現 2 個 $0 的金額
請 AI 幫我把功能需求的文字,轉為 BDD 需求,輸入如下指令:
使用 BDD 的方式,幫我把「畫面可以正常渲染,一開始會出現 2 個 $0 金額的文字」化為 cucumber 的 feature 檔案
如期幫我產出此 feature,而為了方便隨時讀取 feature,新增一個檔案 test.feature 把產出的 feature 結果存起來。
Feature: 畫面正常渲染
Scenario: 畫面顯示初始金額
Given 應用程式已啟動
When 畫面顯示
Then 我應該看到兩個 "$0" 金額的文字
緊接著轉成測試,提供以下指令:
以 @AccountingApp.tsx 為基礎,幫我依照 @test.feature 寫出 BDD 測試。每一項 given, when then 幫我抽成方法
最後,產出了如下的測試,跑了一下測試,通過了 🟢!
describe('AccountingApp', () => {
const givenApplicationIsLaunched = () => {
render(<AccountingApp />);
};
const whenScreenIsDisplayed = () => {
// 這個步驟不需要額外的操作,因為渲染已經在 given 步驟中完成
};
const thenIShouldSeeTwoZeroAmountTexts = () => {
const zeroAmountTexts = screen.getAllByText('$0');
expect(zeroAmountTexts).toHaveLength(2);
};
it('畫面顯示初始金額', () => {
givenApplicationIsLaunched();
whenScreenIsDisplayed();
thenIShouldSeeTwoZeroAmountTexts();
});
});
下一個需求:「可以正常輸入數字,我點擊按鈕 1, 2, 3,畫面會出現 $123」
因為上次已經示範過個別輸入,這次進階一點,數個指令結合成一個,化為 AI 指令鏈餵給 AI。
這裡有一個新的需求:「可以正常輸入數字,我點擊按鈕 1, 2, 3,畫面會出現 $123」。
首先,幫我轉成 BDD 案例,添加於 @test.feature 這個檔案。
接下來,從剛剛生成的 BDD 案例,參考 @BDD_AccountingApp.test.tsx 這個測試,新增一項測試。
果然 AI 沒有辜負期望,為 test.feature 添加了新的 Scenario
//..
Scenario: 可以正常輸入數字
Given 應用程式已啟動
When 我點擊按鈕 1, 2, 3
Then 畫面應該顯示 "$123"
並按照上述 feature 的場景,為我們添加了新的測試。
const whenIClickButtons = (buttons: string[]) => {
buttons.forEach(button => {
const buttonElement = screen.getByText(button);
fireEvent.click(buttonElement);
});
};
const thenScreenShouldDisplayAmount = (amount: string) => {
const displayedAmount = screen.getByText(amount);
expect(displayedAmount).toBeInTheDocument();
};
it('可以正常輸入數字', () => {
givenApplicationIsLaunched();
whenIClickButtons(['1', '2', '3']);
thenScreenShouldDisplayAmount('$123');
});
雖然大致上沒問題,但 buttonElement.click(); 這個寫法不太好,把它改成 fireEvent.click(...)
就好了。
上述測試都是小兒科,假想我們已經把上述的基本功能都完成後,最先要驗證的便為「金額帳目是不是真的成功新增?」。
於是乎,我們的需求會是這樣,將我畫面中預期要做的操作,化為具體的需求:
這裡有一個新的需求:「可以正常新增一條帳目,我點擊數字 1, 2, 3 ,接著按下 OK,並選擇 娛樂,再按下 OK,畫面會出現 娛樂,且 $123 的帳目會顯示在總額,以及該帳目上」。
接著,希望 AI 一併做到什麼事情,也附在指令中,於是乎,完整的指令便是如此
這裡有一個新的需求:「可以正常新增一條帳目,我點擊數字 1, 2, 3 ,接著按下 OK,並選擇 娛樂,再按下 OK,畫面會出現 娛樂,且 $123 的帳目會顯示在總額,以及該帳目上」。
首先,幫我轉成 BDD 案例,新增於另一個 feature 檔案。
接下來,從剛剛生成的 BDD 案例,參考 @BDD_AccountingApp.test.tsx 這個測試,並依照 AccoutingApp 這個檔案為主程式,新增一項測試。
這次稍微等了久一些,因為需要生成的文字比較多,幫我生成了如下的 feature
Feature: 新增支出
Scenario: 新增一條娛樂支出
Given 應用程序已啟動
When 我點擊數字 "1", "2", "3"
And 我點擊 "OK" 按鈕
And 我選擇 "娛樂" 類別
And 我再次點擊 "OK" 按鈕
Then 我應該看到一條新的 "娛樂" 支出記錄
And 該記錄的金額應該是 "$123"
And 總金額應該增加 "$123"
此外,測試也一併生成了
// ... existing code ...
describe('AccountingApp', () => {
// ... existing code ...
const thenIShouldSeeNewExpenseRecord = (category: string, amount: string) => {
const categoryElement = screen.getByText(category);
expect(categoryElement).toBeInTheDocument();
const amountElement = screen.getByText(amount);
expect(amountElement).toBeInTheDocument();
};
const whenISelectCategory = (category: string) => {
const categoryButton = screen.getByText(category);
categoryButton.click();
};
it('可以正常新增一條帳目', () => {
givenApplicationIsLaunched();
whenIClickButtons(['1', '2', '3']);
whenIClickButtons(['OK']);
whenISelectCategory('娛樂');
whenIClickButtons(['OK']);
thenIShouldSeeNewExpenseRecord('娛樂', '$123');
thenScreenShouldDisplayAmount('$123');
});
});
跑了測試,嗯…這次就沒有這麼順利通過了 🔴。
從測試錯誤的訊息 Found multiple elements with the text: $123
來看,又是之前遇到的 getByText()
問題,這個問題一方面也是因為主程式的寫法不夠好,但先擱置著,晚點在重構的章節再來處理。
總之,這個老問題的解法就不多提了,一樣是因為 $123 會被找到多次,所以不能用 getByText()
來找該金額的字做驗證。
最後,輸入以下指令,請 AI 直接改一下驗證規則
幫我調整,thenScreenShouldDisplayAmount 應該要「找到 2 個」;且 thenIShouldSeeNewExpenseRecord 裡面的 getByText 改為 getAllByText,驗證規則改為「至少 1 個」
AI 幫我調整 thenScreenShouldDisplayAmount 這個方法,改成驗證 2 個。
const thenScreenShouldDisplayAmount = (amount: string) => {
const displayedAmounts = screen.getAllByText(amount);
expect(displayedAmounts).toHaveLength(2);
};
再跑一下測試,如期通過了最新的這個測試,但因為 thenScreenShouldDisplayAmount
有被前一個「可以正常輸入數字」的測試給使用到,那我們要不把這個測試修改,多一個新的專門驗證「有 2 個金額存在」,要不就是這個不動,再多一個「只有 1 個金額存在」的 function。
稍微思考了一下,應該「回上一步」,將這個 thenScreenShouldDisplayAmount
維持不變,而是多一個專門驗證 2 個金額存在的 then…
function,這樣會更好理解一點。
const thenScreenShouldDisplayAmountTwice = (amount: string) => {
const displayedAmounts = screen.getAllByText(amount);
expect(displayedAmounts).toHaveLength(2);
};
寫到這邊,終於全都通過了,可喜可賀。
用不著的測試,就砍掉吧!
回顧一下寫好的 Specification,初始值為 0 似乎沒什麼測試的價值,我覺得可以把這一則測試刪掉。
直覺是直接砍測試,再把 feature 裡的 Specification 砍掉,但 feature 規格書應該是最優先的,應該以這個 feature 為文件範本,而程式去對應(cucumber 即是幫我們自動對應起來)。
所以,我就手動把 「畫面顯示初始金額」這個場景給刪除,接著再請 AI 幫我自動「同步」測試。
試了很多指令,希望 AI 可以參考所有的 .feature 檔案來砍掉「不必要」的測試,像是如下這樣,先參考檔案,接著再比對做移除。
參考 @test.feature 和 @create_accounting.feature 其中的 Scenario,將沒有出現在上述兩個檔案 Scenario ,在此檔案 @BDD_AccountingApp.test.tsx 的測試項目給移除
結果也沒有砍到該砍的測試,反而把最新的那一個移除掉了,為什麼 🤔…
// 移除不必要的測試項目
// it('新增一條娛樂支出', async () => {
// givenApplicationIsLaunched();
//...
最後,試了各種指令,發現還是要「多步驟拆解」才比較行得通。先是下指令表列所有 Scenario
表列 @AccountingAppForDemo 底下所有的 *.feature 檔案的 Scenario
接著是將以上結果,與測試檔案做比對,將沒出現在場景(Scenario)中的測試項目給刪除。
以上 Scenario 為基準,與 @BDD_AccountingApp.test.tsx 做比對,移除沒有出現於 Scenario 的測試項目
千辛萬苦,總算…成功把「一則」不必要的測試項目給砍了 😂
花這麼多功夫,只為刪除掉一則測試,也…太累了吧! 以上方法適用於,哪天你照著以上方法寫到一半,忘記目前測試項目到底是不是「最新」的,才適合這樣做,不然更新好 .feature 文件之後,直接「順手」改測試,這樣是最快的。
當然最終是希望可以用 cucumber 來輔助自動執行測試,feautre 文件即測試,這樣自然是最好。
驗證項目從頭開始寫,比想像中還累,但有了 BDD 「框架」的幫助,至少寫起來有個方向,而且轉化為測試條件還頗為準確的。
而且從這次的示範,發現到了意想不到的「AI 限制」,原本以為還蠻簡單的事,只是幫我比對一下不就行了? 結果不小心又踩到指令過於抽象的坑,這簡直就是:我「自以為」講得很清楚了,怪別人怎麼都聽不懂,道理是一樣的 😂。
這邊只是做示範,沒有打算採用 cucumber 測試,而且也不是這次想要深入探討的主題。(更何況我也不熟)。
今天想要示範的有點像是一個行之有年的專案,可能已經開發許久,你很難把測試都改成 cucumber 來寫,而且不見得想導入 cucumber 來用,只好用其「概念」來試著將 Specification By Example 導入於專案中。
此外,理想情況下是「先寫 feature 檔案,再來寫程式碼」,在 cucumber 官方文件中的 anti-pattern 也有提到這項,feature 檔案不應該是「功能的備忘」而已,這份文件應該是需求的一部分,優先於程式碼實作。
此篇文章僅為參考其概念做一些粗淺的嘗試。若有說錯的地方,或是誤用之處,還請不吝指教。