跳转到正文
YGJoon 技术博客

Flutter 单元测试详解

发布于:
·1.5k 字 · 约 4 分钟·作者:
group 组织用例、setUp 准备环境,mockito 生成 Mock 隔离外部依赖,纯 Dart 逻辑先测透再往上叠
Flutter 单元测试详解文章封面
目录

简介

单元测试是测试代码最小单元的方法,能够帮助我们及早发现问题。本文介绍如何在 Flutter 中编写单元测试。

基本配置

添加依赖

dev_dependencies:
  test: ^1.24.1
  mockito: ^5.4.2
  build_runner: ^2.4.6

创建测试文件

// test/calculator_test.dart
import 'package:test/test.dart';
import 'package:my_app/calculator.dart';

void main() {
  test('Calculator should add two numbers', () {
    final calculator = Calculator();
    expect(calculator.add(2, 3), equals(5));
  });
}

测试组织

测试组

group('Calculator', () {
  late Calculator calculator;
  
  setUp(() {
    calculator = Calculator();
  });
  
  test('should add two numbers', () {
    expect(calculator.add(2, 3), equals(5));
  });
  
  test('should subtract two numbers', () {
    expect(calculator.subtract(5, 3), equals(2));
  });
  
  test('should multiply two numbers', () {
    expect(calculator.multiply(2, 3), equals(6));
  });
  
  test('should divide two numbers', () {
    expect(calculator.divide(6, 2), equals(3));
  });
});

生命周期钩子

group('UserRepository', () {
  late UserRepository repository;
  late MockDatabase database;
  
  setUpAll(() {
    // 在所有测试开始前执行一次
  });
  
  setUp(() {
    // 每个测试前执行
    database = MockDatabase();
    repository = UserRepository(database);
  });
  
  tearDown(() {
    // 每个测试后执行
  });
  
  tearDownAll(() {
    // 在所有测试结束后执行一次
  });
  
  test('should save user', () async {
    final user = User('John');
    await repository.saveUser(user);
    verify(database.insert(user)).called(1);
  });
});

Mock 对象

创建 Mock

// 生成 Mock 类
@GenerateMocks([Database])
void main() {
  // 使用生成的 Mock 类
  final database = MockDatabase();
  
  when(database.query('users'))
      .thenAnswer((_) => Future.value([
        {'id': 1, 'name': 'John'},
        {'id': 2, 'name': 'Jane'},
      ]));
      
  // 验证调用
  verify(database.query('users')).called(1);
  
  // 验证未调用
  verifyNever(database.delete('users'));
}

异步测试

test('should fetch users', () async {
  final repository = UserRepository(database);
  
  when(database.query('users'))
      .thenAnswer((_) => Future.value([
        {'id': 1, 'name': 'John'},
      ]));
      
  final users = await repository.getUsers();
  
  expect(users, hasLength(1));
  expect(users.first.name, equals('John'));
});

完整示例

// 被测试的类
class UserService {
  final Database database;
  final ApiClient apiClient;
  
  UserService(this.database, this.apiClient);
  
  Future<User?> getUserById(int id) async {
    try {
      final data = await database.query(
        'users',
        where: 'id = ?',
        whereArgs: [id],
      );
      
      if (data.isEmpty) {
        // 从 API 获取
        final user = await apiClient.getUser(id);
        if (user != null) {
          await database.insert('users', user.toMap());
        }
        return user;
      }
      
      return User.fromMap(data.first);
    } catch (e) {
      return null;
    }
  }
  
  Future<List<User>> searchUsers(String query) async {
    if (query.isEmpty) return [];
    
    final data = await database.query(
      'users',
      where: 'name LIKE ?',
      whereArgs: ['%$query%'],
    );
    
    return data.map((e) => User.fromMap(e)).toList();
  }
  
  Future<bool> updateUser(User user) async {
    try {
      // 先更新 API
      final success = await apiClient.updateUser(user);
      if (!success) return false;
      
      // 再更新数据库
      await database.update(
        'users',
        user.toMap(),
        where: 'id = ?',
        whereArgs: [user.id],
      );
      
      return true;
    } catch (e) {
      return false;
    }
  }
}

