有时,单元测试可能依赖于从实际网络服务或数据库获取数据的类。这会带来一些不便,原因如下:

  • 调用实际服务或数据库会减慢测试执行速度。
  • 如果网络服务或数据库返回意外结果,一个原本通过的测试可能会开始失败。这被称为“不稳定测试”。
  • 通过使用实际网络服务或数据库,很难测试所有可能的成功和失败场景。

因此,与其依赖实际网络服务或数据库,不如“模拟”这些依赖项。模拟允许模拟实际网络服务或数据库,并根据情况返回特定结果。

一般来说,你可以通过创建类的替代实现来模拟依赖项。可以手动编写这些替代实现,或者使用 Mockito 包作为捷径。

本示例介绍了使用 Mockito 包进行模拟的基础知识,步骤如下:

  1. 添加包依赖项。
  2. 创建要测试的函数。
  3. 创建一个包含模拟 http.Client 的测试文件。
  4. 为每个条件编写测试。
  5. 运行测试。

有关更多信息,请参阅 Mockito 包文档。

1. 添加包依赖项

#

要使用 mockito 包,请将其与 flutter_test 依赖项一起添加到 pubspec.yaml 文件的 dev_dependencies 部分。

此示例还使用了 http 包,因此请在 dependencies 部分定义该依赖项。

mockito: 5.0.0 通过代码生成支持 Dart 的空安全。要运行所需的代码生成,请在 dev_dependencies 部分添加 build_runner 依赖项。

要添加依赖项,请运行 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() 方法。

该函数现在应如下所示:

dart
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');
  }
}

在你的应用代码中,可以直接通过 fetchAlbum(http.Client())fetchAlbum 方法提供一个 http.Clienthttp.Client() 会创建一个默认的 http.Client

3. 创建一个包含模拟 http.Client 的测试文件

#

接下来,创建一个测试文件。

根据 单元测试简介 示例中的建议,在根目录的 test 文件夹中创建一个名为 fetch_album_test.dart 的文件。

在主函数中添加注解 @GenerateMocks([http.Client]) 以使用 mockito 生成 MockClient 类。

生成的 MockClient 类实现了 http.Client 类。这允许你将 MockClient 传递给 fetchAlbum 函数,并在每个测试中返回不同的 HTTP 响应。

生成的模拟文件将位于 fetch_album_test.mocks.dart。导入此文件即可使用它们。

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() {
}

接下来,运行以下命令生成模拟:

dart run build_runner build

4. 为每个条件编写测试

#

fetchAlbum() 函数会执行以下两项操作之一:

  1. 如果 HTTP 调用成功,则返回一个 Album
  2. 如果 HTTP 调用失败,则抛出 Exception

因此,你需要测试这两个条件。使用 MockClient 类为成功测试返回“Ok”响应,为不成功测试返回错误响应。使用 Mockito 提供的 when() 函数测试这些条件:

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);
    });
  });
}

5. 运行测试

#

现在你已经有了带有测试的 fetchAlbum() 函数,可以运行测试了。

flutter test test/fetch_album_test.dart

你还可以按照 单元测试简介 示例中的说明,在你喜欢的编辑器中运行测试。

完整示例

#
lib/main.dart
#
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
#
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 测试依赖于网络服务或数据库的函数或类。这只是对 Mockito 库和模拟概念的简要介绍。有关更多信息,请参阅 Mockito 包提供的文档。