0


使用 Mockito 对 Flutter 代码进行单元测试

单元测试验证单个方法或类是否按预期工作。它还通过在进行新更改时确认现有逻辑是否仍然有效来提高可维护性。

通常,单元测试很容易编写,但可以在测试环境中运行。400默认情况下,这会在进行网络调用或 HTTP 请求时产生带有状态代码的空响应。为了解决这个问题,我们可以在每次发出 HTTP 请求时轻松使用 Mockito 返回虚假响应。Mockito 有各种用例,随着我们的进行,我们将逐步介绍这些用例。

在本教程中,我们将演示如何使用 Mockito 来测试 Flutter 代码。我们将学习如何生成模拟、存根数据以及对发出流的方法执行测试。让我们开始吧!

  • 什么是 Mockito?
  • 生成模拟和存根数据- 项目结构概述- 依赖注入
  • 使用参数匹配器
  • 在 Mockito 中创建假货
  • 在 Flutter 中模拟和测试流

什么是 Mockito?

Mockito 是一个众所周知的包,它可以更轻松地生成现有类的假实现。它消除了重复编写这些功能的压力。此外,Mockito 有助于控制输入,因此我们可以测试预期的结果。

假设使用 Mockito 可以更轻松地编写单元测试,但是,如果架构不好,模拟和编写单元测试很容易变得复杂。

在本教程的后面,我们将学习如何将 Mockito 与模型-视图-视图模型 (MVVM) 模式一起使用,该模式涉及将代码库分成不同的可测试部分,例如视图模型和存储库。

生成模拟和存根数据

模拟是真实类的假实现。它们通常用于控制测试的预期结果,或者当真实类在测试环境中容易出错时。

为了更好地理解这一点,我们将为处理发送和接收帖子的应用程序编写单元测试。

项目结构概述

在开始之前,让我们将所有必要的包添加到我们的项目中。

dependencies:
  dio: ^4.0.6 # For making HTTP requests
​
dev_dependencies:
  build_runner: ^2.2.0 # For generating code (Mocks, etc)
  mockito: ^5.2.0 # For mocking and stubbing

我们将使用 MVVM 和存储库模式,其中包括对存储库和视图模型的测试。在 Flutter 中,将所有测试放在test文件夹中是一个很好的做法,它与文件夹的结构非常匹配lib。

接下来,我们将通过附加到文件名来创建authentication_repository.dart和文件。这有助于测试运行者找到项目中存在的所有测试。authentication_repository_test.dart``_test


超过 20 万开发人员使用 LogRocket 来创造更好的数字体验了解更多 →


我们将通过创建一个名为AuthRepository. 顾名思义,这个类将处理我们应用程序中的所有身份验证功能。之后,我们将包含一个登录方法,该方法检查状态代码是否相等200并捕获身份验证时发生的任何错误。

class AuthRepository {
  Dio dio = Dio();
​
  AuthRepository();
​
  Future<bool> login({
    required String email,
    required String password,
  }) async {
    try {
      final result = await dio.post(
        '<https://reqres.in/api/login>',
        data: {'email': email, 'password': password},
      );
​
      if (result.statusCode != 200) {
        return false;
      }
    } on DioError catch (e) {
      print(e.message);
      return false;
    }
​
    return true;
  }
​
  // ...
}
void main() {
  late AuthRepository authRepository;
​
  setUp(() {
    authRepository = AuthRepository();
  });
​
  test('Successfully logged in user', () async {
    expect(
      await authRepository.login(email: '[email protected]', password: '123456'),
      true,
    );
  });
}

在上面的测试中,我们AuthRepository在 setup 函数中初始化了 。由于它将在每个测试和测试组之前直接在内部运行main,因此它将auth为每个测试或组初始化一个新的存储库。

接下来,我们将编写一个测试,期望登录方法返回true而不会抛出错误。但是,测试仍然失败,因为单元测试默认不支持发出网络请求,因此发出的登录请求Dio返回状态码400。

为了解决这个问题,我们可以使用 Mockito 生成一个模拟类,其功能类似于Dio. 在 Mockito 中,我们通过@GenerateMocks([classes])在方法的开头添加注释来生成模拟main。这会通知构建运行器为列表中的所有类生成模拟。

