使用 Mockito 模擬依賴關係

某些情況下,單元測試可能會依賴需要從線上 Web 服務或資料庫中獲取資料的類。這樣會帶來一些不便,原因如下:

  • 訪問線上服務或資料庫會拖慢測試執行效率。

  • 原本可以透過的測試可能會失敗,因為 Web 服務或資料庫可能會回傳不符合預期的結果。這種情況被稱作『flaky test』。

  • 使用線上 web 服務或資料庫來測試很難覆蓋全所有可能成功或失敗的場景。

因此,最好不要依賴線上 web 服務或資料庫,我們可以把這些依賴『模擬(mock)』出來。模擬(Mocks)允許我們仿造一個線上服務或資料庫,並且可以根據條件回傳特定結果。

通常來說,可以透過建立類別的另一種實現來模擬(mock)這種依賴。類別的另一種實現可以手寫,也可以藉助 Mockito 套件,後者簡單一些。

本篇教程介紹了 Mockito 套件的基本用法,可以參考以下步驟:

使用步驟

  1. 新增 mockitotest 依賴

  2. 建立一個要測試的函式

  3. 建立一個模擬了 http.Client 的測試檔案

  4. 給每一個條件寫一個測試

  5. 執行這些測試

更多訊息可以查閱 Mockito package 的官方文件。

1. 新增 package 依賴

為了使用 mockito 套件,首先將其和 flutter_test 的依賴一起新增到 pubspec.yaml 檔案的 dev_dependencies 部分:

本例中還使用了 http 套件,需要新增到 dependencies 部分:

感謝程式碼生成,mockito: 5.0.0 已經支援了 Dart 的空安全。要執行所需的程式碼生成工具,請將 build_runner 依賴新增到 dev_dependencies 專案下。

執行 flutter pub add 新增依賴:

$ flutter pub add http dev:mockito dev:build_runner

2. 建立一個要測試的函式

本例中,我們要對 獲取網路資料 章節的 fetchAlbum 函式進行單元測試。為了便於測試,我們需要做兩個改動:

  1. 給函式提供一個 http.Client。這樣的話我們可以在不同情形下提供相應的 http.Client 實例。如果是 Flutter 以及伺服器端專案,可以提供 http.IOClient 。如果是瀏覽器應用,可以提供 http.BrowserClient。為了測試,我們要提供一個模擬的 http.Client

  2. 使用上面提供的 client 來請求網路資料,不要用 http.get() 這個靜態方法,因為它比較難以模擬。

函式經過改動之後:

Future<Album> fetchAlbum(http.Client client) async {
  final response = await client
      .get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1'));

  if (response.statusCode == 200) {
    // If the server did return a 200 OK response,
    // then parse the JSON.
    return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
  } else {
    // If the server did not return a 200 OK response,
    // then throw an exception.
    throw Exception('Failed to load album');
  }
}

In your app code, you can provide an http.Client to the fetchAlbum method directly with fetchAlbum(http.Client()). http.Client() creates a default http.Client.

3. 建立一個模擬了 http.Client 的測試檔案

接下來,建立一個測試檔案。

遵循 單元測試介紹 章節的建議,我們在根目錄下的 test 資料夾中建立一個名字為 fetch_post_test.dart 的檔案。

在 main 函式上新增一個 @GenerateMocks([http.Client]) 註解以生成含有 mockitoMockClient 類。

MockClient 類實現了 http.Client 類。如此一來,我們就可以把 MockClient 傳給 fetchPost 函式,還可以在每個測試中回傳不同的 http 請求結果。

生成的 mock 檔案將會放在 fetch_album_test.mocks.dart,請匯入以使用它。

import 'package:http/http.dart' as http;
import 'package:mocking/main.dart';
import 'package:mockito/annotations.dart';

// Generate a MockClient using the Mockito package.
// Create new instances of this class in each test.
@GenerateMocks([http.Client])
void main() {
}

Next, generate the mocks running the following command:

$ dart run build_runner build

4. 給每一個條件寫一個測試

回過頭來看,fetchPost() 函式會完成下面兩件事中的一件:

  1. 如果 http 請求成功,回傳 Post

  2. 如果 http 請求失敗,丟擲 Exception

