iT邦幫忙

2024 iThome 鐵人賽

DAY 26
0
Modern Web

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

Day26 — 方興未艾 | 最後一個功能,PDCA 循環跑起來 (Part1)

  • 分享至 

  • xImage
  •  

前言

記帳,那當然要記起來呀

到目前為止,雖然是可以記帳,可以正常新增一筆完整的帳目,還可以編輯、刪除。但是不是忘記了什麼東西?

啊對,就是記帳最重要的「記」這一回事,每次網頁刷新之後,之前的帳目就消失了。那這樣當然不行,都沒有好好記住,怎麼可以叫做記帳 😂

所以,要想辦法讓新增的帳目,可以記錄於「某個地方」,以便之後回頭查看,而這不必限定於使用

下個功能:可以存起來

PDCA 循環

這邊為大家複習一下整個開發流程,可以用 PDCA 循環,涵蓋整個開發的週期:

  1. Plan:先釐清需求,把使用者故事寫好
  2. Do :開發功能
    1. 按照需求寫功能
    2. 接著寫測試保護寫好的邏輯
  3. Check:識別異味,檢核是否有改善空間
  4. Action:修正改善,重構程式碼

一個完整功能可能有數個 PDCA 循環,因為單一項功能可能分成數小個更小的功能單位,每個更小的功能也會經過如此循環。接下來就以「記帳的帳目要記錄起來」為範例,一次介紹完整的 PDCA 循環吧。

Plan 先寫需求

首先,是把這個記帳 App 要能夠紀錄,於是寫了如下的使用者故事:

作為一名使用者
我希望可以「紀錄」每一筆帳目
以便下次回來回頭查找帳目

一樣請 AI 幫我列成 BDD feature 格式,轉出來的結果為:

Feature: 紀錄帳目

  Scenario: 瀏覽已紀錄的帳目
    Given 應用程序已啟動
    When 我查看我的帳目紀錄
    Then 我應該看到所有已紀錄的帳目
    And 每一筆帳目應該包含金額和類別

這樣一看,只有包含 CRUD 的 R 而已,也就是只有 Reading 的功能。但沒有太大問題,因為我只要確保 App 初始狀態會有值,有辦法讀取到「上次」的紀錄,我先不用關心上次的紀錄怎麼來。

這樣功能的「範圍」就比較小且單純,只關注在取得資料後,正確渲染而已。

Do(1) BDD → 測試

從上一步生成的 BDD ,一樣按照先前的方法,試著請 AI 幫我生成看看測試。但這裡有個小小的不同,還記得我們之前都是「先做功能」再寫測試嗎?

這裡想嘗試以經典的 BDD + TDD 開發方式,先寫測試,測試會處於未通過的狀態 🔴,因為我們根本還沒寫功能。接著再來實做程式碼,將功能實現,直至測試通過 🟢。

一樣先試著用比較粗略的指令,請 Cursor IDE 幫我實作測試看看:

幫我根據 Scenario: 瀏覽已紀錄的帳目 這項場景,
新增一筆測試在 @AccountingApp.test.tsx 

結果 AI 給了我跟其他測試差不多的測試

// ... existing tests ...

it('displays recorded account entries correctly', () => {
  whenRender();
  
  // 模擬輸入第一筆帳目
  whenInputNumber(50);
  whenClickOK();
  whenSelectCategory('飲食');
  whenClickOK();

  // 驗證已紀錄的帳目是否正確顯示
  thenElementShouldExist('飲食');
  thenAmountShouldBe('$50');
});

// ... existing tests ...

雖然不符合心中預期,但確實是符合指令所述,看來得想個更具體的指令,讓 AI 更懂我的意圖才行。否則這樣的指令提供幾百次,AI 也猜不出我心目中想要的測試樣貌。

那只好以比較具體的「預計實作方向」來提供指令,因為目前新功能完全都還沒實作,AI 也沒 sample code 可以參考。預計會有個 AccountingRepository 的資料儲存庫,記帳 App 可以從中取得上一次的記帳資料。

最後提供了如下具體的指令:

幫我根據 Scenario: 瀏覽已紀錄的帳目 這項場景,
新增一筆測試在 @AccountingApp.test.tsx 

此測試可以假設資料是來自 AccountingRepository 的 getEntries(),
所以可先在 AccountingRepository 塞一筆帳目資料

並給了以下的生成結果(有用 inline prompt 微調一點)

import { AccountingRepository } from './AccountingRepository';

// ... 現有的代碼 ...

// 在描述塊的開始添加 mock
jest.mock('./AccountingRepository');

it('displays pre-existing accounting entries', () => {
		const mockEntries: HistoryItem[] = [
		  { id: 1, category: '飲食', amount: 100 },
		  { id: 2, category: '日用品', amount: 200 },
		];
		(AccountingRepository.getEntries as jest.Mock).mockResolvedValue(mockEntries);
		
		whenRender();
		
		thenCategoryShouldHave('飲食');
		thenElementShouldExist('$100');
		thenCategoryShouldHave('日用品');
		thenElementShouldExist('$200');
		thenTotalAmountShouldBe('$300');
});