@GenerateMocks([Dio, OtherClass])
void main(){
    // test for login
}

接下来,打开终端并运行命令flutter pub run build_runner build以开始为类生成模拟。代码生成完成后,我们将能够通过添加Mock类名来访问生成的模拟。

@GenerateMocks([Dio])
void main(){
      MockDio mockDio = MockDio()
      late AuthRepository authRepository;
      ...
}

我们必须对数据进行存根,以确保MockDio在调用登录端点时返回正确的响应数据。在 Flutter 中,存根意味着在调用 mock 方法时返回一个假对象。例如,当测试使用 调用登录端点时MockDio,我们应该返回一个带有状态码的响应对象200。

可以使用 function 来对 mock 进行存根,该 functionwhen()可以与thenReturn,一起使用thenAnswer,或者thenThrow在我们调用 mock 方法时提供所需的值。该thenAnswer函数用于返回未来或流的方法,而thenReturn用于模拟类的普通同步方法。


// To stub any method; gives error when used for futures or stream
when(mock.method()).thenReturn(value);
​
// To stub method that return a future or stream
when(mock.method()).thenAnswer(() => futureOrStream);
​
// To stub error
when(mock.method()).thenThrow(errorObject);
​
// dart
@GenerateMocks([Dio])
void main() {
  MockDio mockDio = MockDio();
  late AuthRepository authRepository;
​
  setUp(() {
    authRepository = AuthRepository();
  });
​
  test('Successfully logged in user', () async {
    // Stubbing
    when(mockDio.post(
      '<https://reqres.in/api/login>',
      data: {'email': '[email protected]', 'password': '123456'},
    )).thenAnswer(
      (inv) => Future.value(Response(
        statusCode: 200,
        data: {'token': 'ASjwweiBE'},
        requestOptions: RequestOptions(path: '<https://reqres.in/api/login>'),
      )),
    );
​
    expect(
      await authRepository.login(email: '[email protected]', password: '123456'),
      true,
    );
  });
}

创建存根后,我们仍然需要传入MockDio测试文件,以便使用它而不是真正的dio类。为了实现这一点,我们将从中删除真实dio类的定义或实例化,authRepository并允许它通过其构造函数传递。这个概念称为依赖注入。

依赖注入

Flutter 中的依赖注入是一种技术,其中一个对象或类提供另一个对象的依赖项。这种模式确保测试模型和视图模型都可以定义dio它们想要使用的类型。

class AuthenticationRepository{
        Dio dio;
​
        // Instead of specifying the type of dio to be used
        // we let the test or viewmodel define it
        AuthenticationRepository(this.dio)
}
@GenerateMocks([Dio])
void main() {
  MockDio mockDio = MockDio();
  late AuthRepository authRepository;
​
  setUp(() {
    // we can now pass in Dio as an argument
    authRepository = AuthRepository(mockDio);
  });
}

使用参数匹配器

在前面的登录示例中,如果在发出请求时james@mail.com更改了电子邮件sam@mail.com,则测试将产生no stub found错误。这是因为我们只为james@mail.com.

但是,在大多数情况下,我们希望通过使用 Mockito 提供的参数匹配器来避免重复不必要的逻辑。使用参数匹配器,我们可以将相同的存根用于广泛的值而不是确切的类型。

为了更好地理解匹配参数,我们将测试PostViewModel并为PostRepository. 建议使用这种方法,因为当我们存根时,我们将返回自定义对象或模型,而不是响应和映射。这也很容易!

首先,我们将创建PostModel更清晰地表示数据的 。

class PostModel {
  PostModel({
    required this.id,
    required this.userId,
    required this.body,
    required this.title,
  });
​
  final int id;
  final String userId;
  final String body;
  final String title;
​
  // implement fromJson and toJson methods for this
}

接下来,我们创建PostViewModel. 这用于检索或发送数据到PostRepository. PostViewModel只是从存储库中发送和检索数据并通知 UI 使用新数据重建。

