30 天學會 Flutter 測試Day 6 / 6

Day 6 不改變狀態也不回傳,那我怎麼測試?

這幾天的文章中,我們談論如何處理那些頑劣的依賴,透過 Stub 的方式,注入我們設計過的資料到測試之中,最後驗證回傳值或者物件狀態來決定測試成功與失敗。再討論 Stub 的時候,我們介紹了測試替身,也講到測試替身有許多種,今天就介紹另一種測試替身 Mock 與它的使用場景吧。

Mock 是什麼

那 Mock 是什麼呢?在前幾天我們有稍微介紹 Mock:Mock 物件用來驗證 SUT 是不是有正確跟這個 Mock 物件正確的互動,如果有正確的呼叫 Mock 物件身上的方法,那測試就會綠燈,反之則會紅燈。那為什麼我們會需要 Mock,想像一下,假設我們想測試的方法沒有回傳值,也不會改變自身的狀態時,我們就無法透過驗證狀態或回傳值等方式來測試,我們只能依靠 Mock 來幫忙驗證互動。

出處:http://xunitpatterns.com/Mock Object.html

在上圖中,跟 Stub 一樣,我們會在 Arrange 階段建立 SUT 與注入 Mock 物件,也會在 Act 階段呼叫 SUT 身上的方法,但是最後是驗證 Mock 物件,而不是驗證 SUT,讓我們透過實際例子來感受一下 Mock 吧。

先舉個例子

假設我們有一個 PurchaseProductService 的類別,當使用者購買商品時,程式會呼叫 PurchaseProductService,檢查錢包是否有足夠錢,然後透過 ProductRepository 呼叫後端 API 購買。往下看之前,有興趣的觀眾朋友可能可以想想看,我們要怎麼測試這個類別。

class PurchaseProductService {
                    final ProductRepository productRepository;
                  
                    PurchaseProductService(this.productRepository);
                  
                    void execute(Product product, Wallet wallet) {
                      if (product.price > wallet.money) {
                        throw MoneyNotEnoughException();
                      }
                      
                      productRepository.purchase(product);
                    }
                  }
                  

這麼方法沒有回傳值,類別本身也沒有狀態可以拿來驗證,我們就沒辦法透過狀態驗證來決定是否成功,那我們要如何解決呢?讓我們手寫一個 Mock 物件來測試這個類別吧,新增一個假的 MockProductRepository 並設定預期的結果給它,然後這個 MockProductRepository 傳入 PurchaseProductService 之中呼叫完 execute 後,呼叫 MockProductRepository.verify 來確認結果是否符合預期,也就是 callCount 要等於 1 且 product 要是 Product(100)。

main() {
                    test("purchase product success", () {
                      var mockProductRepository = MockProductRepository();
                  
                      mockProductRepository.setExpectedCallCount(1);
                      mockProductRepository.setExpectedProduct(const Product(100));
                  
                      var purchaseProductService = PurchaseProductService(mockProductRepository);
                  
                      purchaseProductService.execute(const Product(100), Wallet(200));
                  
                      mockProductRepository.verify();
                    });
                  }
                  
                  class MockProductRepository implements ProductRepository {
                    int expectedCallCount = 0;
                    int actualCallCount = 0;
                    Product? expectedProduct;
                    Product? actualProduct;
                  
                    void setExpectedProduct(Product product) {
                      expectedProduct = product;
                    }
                  
                    void setExpectedCallCount(int count) {
                      expectedCallCount = count;
                    }
                  
                    @override
                    Future<void> purchase(Product product) async {
                      actualProduct = product;
                      actualCallCount ++;
                    }
                  
                    void verify() {
                      expect(actualProduct, expectedProduct);
                      expect(actualCallCount, expectedCallCount);
                    }
                  }
                  

是不是覺得寫 Mock 物件很累,其實如果真的要使用 Mock,我們有更輕鬆簡單的方式。包含前幾天介紹的 Stub 加上今天介紹的 Mock,當我們需要時,如果都得要花時間自己手刻,未免有點浪費時間,借助測試套件的幫助,讓我們能更快速的產生這些測試替身。

讓製作測試替身更容易

Flutter 測試相關的套件有許多,而其中常使用的肯定是 mockito 了,與 Java 著名的 Mockito 套件一樣,可以協助我們在測試中製作各式各樣的測試替身。以今天的例子來說,我們可以用 mockito 改寫一下。

@GenerateNiceMocks([MockSpec<ProductRepository>()])
                  main() {
                    test("purchase product success", () {
                      var mockProductRepository = MockProductRepository();
                  
                      var purchaseProductService = PurchaseProductService(mockProductRepository);
                  
                      purchaseProductService.execute(const Product(100), Wallet(200));
                  
                      verify(mockProductRepository.purchase(const Product(100))).called(1);
                    });
                  }
                  

首先我們得先在 main 上面加上 @GenerateNiceMocks 的 annotation,主要是讓 build_runner 可以自動幫我們產生 Mock 物件,接著我們就能直接使用 MockProductRepository 了,是不是很神奇。如果細心的觀眾朋友可能會注意到,當我們執行完 build_runner,在測試檔案旁邊會多一個 mock 檔案,這裡頭其實就是 mockito 幫我們產生好的 MockProductRepository。