// 测试文件
@GenerateMocks([Database, ApiClient])
void main() {
  group('UserService', () {
    late UserService service;
    late MockDatabase database;
    late MockApiClient apiClient;
    
    setUp(() {
      database = MockDatabase();
      apiClient = MockApiClient();
      service = UserService(database, apiClient);
    });
    
    group('getUserById', () {
      test('should return user from database when exists', () async {
        // Arrange
        when(database.query(
          'users',
          where: 'id = ?',
          whereArgs: [1],
        )).thenAnswer((_) => Future.value([
          {'id': 1, 'name': 'John'},
        ]));
        
        // Act
        final user = await service.getUserById(1);
        
        // Assert
        expect(user, isNotNull);
        expect(user!.id, equals(1));
        expect(user.name, equals('John'));
        verify(database.query(
          'users',
          where: 'id = ?',
          whereArgs: [1],
        )).called(1);
        verifyNever(apiClient.getUser(any));
      });
      
      test('should fetch from API when not in database', () async {
        // Arrange
        when(database.query(
          'users',
          where: 'id = ?',
          whereArgs: [1],
        )).thenAnswer((_) => Future.value([]));
        
        when(apiClient.getUser(1))
            .thenAnswer((_) => Future.value(User(1, 'John')));
            
        // Act
        final user = await service.getUserById(1);
        
        // Assert
        expect(user, isNotNull);
        expect(user!.id, equals(1));
        expect(user.name, equals('John'));
        verify(apiClient.getUser(1)).called(1);
        verify(database.insert('users', any)).called(1);
      });
    });
  });
}

最佳实践

  1. 纯函数优先:正文 UserService 那种带 database+apiClient 的类要写一堆 when/verify,但 JsonConverter.convertJsonToDart 这种纯函数(实战案例)一行 expect 就能测。重构前先把纯逻辑抽出来,测试成本直接砍半。
  2. Mock 只 mock 边界:database、apiClient 这类 IO 边界值得 mock;mock 了 5 层还断言 mock 本身(比如被测代码只是转发,却 verify 转发被调用了)等于在测 mockito 框架。经验值:一个用例里 when 超过 3 个,说明被测类职责太重,该拆了。
  3. 边界条件比 happy path 更值钱:divide(6, 0) 抛 ArgumentError、searchUsers('') 直接返回 []、getUserById 数据库命中时不走 API——正文示例的用例里真正有价值的就是这几个分支。每个 if/else 至少一个用例。
  4. 异步测试必须 await 到底:漏写 await 的 Future 测试会”假通过”——断言在 Future 完成前就跑完了。看到 test('...', () async 就检查最后一行有没有 await 被测调用。
  5. 用 group+命名说清场景:group('getUserById') 下面是 'should return user from database when exists',失败时一眼知道是”数据库命中”分支挂了,而不是对着 test 3 failed 猜。

注意事项

  1. 别测试实现细节:断言”调用了 database.query('users', where: 'id = ?')”是实现细节——明天改成缓存优先,测试全红但功能没坏。断言返回值和可观察行为;verify 调用次数只用在”必须发生一次”的副作用上(比如 API 回填时的 insert)。
  2. Mock 的 thenAnswer 别写死大数据:thenAnswer((_) => Future.value([...100条...])) 让测试又慢又难读。只给刚好触发分支的最小数据——getUserById 的用例 1 条记录就够了。
  3. setUp 里重建依赖:database/apiClient 每个用例新建(正文就是这么写的)。Mock 是有状态的——上一个用例的 when 会污染下一个,出现”本地全过、CI 随机挂”。
  4. 控制粒度:一个 test 只断言一个行为。正文 'should fetch from API when not in database' 同时 verify 了 apiClient.getUser 和 database.insert——这是可接受的上限,再往下加断言就该拆成两个用例。
  5. 别为私有方法写测试:_parseUser 测不透,就说明它该被抽成公开的纯函数(或独立类)。通过公开 API 覆盖私有逻辑——私有方法改名不该导致测试重写。

实战案例

单元测试我写得不多,但有个绝佳的候选:ygking_json_to_dart_model 的 JsonConverter。它是纯 Dart 类(143 行,零 Flutter 依赖),convertJsonToDart 是纯函数——输入 JSON 字符串,输出 Dart 代码字符串。这种函数是单元测试的完美靶子:expect(converter.convertJsonToDart('{"name":"a"}', 'User'), contains('class User')),再补几个边界(空数组、嵌套对象、非法 JSON)。我没写(工具类项目,写完就扔),但如果重来,我会先写测试——因为”字符串生成”类逻辑最容易被改坏,加个功能崩三个 case。纯函数优先写单测,有 UI 依赖的往后排,这是投入产出比最高的测试策略。

总结

单元测试是保证代码质量的重要手段。通过编写全面的单元测试,可以提高代码的可靠性和可维护性。


分享这篇文章:


相关文章

上一篇
Flutter 文件操作详解
下一篇
Flutter Widget 测试详解