iT邦幫忙

2024 iThome 鐵人賽

DAY 21
0
Modern Web

與 AI 一起開發 Side Project 吧!系列 第 21

Day21 — 穩如泰山 | 測試換個寫法試試看? AI 協助下的新寫法嘗試

  • 分享至 

  • xImage
  •  

前言

上一次我們測試的寫法,是先請 AI 幫我們大致產出來,修正測試項目,最後才做重構。發現這樣前前後後修改起來,也是蠻花時間的,而且需要花心思去額外比對,看測試有沒有涵蓋到真正需要驗證的功能。

那藉由 AI 的幫助下,測試有沒有其他更好且可靠的寫法,做到快速又可靠?

測試也要優化

試著從頭以 BDD 開發

上次有摸到一點 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();
  });
});

如法炮製,再多一個試試看 — AI 指令鏈

下一個需求:「可以正常輸入數字,我點擊按鈕 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);
};

寫到這邊,終於全都通過了,可喜可賀。

https://ithelp.ithome.com.tw/upload/images/20241001/201683080VIpMWA8O8.png

跟著產品一起迭代

用不著的測試,就砍掉吧!

回顧一下寫好的 Specification,初始值為 0 似乎沒什麼測試的價值,我覺得可以把這一則測試刪掉。

直覺是直接砍測試,再把 feature 裡的 Specification 砍掉,但 feature 規格書應該是最優先的,應該以這個 feature 為文件範本,而程式去對應(cucumber 即是幫我們自動對應起來)。

所以,我就手動把 「畫面顯示初始金額」這個場景給刪除,接著再請 AI 幫我自動「同步」測試。

https://ithelp.ithome.com.tw/upload/images/20241001/20168308yB6YRw6Agy.png

砍掉測試,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 檔案不應該是「功能的備忘」而已,這份文件應該是需求的一部分,優先於程式碼實作。

此篇文章僅為參考其概念做一些粗淺的嘗試。若有說錯的地方,或是誤用之處,還請不吝指教。

REF


上一篇
Day20 — 穩如泰山 | 急速產單元測試 Part2,優化指令產出更好的測試!
下一篇
Day22 — 青出於藍 | 稍微暫停,整理重構一下程式碼吧
系列文
與 AI 一起開發 Side Project 吧!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言