import 'package:flutter/material.dart';
import 'package:mockito_article/models/post_model.dart';
import 'package:mockito_article/repositories/post_repository.dart';
​
class PostViewModel extends ChangeNotifier {
  PostRepository postRepository;
  bool isLoading = false;
​
  final Map<int, PostModel> postMap = {};
​
  PostViewModel(this.postRepository);
​
  Future<void> sharePost({
    required int userId,
    required String title,
    required String body,
  }) async {
    isLoading = true;
    await postRepository.sharePost(
      userId: userId,
      title: title,
      body: body,
    );
​
    isLoading = false;
    notifyListeners();
  }
​
  Future<void> updatePost({
    required int userId,
    required int postId,
    required String body,
  }) async {
    isLoading = true;
    await postRepository.updatePost(postId, body);
​
    isLoading = false;
    notifyListeners();
  }
​
  Future<void> deletePost(int id) async {
    isLoading = true;
    await postRepository.deletePost(id);
​
    isLoading = false;
    notifyListeners();
  }
​
  Future<void> getAllPosts() async {
    isLoading = true;
    final postList = await postRepository.getAllPosts();
​
    for (var post in postList) {
      postMap[post.id] = post;
    }
​
    isLoading = false;
    notifyListeners();
  }
}

如前所述,我们模拟依赖关系而不是我们测试的实际类。在这个例子中,我们为 . 编写单元测试PostViewModel并模拟PostRepository. 这意味着我们将调用生成MockPostRepository类中的方法,而不是PostRepository可能引发错误的方法。

Mockito 使匹配参数变得非常容易。例如,看updatePost一下PostViewModel. 它调用存储库updatePost方法,该方法只接受两个位置参数。对于这个类方法的存根,我们可以选择提供精确的postIdand body,或者我们可以使用anyMockito 提供的变量来保持简单。

@GenerateMocks([PostRepository])
void main() {
  MockPostRepository mockPostRepository = MockPostRepository();
  late PostViewModel postViewModel;

  setUp(() {
    postViewModel = PostViewModel(mockPostRepository);
  });

  test('Updated post successfully', () {
    // stubbing with argument matchers and 'any'
    when(
      mockPostRepository.updatePost(any, argThat(contains('stub'))),
    ).thenAnswer(
      (inv) => Future.value(),
    );

    // This method calls the mockPostRepository update method
    postViewModel.updatePost(
      userId: 1,
      postId: 3,
      body: 'include `stub` to receive the stub',
    );

    // verify the mock repository was called
    verify(mockPostRepository.updatePost(3, 'include `stub` to receive the stub'));
  });
}

上面的存根包括any变量和argThat(matcher)函数。在 Dart 中,匹配器用于指定测试期望。我们有不同类型的匹配器适用于不同的测试用例。例如,如果对象包含相应的值,则匹配器contains(value)返回。true

匹配位置参数和命名参数

在 Dart 中,我们也有位置参数和命名参数。在上面的示例中,方法的模拟和存根updatePost处理位置参数并使用any变量。

但是,命名参数不支持any变量,因为 Dart 没有提供一种机制来知道元素是否用作命名参数。相反,我们anyNamed(’name’)在处理命名参数时使用该函数。

when(
  mockPostRepository.sharePost(
    body: argThat(startsWith('stub'), named: 'body'),
    postId: anyNamed('postId'),
    title: anyNamed('title'),
    userId: 3,
  ),
).thenAnswer(
  (inv) => Future.value(),
);

当使用带有命名参数的匹配器时,我们必须提供参数的名称以避免错误。您可以在 Dart 文档中阅读有关匹配器的更多信息,以查看所有可能的可用选项。

向日葵远程控制软件,居家办公必备神器,支持手机控制电脑远程传输文件!

在 Mockito 中创建假货

模拟和假货经常被混淆,所以让我们快速澄清两者之间的区别。

模拟是生成的类,允许使用参数匹配器进行存根。然而,Fake 是覆盖真实类的现有方法以提供更大灵活性的类,所有这些都无需使用参数匹配器。

例如,在 post 存储库中使用 fakes 而不是 mocks 将允许我们使 fake repository 功能类似于真实存储库。这是可能的,因为我们能够根据提供的值返回结果。简单来说,当我们调用sharePost测试时,我们可以选择保存帖子,稍后再确认帖子是否被保存getAllPosts。

class FakePostRepository extends Fake implements PostRepository {
  Map<int, PostModel> fakePostStore = {};

