- 示例工程
- 移动开发
【免费下载链接】flutter-examples
[Examples] Simple basic isolated apps, for budding flutter devs.
本指南以 flutter-examples 仓库中的 unit_testing 示例项目为主线,系统讲解 Flutter/Dart 单元测试的完整实践:从测试环境配置、被测代码设计,到同步/异步测试用例的编写与运行命令。读完本文,你将能够为项目中的表单校验逻辑、数据获取层编写覆盖空值、边界与成功路径的单元测试,并理解如何让测试真正印证 UI 层的业务行为。
一、项目定位:一个专注单元测试的最小示例
原文档将该项目定位为 "Example app demonstrating unit testing",并指出单元测试的核心价值:单元测试非常适合验证单个函数(function)、方法(method)或类(class)的行为("Unit tests are handy for verifying the behavior of a single function, method, or class")。这正是本示例 App 的设计意图——它并不追求复杂的 UI 交互,而是把"可以被测试的业务逻辑"显式地抽离出来,供开发者对照学习。
从 目录结构 可以看到,本项目将代码与测试严格分层:
unit_testing/ ├── lib/ │ ├── api/Places.dart # 模拟远程 API 的数据源 │ ├── components/place_info_card.dart # 景点信息卡片 UI │ ├── model/lat_long.dart # 经纬度模型 │ ├── model/location.dart # 景点模型 │ ├── screens/home_screen.dart # 景点列表页 │ ├── screens/sign_in_screen.dart # 登录表单页 │ ├── helpers.dart # 核心被测逻辑:表单校验 + 数据获取 │ └── main.dart # 应用入口与路由 ├── test/unit/ │ ├── formValidator_test.dart # 表单校验的同步测试 │ └── places_test.dart # 数据获取的异步测试 ├── assets/ # 景点图片资源 └── pubspec.yaml要点在于:被测逻辑集中在 lib/helpers.dart,UI 层只负责调用。这种"把逻辑从 Widget 中抽离"的组织方式,正是单元测试能够轻量、快速执行的前提。
二、测试环境配置:pubspec.yaml 中的 flutter_test
运行单元测试的前提是在pubspec.yaml中声明测试依赖。查看 unit_testing/pubspec.yaml,关键配置如下:
environment: sdk: ">=2.12.0 <3.0.0" dependencies: flutter: sdk: flutter cupertino_icons: ^0.1.3 dev_dependencies: flutter_test: sdk: flutter flutter: uses-material-design: true assets: - assets/需要说明的配置要点:
flutter_test必须放在dev_dependencies中:它随 Flutter SDK 提供(sdk: flutter),仅用于开发与测试阶段,不会打包进发布产物。flutter_test提供了test()、expect()、group()等核心 API,也内置了package:test的能力。- SDK 约束
>=2.12.0 <3.0.0:本项目基于 Dart 2.12 及以上的 null safety 语义编写,意味着分析器会要求显式处理空值(下文会看到被测代码在 null safety 下的一个值得注意的写法)。 assets: - assets/声明:应用运行时会加载 assets 下的四张景点图片(1.jpeg、2.jpg、3.jpg、4.jpg),供 place_info_card.dart 中的Image.asset使用。测试本身不依赖这些资源,但应用运行需要。
配置完成后,测试文件通过import 'package:flutter_test/flutter_test.dart';引入测试框架,并通过import 'package:unit_testing/helpers.dart';直接引用被测代码。
三、被测代码设计:可测试的业务逻辑从哪来
单元测试的前提是被测单元本身是独立、无副作用、可通过参数驱动的函数或类。本项目在 lib/helpers.dart 中提供了两个典型被测单元:
3.1 表单校验类 FormValidator
class FormValidator { static String validateEmail(String email) { final pattern = RegExp( r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+"); if (email.isEmpty) { return "please enter email"; } else if (!pattern.hasMatch(email)) { return "please enter valid email"; } else { return null; } } static String validatePassword(String password) { if (password.isEmpty) { return "please enter your password"; } else if (password.length < 8) { return "minimum lenght of password must be 8 characters"; } else { return null; } } }从实现看,它遵循了"先空值、再格式、后成功"的校验顺序,把错误提示作为返回值返回,null表示校验通过。这是单元测试最理想的形态:纯函数、无 IO、输入输出一一对应。需要注意:在 null safety 语义下,String返回类型却可能返回null,更严谨的写法是将签名改为String?;从源码结构看,这属于示例项目早期写法,不影响测试逻辑的讲解。
3.2 数据获取类 TouristPlaces
class TouristPlaces { static Future<List<Location>> getData() async { // Here we can call a real API. return await PlacesAPI().fetchAllPlaces(); } }TouristPlaces.getData()对外暴露一个异步接口,注释明确说明"这里本可以调用真实 API",而实际数据源 lib/api/Places.dart 用硬编码列表 + 延迟模拟实现了接口:
class PlacesAPI { final List<Location> _locations = [ Location(id: 1, name: "Kumarakom Backwaters", country: "India", ...), Location(id: 2, name: "Angel Falls", country: "Venezuela", ...), Location(id: 3, name: "Avenue of the Baobabs", country: "Madagascar", ...), Location(id: 4, name: "Denali National Park", country: " Alaska", ...), ]; Future<List<Location>> fetchAllPlaces() { return Future.delayed(Duration(seconds: 3), () { return _locations; }); } }数据模型定义在 lib/model/location.dart(id、name、image、latlong、country、info)与 lib/model/lat_long.dart(latitude、longitude)中。fetchAllPlaces()通过Future.delayed(Duration(seconds: 3), ...)模拟了约 3 秒的网络延迟,这为编写异步单元测试提供了真实的场景。
四、同步测试实战:逐一解读 formValidator_test.dart
同步测试针对纯函数校验逻辑,文件位于 test/unit/formValidator_test.dart。其结构使用了group()对相关用例分组,再以test()声明单个用例:
import 'package:flutter_test/flutter_test.dart'; import 'package:unit_testing/helpers.dart'; void main() { group( 'Email test', () { test( 'Valid Email test', () { final result = FormValidator.validateEmail("example@flutter.com"); expect(result, isNull); }, ); test( 'Invalid Email test', () { final result = FormValidator.validateEmail("abcf"); expect(result, 'please enter valid email'); }, ); test( 'Empty email test', () { final result = FormValidator.validateEmail(""); expect(result, 'please enter email'); }, ); }, ); group( 'Password Test', () { test( 'Empty password test', () { final resutl = FormValidator.validatePassword(""); expect(resutl, 'please enter your password'); }, ); test( 'Valid password test', () { final resutl = FormValidator.validatePassword("hello@123456"); expect(resutl, isNull); }, ); test( 'Invalid password test', () { final resutl = FormValidator.validatePassword("hello"); expect(resutl, 'minimum lenght of password must be 8 characters'); }, ); }, ); }4.1 三种断言语义
| 用例 | 输入 | 断言expect(...) | 验证目标 |
|---|---|---|---|
| Valid Email test | example@flutter.com | isNull | 合法邮箱返回null(通过) |
| Invalid Email test | abcf | 'please enter valid email' | 非法格式返回对应错误文案 |
| Empty email test | "" | 'please enter email' | 空值优先返回"请输入邮箱" |
| Empty password test | "" | 'please enter your password' | 空密码提示 |
| Valid password test | hello@123456 | isNull | 长度 ≥ 8 且非空即通过 |
| Invalid password test | hello | 'minimum lenght of password must be 8 characters' | 长度不足 8 触发长度校验 |
这里的isNull是 flutter_test 内置的匹配器(matcher),用于断言结果为null;直接传入字符串则断言结果与该字符串相等。这套用例与 helpers.dart 的分支结构一一对应,覆盖了"空值 → 格式/长度 → 成功"三条路径,这正是单元测试"边界覆盖"的基本范式。
4.2 测试如何反哺 UI
这段被测试的校验逻辑并非孤立存在——登录页 lib/screens/sign_in_screen.dart 的TextFormField直接把它挂载为表单验证器:
TextFormField( validator: (value) => FormValidator.validateEmail(value), decoration: InputDecoration(hintText: "Email"), ), ... TextFormField( validator: (value) => FormValidator.validatePassword(value), decoration: InputDecoration(hintText: "Password"), ), ... RaisedButton( onPressed: () { if (_formKey.currentState.validate()) { Navigator.of(context).pushReplacementNamed('/homeScreen'); } }, child: Text('Sign In'), )也就是说,单元测试覆盖的正是登录表单的判定规则:校验通过后Navigator跳转到/homeScreen。从 lib/main.dart 的路由定义("/": (context) => SignInScreen()、"/homeScreen": (context) => HomeScreen())可以看到完整流程。测试保证的是"规则正确",UI 负责"规则生效"。
五、异步测试实战:places_test.dart 与模拟 API
第二个测试文件 test/unit/places_test.dart 展示了异步单元测试的写法:
import 'package:flutter_test/flutter_test.dart'; import 'package:unit_testing/helpers.dart'; import 'package:unit_testing/model/location.dart'; void main() { test( "fetched places", () async { print("test is running"); print("called API for feching Locations"); final places = await TouristPlaces.getData(); expect(places.length, greaterThan(0)); for (var i in places) { final Location place = i; expect(place.id, isNotNull); } }, ); }要点解析:
- 测试体声明为
() async:由于被测方法返回Future<List<Location>>,测试通过await等待异步结果,这是 Dart 异步测试的标准形态。 expect(places.length, greaterThan(0)):断言数据源返回非空列表,greaterThan同样是 flutter_test 提供的数值匹配器。expect(place.id, isNotNull):遍历每条景点数据,断言每条记录的id字段均已初始化,用于验证"数据完整性"。- 真实运行该测试会等待约 3 秒:因为 Places.dart 中的
fetchAllPlaces()通过Future.delayed人为引入了延迟。这提示了一个工程实践:真实网络请求不应出现在单元测试中,示例用内存列表模拟数据源,从而让测试在无网络、无真实后端的环境下稳定可复现。
在 UI 侧,首页 lib/screens/home_screen.dart 通过FutureBuilder消费同一数据源:
body: FutureBuilder( future: TouristPlaces.getData(), // this functions calls API. builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.hasData) { final List<Location> data = snapshot.data; return ListView.builder( physics: BouncingScrollPhysics(), itemCount: data.length, itemBuilder: (context, index) => PlaceInfo(data: data[index]), ); } else { return Center(child: CircularProgressIndicator()); } }, ),可以推断:测试验证的"数据非空、字段完整"正是FutureBuilder渲染列表(data.length与data[index])所依赖的前提。先有测试锁定数据契约,再有 UI 安全消费数据,是本示例想传递的完整链路。
六、如何运行测试:flutter test 命令
在项目根目录(即unit_testing/)执行标准 Flutter 测试命令:
# 运行该项目全部测试 flutter test # 运行指定测试文件 flutter test test/unit/formValidator_test.dart # 运行指定分组(按名称过滤,例如只跑邮箱相关用例) flutter test --name "Email test"- 约定:Flutter 默认收集
test/目录下以_test.dart结尾的文件,本项目两个测试文件恰好命名规范,因此flutter test会同时执行同步与异步测试。 - 输出:每个
test()以.表示通过,失败时会打印失败用例名、期望值与实际值;group()仅用于组织输出,不影响执行结果判定。
七、边界与延伸:从示例走向生产实践
结合本项目源码,可以总结出几条可直接迁移到真实项目的经验:
- 逻辑与 UI 分离是测试友好的前提:
FormValidator与TouristPlaces被独立成类,UI 只做薄封装调用,这正是"被测单元"能被轻量构造的关键。 - 空值、非法值、成功值三种输入必须齐备:从 formValidator_test.dart 可以看到,六条用例恰好覆盖两类校验的三种输入形态,任何缺失都意味着分支未被验证。
- 用可控的假数据源替代真实依赖:
PlacesAPI用Future.delayed模拟延迟、用内存列表替代网络响应,测试因此无需真实后端即可稳定复现;生产项目中通常使用 mock 库或依赖注入实现同样的隔离。 - null safety 下的返回类型要显式:
validateEmail/validatePassword以null表示"通过",在sdk: ">=2.12.0"的环境下,更严谨的签名应改为String?,让编译器参与空值管理。 - 测试要印证业务链路:登录校验、数据获取两个测试单元分别对应 sign_in_screen.dart 的表单验证与 home_screen.dart 的列表渲染,测试与使用方形成闭环,才是"可验证代码"的完整含义。
八、小结
flutter-examples 的 unit_testing 项目虽然小巧,却完整覆盖了 Flutter 单元测试的四个核心环节:环境配置(pubspec.yaml中的flutter_test)、可测代码设计(FormValidator、TouristPlaces)、同步断言(expect与isNull/字符串匹配器)、异步验证(async test+await+greaterThan/isNotNull)。对照 test/unit/formValidator_test.dart 与 test/unit/places_test.dart 逐行研读,再结合flutter test实际运行观察结果,即可快速建立"为函数、方法、类编写行为验证"的单元测试方法论,并将其迁移到自己的 Flutter 项目中。
- 示例工程
- 移动开发
【免费下载链接】flutter-examples
[Examples] Simple basic isolated apps, for budding flutter devs.
相关推荐
网络设备配置备份终极指南:为什么Oxidized是你的最佳选择?
网络设备配置备份终极指南:为什么Oxidized是你的最佳选择? 在复杂的网络环境中, 设备配置备份 是网络工程师的生命线。想象一下,当核心路由器突然宕机,你需
后端运维网络如何永久保存微信聊天记录:WeChatMsg数据主权完整指南
如何永久保存微信聊天记录:WeChatMsg数据主权完整指南 你是否曾因为手机丢失或更换而永远失去了珍贵的微信聊天记录?那些与家人朋友的温馨对话、重要的工作讨论
Flutter Provider测试策略:编写可靠的单元测试和集成测试
Flutter Provider测试策略:编写可靠的单元测试和集成测试 在Flutter应用开发中, Provider状态管理 已成为最受欢迎的选择之一。但要确
前端移动开发
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考