最終看起來不錯,測試先到目前這樣就好,晚點等測試通過後,再來整理一下。至於測試的部分,因為功能根本還沒寫,當然是沒通過囉 🔴 。

Do(2) 測試 → 開發功能

測試已經寫了,緊接是把功能給完善。上次已經決定好開發的方向:資料來自於 AccountingRepository.getEntries()

既然如此,目標也相當明確了,不用猶豫指令怎麼給,就直接把具體指令提供給 AI:

請幫我在 App 實作 AccountingRepository,
這個 AccountingRepository 有個 getEntries 方法可以取得先前紀錄的帳目資料,作為帳目的「初始值」

一樣是用 claude-sonnet 的模型幫我產出這個「新功能」,最後產出了如下程式碼實作

// ... existing imports ...
import { useEffect } from "react";

// 新增 AccountingRepository 介面
interface AccountingRepository {
  getEntries: () => Promise<HistoryItem[]>;
}

// 實作 AccountingRepository
const accountingRepository: AccountingRepository = {
  getEntries: async () => {
    // 這裡模擬從 API 或本地存儲獲取數據
    // 在實際應用中,您可能會從後端 API 或本地存儲獲取數據
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve([
          { id: 1, amount: 100, category: "飲食" },
          { id: 2, amount: 200, category: "交通" },
          { id: 3, amount: 300, category: "娛樂" },
        ]);
      }, 1000);
    });
  },
};

const App: React.FC = () => {
  // ... existing state declarations ...

  // 使用 useEffect 來獲取初始帳目數據
  useEffect(() => {
    const fetchInitialEntries = async () => {
      try {
        const initialEntries = await accountingRepository.getEntries();
        setHistory(initialEntries);
      } catch (error) {
        console.error("Failed to fetch initial entries:", error);
      }
    };

    fetchInitialEntries();
  }, []);

  // ... rest of the component code ...
};

export default App;

雖然看起來還可以,但實作起來有點小複雜,我們晚點再來用 useQuery 這個 library 處理這部分的 query 實作。自己刻 useEffect 和 try…cath 來做 fetch 這件事,已經不是目前的主流寫法了,還是拿其他 library 已經處理好的來用為佳。

接著,我希望實作 interface AccountingRepository 的物件是個 class ,於是用 inline prompt 下指令直接改該處實作 :「我希望用 class 實作 AccountingRepository」。

這邊完成之後,我希望程式碼更乾淨,於是把這個 Repository 搬到另外一個檔案,這樣更「乾淨清楚」一點。而且 AI 預設回傳一些「假資料」,但我現在用不到,先註解起來作為稍後的參考使用。

./repository/AccountingRepository.ts

import { HistoryItem } from "../types";

interface AccountingRepository {
    getEntries: () => Promise<HistoryItem[]>;
}

export class AccountingRepositoryImpl implements AccountingRepository {
    async getEntries(): Promise<HistoryItem[]> {
        return Promise.resolve([]);

        // return new Promise((resolve) => {
        //     setTimeout(() => {
        //         resolve([
        //             { id: 1, amount: 100, category: "飲食" },
        //             { id: 2, amount: 200, category: "交通" },
        //             { id: 3, amount: 300, category: "娛樂" },
        //         ]);
        //     }, 1000);
        // });
    }
}

雖然實作到這邊了,但測試還沒有通過,反而還多壞了一個原先的測試。這是因為新加上了資料取得的功能,這個測試因而壞掉,被新來的 fetch 所影響「渲染狀態順序」,而造成非預期行為的測試不通過。

知道可能會這樣,但不太確定該怎麼做。也是請 AI 幫忙 debug 看看,最後在驗證這一塊包起了 waitFor 的函式,這好像有喚起了一點記憶,想起來 waitFor 可以做到「等待 UI 渲染」才做下個步驟。

it('allows editing of an existing record by double-clicking', async () => {
	  await whenRender();
	  
	  //...
	
	  await waitFor(() => {
	    thenCategoryShouldHave('飲食');
	    thenAmountShouldBe('$20');
	    thenElementShouldNotExist('娛樂');
	    thenElementShouldNotExist('$10');
	  });
});

但事情沒有憨人想得這麼簡單,測試還是一樣 🔴 沒通過,不過提供了一個方向,我應該要在「觸發行為」的時候,等待上個組件渲染好才對。試著在 double click 這裡用了 findByText 做 await 等待,等組件變化之後才做下一步。

async function whenDoubleClickRecord(recordText: string) {
  const recordElement = (await screen.findByText(recordText)).parentElement;
  //...
}

果不其然,賓果! 測試通過了!這樣改果然沒錯。

至於新功能的測試,改一下寫法即可,從 mock 改成 spyOn,將 AccountRepository 的回傳值給「假冒」起來,只需關注這個 repository 有確實給我值就好,這樣我就能測試 App 是不是真的有拿這個值來用。

最後測試長這樣,最後的驗證一樣是需要用 waitFor 等待,等狀態(state)變化都完成後,再來驗組件事否存在。