class MockProductRepository extends _i1.Mock implements _i2.ProductRepository {
                    @override
                    _i3.Future<void> purchase(_i2.Product? product) => (super.noSuchMethod(
                          Invocation.method(
                            #purchase,
                            [product],
                          ),
                          returnValue: _i3.Future<void>.value(),
                          returnValueForMissingStub: _i3.Future<void>.value(),
                        ) as _i3.Future<void>);
                  }
                  

使用 mockito 來輔助製作 Mock 物件,能省去寫測試替身的時間,讓時間花在更有價值的事情上。雖然我們的例子中都用手寫測試替身來測試,但這只是希望方便大家了解測試替身的內涵,但實務中是不太會這樣做的,大多數語言都有方便製作測試替身的套件,使用這些套件節省時間,讓我們花更多時間專注實作與設計有效的測試案例是比較有價值的。

Stub 也能用 mockito

mockito 除了可以用來產生 Mock 之外,還能於 Stub 假資料,讓我們改寫一下前幾天的 UserRepository 測試。

@GenerateNiceMocks([MockSpec<Client>()])
                  main() {
                    test("get user ok from api", () async {
                      var mockClient = MockClient();
                  
                      when(mockClient.get(Uri.parse("https://jsonplaceholder.typicode.com/users/1"))).thenAnswer(
                        (_) async => Response("{\"id\":1, \"name\": \"Tom\"}", 200),
                      );
                  
                      var userRepository = UserRepository(mockClient);
                  
                      var user = await userRepository.get(1);
                  
                      expect(user, User(id: 1, name: "Tom"));
                    });
                  }
                  

還記得前幾天我們自己實作的 StubClient,我們修改一下測試,改用 mockito 產生一個 MockClient,接著我們能用 mockito 中的 when 方法來作假 MockClient 中的 API 回傳值。以上面的例子來說,我們就指定了 mockClient.get() 在測試中會回傳指定 Response 物件。

狀態驗證 vs 行為驗證

至此我們已經認識了兩個最常用的測試替身 Stub 與 Mock 之外,而這兩個測試替身其實也分屬於兩種不同的驗證方式:狀態驗證行為驗證。顧名思義,狀態驗證的測試都是驗證物件身上的狀態或回傳值,來確認結果是否符合預期,而行為驗證則是確認 SUT 是否有呼叫依賴身上的方法,來決定結果是否符合預期。

這兩種測試方法倒也沒有誰好誰壞,不同的開發方式,也各自傾向的測試方式,有時候我們會只能驗證狀態,有時候我們只能驗證行為。但是當兩個方法都容易使用的時候,通常會更傾向於使用狀態驗證的方式,使用狀態驗證的測試,比較不容易因為架構調整而需要修改,驗證行為會造成測試認識物件的實作,當實作方式改變時,造成測試脆弱的問題。

除了 mockito 之外

測試套件除了 mockito 之外,由 **Felix Angelov **製作的 mocktail 也是不錯的選擇。與 mockito 用法十分類似,一樣是使用 when 來設定假資料,一樣可以用 verify 來驗證互動,只是寫法上稍微有些差別。比較大的不同是,mockito 是使用 @GenerateMocks 或者 @GenerateNiceMocks 加上 build_runner 來產生測試替身,而 mocktail 則是需要自己寫一個 Mock 類別。

class MockClient extends Mock implements Client {}
                  

雖然看似 mocktail 要自己寫比較麻煩,但實際上並不太花時間,有時候反而是反覆執行 build_runner 要更稍微花一點時間。

main() {
                    test("get user ok from api", () async {
                      var mockClient = MockClient();
                  
                      when(() => mockClient.get(Uri.parse("https://jsonplaceholder.typicode.com/users/1"))).thenAnswer(
                        (_) async => Response("{\"id\":1, \"name\": \"Tom\"}", 200),
                      );
                  
                      var userRepository = UserRepository(mockClient);
                  
                      var user = await userRepository.get(1);
                  
                      expect(user, User(id: 1, name: "Tom"));
                    });
                  }
                  

使用哪一個套件,還是可以根據觀眾自己的需求決定即可。

小結

Mock 主要用於驗證 SUT 與依賴的互動狀況,當測試無法透過狀態驗證來檢查結果時,我們就會建立 Mock 物件來協助檢查互動結果。在實務中,我們常常會使用測試套件來減輕寫測試的負擔,使用測試套件快速建立測試替身。在 Flutter 中,我們可以選擇 mockito 或 mocktail 來 Stub 或 Mock,使用測試套件不但可以減少寫的時候的負擔,也可以減少我們維護的的成本。

Mock 是最後的選擇

如果我們可以選擇測試回傳值,或者物件身上的狀態,那我們最好不要使用 Mock 的方式驗證。因為 Mock 驗證的 SUT 是否正確地與依賴互動,等同於在測試中指定 SUT 必須要該依賴互動,這會使得當我們未來想修改 SUT 的依賴時,測試也不得不修改,因為測試指定了 SUT 必須要與這個依賴互動。

使用 any 讓測試更彈性

在上面測試中,呼叫 when 方法時,我們指定了只有在當使用端傳入特定參數給 mockClient.get 時,才會回應特定回傳值。如果參數不符合,這個設定就不會生效。

when(mockClient.get(Uri.parse("https://jsonplaceholder.typicode.com/users/1")))
                  
                  when(mockClient.get(any))
                  

但是其實我也可以使用 any ,用以告訴 mockito 無論使用端傳入什麼參數,都一律回傳指定回傳值,這樣會使得測試更加有彈性。