LY Corporation Tech Blog

支持 LY Corporation 和 LY Corporation Group (LINE Plus, LINE Taiwan and LINE Vietnam) 服務,宣傳技術和開發文化。

測試新手的學習筆記:如何寫出更符合實際需求的測試?

前言

不知道你有沒有也遇過這種情況:把一段 code 丟給 AI,請他幫你寫測試,但只要系統稍微重構,測試就全部壞光光

我以前也是這樣,直到有一天我發現,原來「測試到底要驗證什麼」這件事,比寫測試本身還重要。

這篇文章是我自己一路從「不知道測試重點」到「能寫出對齊需求方的測試」的學習筆記。也希望能幫助跟我一樣剛接觸測試,少走一點冤枉路。

常見誤區:給 AI 生 test 就完成

首先,就我自己第一次接觸到測試的經驗來說,最大的地雷就是把生出來的程式碼直接丟給 AI 然後叫他幫我寫測試

這樣測試起來超級方便,但是很容易生出無效測試⋯⋯舉例來說,我們的場景如果來到了訂單服務系統:

class OrderService {
    constructor(private orderRepository: OrderRepository) {}

    async createOrder(orderId: string, customerType: 'normal' | 'vip'): Promise<Order> {
        const order = new Order(orderId, customerType);
        await this.orderRepository.save(order);
        return order;
    }
}

如果我請 GPT-4o 幫我寫測試的話,會直接生成下面這個 test

 jest.mock('./OrderRepository');
jest.mock('./Order');
jest.mock('./OrderItem');

describe('OrderService', () => {
    let orderRepository: jest.Mocked<OrderRepository>;
    let orderService: OrderService;
    let mockOrder: jest.Mocked<Order>;

    beforeEach(() => {
        orderRepository = new OrderRepository() as jest.Mocked<OrderRepository>;
        orderService = new OrderService(orderRepository);
        mockOrder = new Order('orderId', 'normal') as jest.Mocked<Order>;
    });

    describe('createOrder', () => {
        it('should create and save a new order', async () => {
            orderRepository.save.mockResolvedValueOnce(undefined);

            const order = await orderService.createOrder('orderId', 'normal');

            expect(orderRepository.save).toHaveBeenCalledWith(order);
            expect(order).toBeInstanceOf(Order);
        });
    });
});
 

你可能會覺得:問題在哪?不正是直接按照上面的程式碼來進行測試嗎?

這段測試確實是按照 OrderService 來進行測試了,但是他的問題在於:測試只關注 component 之間的互動,而不是業務流程有沒有被滿足。

另外 AI 生出來的測試也只在乎某某 function 有沒有被 call 到,但實際的資料流就斷在這個 service 裡面了,AI 寫出來的單元測試關注的範圍只在 function 這個 unit。缺點是測試本身都在描述技術實作,而不是業務語言。

測試的重點應該是驗收:這個功能有沒有滿足需求方的要求,能不能交付商業價值。但上面那樣直接丟給 AI 所生出來的測試只能確保 component 之間的互動正常。甚至是暫時正常,因為如果測試高度關注某某 function 有沒有被呼叫,代表這個測試非常依賴實作細節,導致重構時測試也會跟著壞掉。

到了這邊,我想來提倡一下 Martin Fowler 提倡的 Sociable Test,我們把 unit test 的 unit 定義為單一行為

image
source: https://martinfowler.com/bliki/UnitTest.html

像是我們允許訂單服務在測試裡面去用真的 Repository(也可以是 in memory 實作),但我們讓整個流程跑一次,最後驗證:訂單有沒有真的被建立?資料有沒有正確?客戶拿到的訂單是不是他期望的?

這樣的測試,就叫做 sociable test,因為它允許這個服務跟其他 class(像 Repository)互動起來,重點是關注「行為結果」,而不是「技術內部的呼叫」。

不過我覺得要拿捏好這個 unit scope,舉個浮誇一點的例子:如果你有 10 個微服務,一個行為需要 10 個微服務串連起來才能完成驗證,那會非常麻煩。

在這個情況下,我們會需要衡量一下測試執行的速度,如果跑一個服務的測試都需要依賴其他九個服務的建立,那我們還是先切割小驗收範圍比較好。像是我們只驗收到該微服務打 API 出去,或是接 API 資料進來,而不會需要去關心這些 API 打出去的資料有沒有被妥善處理,因為這是另一個服務要去擔心的事情。

sociable test 強調的是「驗證整個流程的行為」,而不僅僅是某個 function 是否被呼叫。這樣的測試可以幫助我們避免掉過度關注實作細節,而能聚焦在商業價值上。

了解完 sociable test 之後、把 unit scope 切開後,我們就需要來了解測試替身的概念,我覺得也是一個挺 tricky 的部分。

測試替身

我自己在看測試替身的時候,覺得網路上的說法眾說紛紜,很像在讀哲學一樣、每個人的見解都有所出入,最後是看 xUnit Pattern 這邊的說明才比較理解。

透過替身,我們可以:

  • 避免測試真的去呼叫 API、發送 email 等等第三方服務

  • 加快測試速度,因為替身通常是非常 light-weight

  • 讓測試更穩定,不被外部系統的狀況影響

  • 更容易驗證邏輯分支,因為替身可以精準模擬特定行為或錯誤。

但我自己在學測試替身的時候,常常遇到最困擾的一件事:網路上很多文章對於 mock、stub、spy、fake、dummy 的定義都不一樣,搞得很像哲學大辯論。

所以我最後是回到 xUnit Patterns 的分類來學,這邊我會用簡單的例子來幫大家釐清這些角色是誰、要怎麼選。