it('displays pre-existing accounting entries', async () => {
    const mockEntries: HistoryItem[] = [
      { id: 1, category: '飲食', amount: 100 },
      { id: 2, category: '日用品', amount: 200 },
    ];
    jest.spyOn(AccountingRepositoryImpl.prototype, 'getEntries').mockResolvedValue(mockEntries);

    await whenRender();

    await waitFor(() => {
      thenCategoryShouldHave('飲食');
      thenElementShouldExist('$100');
      thenCategoryShouldHave('日用品');
      thenElementShouldExist('$200');
      thenTotalAmountShouldBe('$300');
    });
  });

Check 重構整理

還沒有要實作紀錄如何取得,所以 getEntries() 這部分可以先放著不管。如上面所提,我想先把 fetch 資料那塊,用 react-query 這個函式庫做處理,程式碼可以相對簡潔並可靠地獲取資料。

下了「幫我用 react-query 這個套件重構這一塊」的 prompt,AI 很忠實的幫我將剛剛用 useEffect 實作 fetch 那一塊,改成用 useQuery 的方式獲取資料,並設定到 state 上。

AI 「很貼心」的為我們加上 loading 時候會出現的 Loading… 文字,如此一來可以在 UI 上防止使用者「誤觸」,也可以用在測試中「確保資料已經 loading 完成」

const { data: initialEntries, isLoading, error } = useQuery<HistoryItem[]>(['initialEntries'], async () => {
    try {
        return await accountingRepository.getEntries();
    } catch (error) {
        console.error("Failed to fetch initial entries:", error);
        throw error;
    }
});

useEffect(() => {
    if (initialEntries) {
        setHistory(initialEntries);
    }
}, [initialEntries]);

//...

if(isLoading) {
	return ...
}

因應上面的功能重構,測試也要調整一下寫法,在 whenRender 時需要多判斷 Loading 的文字已經消失。此外,因為 whenRender 變為 async 函式,所以有用到的地方都要調整一下。這樣調整之後,測試也都通過了 🟢。

async function whenRender() {
  //...

  await waitFor(() => {
    expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
  });
}

//...

await whenRender();

後來想想,根據「Loading…」這個文字來判斷,其實不太可靠。以後如果文字換成「載入中」,那測試還要回頭調整,這是我們開發者最不樂見的:「過於依賴 UI 細節」。

所以我想修改一下,趁現在記憶猶新,不要把這種明顯的「地雷」埋給未來的自己。於是想說該怎麼去驗證「已經不是 loading 狀態了?」,即便加了新功能,有什麼是可以在程式被修改後也「不太會變動」的判斷方式? 那應該就是判斷「最外層」的 App Wrapper 是否存在了。如果沒特別的大幅度更動,基本上都不太會動到這一塊的程式碼。

  • App

    <div data-testid="accounting-app" ...>
    
  • 測試

    function whenRender() {
    		//...
    		await waitFor(() => {
    		  expect(screen.getByTestId("accounting-app")).toBeInTheDocument();
    		});
    }
    
    

重構到此,程式碼變得精簡許多,測試也隨之修正並調整好了。

Action 覆盤

剩下哪些?現在有了「取得」資料,但新增、修改和刪除都還沒有做,現在只算是個做到一半的記帳軟體。甚至連裡面的資料紀錄實作,都還沒有決定怎麼做。

但目前這樣「一個功能」算是完成了,姑且就先做到這邊,預計下次會先做「新增」的部分,這樣 App 就可以「自給自足」,可以在 App 內新增帳目,下次打開 App 就能看到上次紀錄的帳目了。

結語

PDCA 循環,生生不息

這次介紹的 PDCA 開發循環,是個比較小的循環,從個小功能開始規劃、製作、重構以及下一步的盤點。藉由這樣一個功能的完整開發週期,可以把功能「完整」交付出去,甚至要上版也都沒問題,而不必擔心寫到一半,有哪些功能因而壞去。

所以,小循環還是大循環好? 小循環的開發方式,更貼近敏捷開發的精神,小步驟多增量的方式做開發製作,一個「完整」的需求,可能會經歷數次的 PDCA 循環。像這次的功能,就會分成好幾次的 PDCA 循環製作。

而大循環則是每個 PDCA 階段要做的事,都一起做一做,更貼近瀑布開發方式的精神。如這次沒有開發到的「新增、修改和刪除」,可能就會跟著「讀取」這個功能一起做開發。

如果是大循環的開發法,那有可能這篇介紹到需求+測試就差不多了,隔天(按照遺忘曲線來說,隔天記憶忘了 7 成)回頭看,至少有一半已經忘記寫什麼了…。

所以我會傾向更小的循環,一個循環在半天到一天之內可以完成,趁著記憶猶新趕快開發,如此一來更有效率呢!

REF


上一篇
Day25 — 青出於藍,重構的小步前進,一次一小步 Part2
下一篇
Day27 — 方興未艾 | PDCA 循環,跑到最後吧! (Part2)
系列文
與 AI 一起開發 Side Project 吧!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言