  @override
  Future<PostModel> sharePost({
    int? postId,
    required int userId,
    required String title,
    required String body,
  }) async {
    final post = PostModel(
      id: postId ?? 0,
      userId: userId,
      body: body,
      title: title,
    );
    fakePostStore[postId ?? 0] = post;
    return post;
  }

  @override
  Future<void> updatePost(int postId, String body) async {
    fakePostStore[postId] = fakePostStore[postId]!.copyWith(body: body);
  }

  @override
  Future<List<PostModel>> getAllPosts() async {
    return fakePostStore.values.toList();
  }

  @override
  Future<bool> deletePost(int id) async {
    fakePostStore.remove(id);

    return true;
  }
}

更新后的测试使用fake如下所示。使用fake,我们可以一次测试所有方法。帖子在添加或共享时将获取到存储库中的地图。

@GenerateMocks([PostRepository])
void main() {
  FakePostRepository fakePostRepository = FakePostRepository();
  late PostViewModel postViewModel;

  setUp(() {
    postViewModel = PostViewModel(fakePostRepository);
  });

  test('Updated post successfully', () async {
    expect(postViewModel.postMap.isEmpty, true);
    const postId = 123;

    postViewModel.sharePost(
      postId: postId,
      userId: 1,
      title: 'First Post',
      body: 'My first post',
    );
    await postViewModel.getAllPosts();
    expect(postViewModel.postMap[postId]?.body, 'My first post');

    postViewModel.updatePost(
      postId: postId,
      userId: 1,
      body: 'My updated post',
    );
    await postViewModel.getAllPosts();
    expect(postViewModel.postMap[postId]?.body, 'My updated post');
  });
}

在 Flutter 中模拟和测试流

使用 Mockito 模拟和存根流与期货非常相似,因为我们对存根使用相同的语法。然而,流与期货有很大不同,因为它们提供了一种机制,可以在发出值时持续监听它们。

要测试返回流的方法,我们可以测试该方法是否被调用或检查值是否以正确的顺序发出。

class PostViewModel extends ChangeNotifier {
  ...
  PostRepository postRepository;
  final likesStreamController = StreamController<int>();
​
  PostViewModel(this.postRepository);
​
  ...
  void listenForLikes(int postId) {
    postRepository.listenForLikes(postId).listen((likes) {
      likesStreamController.add(likes);
    });
  }
}
​
​
@GenerateMocks([PostRepository])
void main() {
  MockPostRepository mockPostRepository = MockPostRepository();
  late PostViewModel postViewModel;
​
  setUp(() {
    postViewModel = PostViewModel(mockPostRepository);
  });
​
  test('Listen for likes works correctly', () {
    final mocklikesStreamController = StreamController<int>();
​
    when(mockPostRepository.listenForLikes(any))
        .thenAnswer((inv) => mocklikesStreamController.stream);
​
    postViewModel.listenForLikes(1);
​
    mocklikesStreamController.add(3);
    mocklikesStreamController.add(5);
    mocklikesStreamController.add(9);
​
    // checks if listen for likes is called
    verify(mockPostRepository.listenForLikes(1));
    expect(postViewModel.likesStreamController.stream, emitsInOrder([3, 5, 9]));
  });
}

在上面的示例中,我们添加了一个listenforLikes方法,该方法调用该PostRepository方法并返回一个我们可以监听的流。接下来,我们创建了一个测试来侦听流并检查方法是否以正确的顺序被调用和发出。

对于一些复杂的情况,我们可以使用expectLaterorexpectAsync1来代替只使用expect函数。

结论

就像大多数逻辑看起来一样简单,编写测试非常重要,因此我们不会重复 QA 这些功能。编写测试的目的之一是在您的应用程序变大时减少重复的 QA。

在本文中,我们了解了如何在编写单元测试时有效地使用 Mockito 生成模拟。我们还学习了如何使用 fakes 和参数匹配器来编写功能测试。

标签: 单元测试

本文转载自: https://blog.csdn.net/weixin_47967031/article/details/127280904
版权归原作者 pxr007 所有, 如有侵权,请联系我们删除。

“使用 Mockito 对 Flutter 代码进行单元测试”的评论:

还没有评论