Flutter 单元测试示例

我在这里创建了一个简单的应用程序,作为分享学习单元测试的简单方法。

在其中,我从 PokéApi 获取宝可梦列表,并将它们显示在 ListView 小部件中,如下所示:

Tela principal do app mostrando uma lista de pokemon

关于测试

Pokemon.dart

这个 宝可梦模型 有 2 个方法:levelUp,它增加宝可梦的 HP 和等级;以及一个工厂方法 fromJson,如下所示:

  • levelUp

void levelUp() {
  level++;
  hp *= 1.05;
}
  • fromJson

factory Pokemon.fromJson(Map<String, dynamic> json) {
  return Pokemon(
    name: json['species']['name'],
    imageUrl: json['sprites']['front_default'],
  );
}

尽管 levelUp 方法做了两件事,但我为了项目的简洁性还是保留了它。那么,对于这个方法,需要验证两个点:

  • 调用时,宝可梦的等级会增加吗?
  • 调用时,宝可梦的 HP 会增加 5% 吗?

为此,我写了以下测试:

test('When a pokemon levels up, then it\'s level increases', () {
  // Arrange
  final pokemon = Pokemon(name: 'Bulbasaur');
  final initialLevel = pokemon.level;
  final expectedLevel = initialLevel + 1;
  // Act
  pokemon.levelUp();

  // Assert
  expect(pokemon.level, expectedLevel);
});

E

test('When a pokemon levels up, then it\'s HP increases 5%', () {
  // Arrange
  final pokemon = Pokemon(name: 'Bulbasaur');
  var expectedHp = pokemon.hp * 1.05;
  
  // Act
  pokemon.levelUp();

  // Assert
  expect(pokemon.hp, expectedHp);
});

我喜欢遵循 AAA 模式来编写我的测试。

  • Arrange(准备):你创建特定测试的变量和配置;
  • Act(执行):你调用方法;
  • Assert(断言):你验证获得的结果是否是预期的结果。

此外,我保留了注释,以便于可视化这些阶段。

根据阶段解释测试

  • 第一个测试中,我创建了一个宝可梦并定义了预期的等级 (Arrange),然后调用 levelUp 方法 (Act),最后确保宝可梦的等级等于初始等级 + 1;
  • 第二个测试中,我创建了一个宝可梦并定义了预期的 HP (Arrange),然后调用 levelUp 方法 (Act),最后确保宝可梦的 HP 等于 HP + 5%。

为什么这很重要?

想象一下,你不得不手动测试。从你选择一个初始宝可梦到它达到 30 级,仅仅是为了验证你的宝可梦的 HP 是 411.61,需要花费多长时间?在我这台不算很强大的机器上,测试执行只花了 1 秒钟!

PokemonApiClient.dart

这个 是负责从 PokeApi 获取数据的类。它只有一个方法,但有两个可能的输出:1 个是“快乐路径”,一切正常,API 返回预期结果;另 1 个是“悲伤路径”,发生了一些事情,API 没有返回预期值。

Future<List<Pokemon>> getPokemon() async {
  List<Pokemon> pokemons = [];
  for (int i = 1; i <= 10; i++) {
    var response = await client.get(Uri.parse('$url/$i'));
    if (response.statusCode == 200) {
      var json = jsonDecode(response.body);
      pokemons.add(Pokemon.fromJson(json));
    }
  }
  return pokemons;
}

不幸的是,我不得不使用 for 循环来调用每个宝可梦的特定端点,因为带有分页的请求版本没有返回 sprite 数据,这将使界面比现在更加受限。

单元测试需要只测试单元,而且大多数时候,单元是一个方法。它们是最简单的测试类型,应该独立(不依赖数据库、API 等)且快速地执行。由于这些特性,它们通常在应用程序中数量最多。

Pirâmide de testes

那么如何测试对 API 或数据库的调用呢?使用模拟对象 (mocks)——模拟其他对象行为的对象。

对于这个类的测试,我使用了 mockito 库,就像 Flutter 文档中的示例一样,但你可以使用任何执行类似操作的库。

@GenerateMocks([http.Client])
void main() {
  group('getPokemon method', () {
    test(
        'When response\'s status code is not 200, getPokemon returns an empty list',
        () async {
      // Arrange
      final client = MockClient((request) async {
        return Response('', 404);
      });
      final pokemonClient = PokemonApiClient(client: client);
      final expectedResult = [];

      // Act
      final result = await pokemonClient.getPokemon();

      // Assert
      expect(result, expectedResult);
    });

    test(
        'When response\'s status code is 200, getPokemon returns a pokemon list',
        () async {
      // Arrange
      final client = MockClient((request) async {
        return Response(
            '{"species": {"name": "bulbasaur"}, "sprites": {"front_default": ""}}',
            200);
      });
      final pokemonClient = PokemonApiClient(client: client);

      // Act
      final result = await pokemonClient.getPokemon();

      // Assert
      expect(result.length, 10);
    });
  });
}

这里有 3 个新的重要概念,它们是:

  • @GenerateMocks([http.Client]): 这里我使用了 mockito 库,并要求它为每个测试创建一个新的 http.Client 模拟实例。要使其正常工作,我需要安装 build_runner 库并使用命令:
$ flutter pub run build_runner build
  • group: 一种将与同一类或同一方法相关的测试分组的方式;
  • final client = MockClient((request) async {return Response(”, 404);}); : 这里我创建了模拟的 client。它将通过构造函数传递给 PokemonApiClient 类。这是一种将类的依赖关系外部化并使其耦合度降低的方法——例如,在这种情况下,任何 client 都可以传递给它。模拟对象时,重要的是定义它对任何方法的响应。在这种情况下,我告诉它需要用空响应和 404 状态码返回请求。

在解释了这些概念之后,我将讨论测试本身:

  • 第一个测试中,我创建了 client 的模拟对象,预期结果变量:一个空列表和一个 PokemonApiClient 实例 (Arrange),然后调用 getPokemon 方法 (Act),最后验证获得的结果是否与预期结果相同 (Assert)。
  • 第二个测试中,我做了同样的事情,但这次我配置了 client 返回一个宝可梦的 json。由于方法内部有一个循环运行 10 次,所以在 Assert 部分,我验证列表是否包含 10 个宝可梦,以及宝可梦的名称是否与响应中获得的相同。

为什么这很重要?

仔细思考一个类或方法需要测试到什么程度是很棒的。如果仅仅是一次 API 调用,没有其他行为,那么进行单元测试是否有意义,因为调用会被模拟?总之,由于这个方法有行为,因此重要的是不仅要验证它是否返回预期结果,还要验证它是否在所有情况下都返回预期结果:200 响应 / 任何其他响应。

GitHub

查看 Github