image
source: https://learn.microsoft.com/zh-tw/archive/msdn-magazine/2007/september/images/cc163358.fig02.gif
  • Dummy Object

    這是最簡單的替身,通常只是為了讓參數不為 null,或是佔個位置,實際上在測試中不會被使用到。

  • Fake

    是一個有功能的替身,常見例子是用 in-memory database 取代真的資料庫。它會真的執行邏輯,但通常是簡化版、速度快,不關注驗證行為,只是讓流程能走得通。另外用 fake 有個好處:在開源專案裡面我有看過一個 feature 要做好會需要花點力氣,所以先暫時用 fake 的實作讓該功能加減可以用,並且請其他志願者來實作。

  • Stub

    讓你能控制被測者 SUT (Subject Under Test) 的間接輸入。例如你希望讓某隻 API 回傳指定的結果,好讓 SUT 執行特定邏輯。另外 Stub 是被動的,它只提供預設的輸出。

  • Spy

    Spy 就像探針一樣,他是為了讓你去監測 SUT 的內部行為,還會記錄 SUT 對它做了什麼操作(例如:被呼叫了幾次、參數是什麼),讓你之後可以驗證這些行為。

    但我不太喜歡用 spy,因為用了 spy 就代表你需要讓測試依賴於程式碼的實作細節,維護性會稍稍降低。不過在必要場景下還是得用。

Mock,這個我得拉出來講

在實務上,mock library(像 jest、Mockito)所產生的物件,常常同時具備 dummy、stub、spy、mock 的能力。你可以用這些 library 來產生一個假物件,讓它同時回傳固定值、記錄呼叫、驗證互動,甚至只做佔位。

所以當我們說「mock 一個物件」,很多時候只是泛指「用 library 產生一個替身」,而不是指「嚴格的 mock object」。

這也是為什麼在討論測試替身時,真的很容易搞混,因為大家說的「mock」其實可能在做 stub 或 spy 的事。所以我的建議是:不需要太糾結誰是 spy,誰是 mock。

這邊有簡單的 check list,幫助你判斷

  • 想控制 indirect input:用 stub

  • 想驗證是否被呼叫:用 spy (或 mock)

  • 想模擬整個流程但不碰到真的資料庫:用 fake

  • 只是佔位置,不會被用到:用 dummy

學會測試替身是不是就夠了?

到這邊應該已經對測試有 sense 了,不過雖然學會了測試替身,我們已經能夠在技術層面上更靈活地寫測試、模擬場景、驗證行為。但這時候我自己也會遇到一個困惑:「即使我用 sociable test 加上測試替身,測試依然會寫成一堆技術的驗證,業務方看不懂、需求方也看不懂。」

因為大部分測試還是會寫成:

expect(orderRepository.save).toHaveBeenCalled();
expect(order.customerType).toBe('vip');

這樣的語言太技術導向,無法讓商業方或產品經理直接參與。

這時候,我們就可以進一步提升測試的層次,往 BDD(Behavior-Driven Development, 行為驅動開發)邁進。

BDD 行為驅動測試:讓測試說人話

BDD 的核心思想是:測試是程式給人看的

BDD 鼓勵我們把測試寫得更接近業務語言,讓所有利害關係人(PM、開發、測試、業務)都能參與討論需求與驗收標準。

而且 BDD 強調:

  • 測試應該描述行為(behavior)

  • 測試應該用人類語言(自然語言)

  • 測試應該以需求場景為單位

這樣的方式不只是「寫測試」,而是透過測試幫助團隊對齊需求、聚焦商業價值

比如說,我們可能寫了下面這樣的 sociable test:

it('should create order for vip customer with correct discount', async () => {
    const order = await orderService.createOrder('orderId', 'vip');
    expect(order.discount).toBe(20);
});

這樣的測試已經比之前好,因為它驗證了商業邏輯「VIP 要有 20% 折扣」。

但這段程式碼沒有直接用業務語言去描述這個場景,這時候就可以借助 BDD(Behavior-Driven Development, 行為驅動開發)來提升可閱讀性。

BDD 是什麼?

BDD 最重要的貢獻是:

  • 讓測試變成團隊裡的共同語言

  • 測試用來描述「當使用者做了什麼,系統該有什麼行為」

  • 測試描述的是「業務價值」,而不只是「技術行為」

用 Given-When-Then 重寫剛才的測試

如果用 BDD 思維來重寫剛剛的測試,我們會這樣寫:

describe('Create Order', () => {
    it('Given customer with vip, When the customer creates an order, Then the order will discount by 20', async () => {
        // Given
        const vipCustomer = 'vip';

        // When
        const order = await orderService.createOrder('orderId', vipCustomer);

        // Then
        expect(order.discount).toBe(20);
    });
});

這樣的測試,有幾個好處:

  • 測試敘述直接說明需求(VIP 客戶應該有折扣)

  • 測試步驟清楚分成 Given(前置條件)、When(行為)、Then(驗證)

  • 不需要懂程式的人看 test name 就能理解需求是什麼

結語

今天一口氣介紹了常見誤區、測試替身、BDD,其實還有很多深入探討的主題,比方說 BDD 可以怎麼幫助 AI 加速你寫程式的效率、測試要怎麼寫才有好的維護性、⋯⋯
如果你也是最近才開始碰測試,或是也有「AI 幫我寫測試」卻踩過雷的經驗,
歡迎找我聊聊你在測試上遇過的坑是什麼。
未來有機會說不定可以再跟大家分享這類主題~我們下次再見!