因此,我們要測試這兩種條件。可以使用 MockClient 類為成功的測試回傳一個 “OK” 的請求結果,為不成功的測試回傳一個錯誤的請求結果。

我們使用 Mockito 的 when() 函式來達到以上目的:

import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:mocking/main.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';

import 'fetch_album_test.mocks.dart';

// Generate a MockClient using the Mockito package.
// Create new instances of this class in each test.
@GenerateMocks([http.Client])
void main() {
  group('fetchAlbum', () {
    test('returns an Album if the http call completes successfully', () async {
      final client = MockClient();

      // Use Mockito to return a successful response when it calls the
      // provided http.Client.
      when(client
              .get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1')))
          .thenAnswer((_) async =>
              http.Response('{"userId": 1, "id": 2, "title": "mock"}', 200));

      expect(await fetchAlbum(client), isA<Album>());
    });

    test('throws an exception if the http call completes with an error', () {
      final client = MockClient();

      // Use Mockito to return an unsuccessful response when it calls the
      // provided http.Client.
      when(client
              .get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1')))
          .thenAnswer((_) async => http.Response('Not Found', 404));

      expect(fetchAlbum(client), throwsException);
    });
  });
}

5. 執行測試

現在我們有了一個帶測試的 fetchAlbum() 函式,開始執行測試!

$ flutter test test/fetch_album_test.dart

你也可以參考 單元測試介紹 章節用自己喜歡的編輯器來執行測試。

完整的範例

lib/main.dart
import 'dart:async';
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

Future<Album> fetchAlbum(http.Client client) async {
  final response = await client
      .get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1'));

  if (response.statusCode == 200) {
    // If the server did return a 200 OK response,
    // then parse the JSON.
    return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
  } else {
    // If the server did not return a 200 OK response,
    // then throw an exception.
    throw Exception('Failed to load album');
  }
}

class Album {
  final int userId;
  final int id;
  final String title;

  const Album({required this.userId, required this.id, required this.title});

  factory Album.fromJson(Map<String, dynamic> json) {
    return Album(
      userId: json['userId'] as int,
      id: json['id'] as int,
      title: json['title'] as String,
    );
  }
}

void main() => runApp(const MyApp());

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  late final Future<Album> futureAlbum;

  @override
  void initState() {
    super.initState();
    futureAlbum = fetchAlbum(http.Client());
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Fetch Data Example',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Fetch Data Example'),
        ),
        body: Center(
          child: FutureBuilder<Album>(
            future: futureAlbum,
            builder: (context, snapshot) {
              if (snapshot.hasData) {
                return Text(snapshot.data!.title);
              } else if (snapshot.hasError) {
                return Text('${snapshot.error}');
              }

              // By default, show a loading spinner.
              return const CircularProgressIndicator();
            },
          ),
        ),
      ),
    );
  }
}
test/fetch_album_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:mocking/main.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';

import 'fetch_album_test.mocks.dart';

// Generate a MockClient using the Mockito package.
// Create new instances of this class in each test.
@GenerateMocks([http.Client])
void main() {
  group('fetchAlbum', () {
    test('returns an Album if the http call completes successfully', () async {
      final client = MockClient();

      // Use Mockito to return a successful response when it calls the
      // provided http.Client.
      when(client
              .get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1')))
          .thenAnswer((_) async =>
              http.Response('{"userId": 1, "id": 2, "title": "mock"}', 200));

      expect(await fetchAlbum(client), isA<Album>());
    });

    test('throws an exception if the http call completes with an error', () {
      final client = MockClient();

      // Use Mockito to return an unsuccessful response when it calls the
      // provided http.Client.
      when(client
              .get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1')))
          .thenAnswer((_) async => http.Response('Not Found', 404));

      expect(fetchAlbum(client), throwsException);
    });
  });
}

總結

透過本例,我們已經學會瞭如何用 Mockito 來測試對 web 服務或資料庫有依賴的函式或類。這裡只是簡短地介紹了 Mockito 函式庫以及模擬(mocking)的概念。更多內容請移步至 Mockito package