使用 Mockito 模擬依賴關係
某些情況下,單元測試可能會依賴需要從線上 Web 服務或資料庫中獲取資料的類。這樣會帶來一些不便,原因如下:
-
訪問線上服務或資料庫會拖慢測試執行效率。
-
原本可以透過的測試可能會失敗,因為 Web 服務或資料庫可能會回傳不符合預期的結果。這種情況被稱作『flaky test』。
-
使用線上 web 服務或資料庫來測試很難覆蓋全所有可能成功或失敗的場景。
因此,最好不要依賴線上 web 服務或資料庫,我們可以把這些依賴『模擬(mock)』出來。模擬(Mocks)允許我們仿造一個線上服務或資料庫,並且可以根據條件回傳特定結果。
通常來說,可以透過建立類別的另一種實現來模擬(mock)這種依賴。類別的另一種實現可以手寫,也可以藉助 Mockito 套件,後者簡單一些。
本篇教程介紹了 Mockito 套件的基本用法,可以參考以下步驟:
使用步驟
-
新增
mockito
和test
依賴 -
建立一個要測試的函式
-
建立一個模擬了
http.Client
的測試檔案 -
給每一個條件寫一個測試
-
執行這些測試
更多訊息可以查閱 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
函式進行單元測試。為了便於測試,我們需要做兩個改動:
-
給函式提供一個
http.Client
。這樣的話我們可以在不同情形下提供相應的http.Client
實例。如果是 Flutter 以及伺服器端專案,可以提供http.IOClient
。如果是瀏覽器應用,可以提供http.BrowserClient
。為了測試,我們要提供一個模擬的http.Client
。 -
使用上面提供的
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])
註解以生成含有 mockito
的 MockClient
類。
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()
函式會完成下面兩件事中的一件:
-
如果 http 請求成功,回傳
Post
-
如果 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。