测试
简介
Nylo Website v7 包含一个受 Laravel 测试工具启发的全面测试框架。它提供:
- 测试函数,具有自动设置/拆卸功能(
nyTest、nyWidgetTest、nyGroup) - 通过
NyTest.actingAs<T>()进行身份验证模拟 - 时间穿越,用于在测试中冻结或操纵时间
- API 模拟,支持 URL 模式匹配和调用追踪
- 工厂,内置假数据生成器(
NyFaker) - 平台通道模拟,用于安全存储、路径提供者等
- 自定义断言,用于路由、Backpack、身份验证和环境
开始使用
在测试文件顶部初始化测试框架:
import 'package:nylo_framework/nylo_framework.dart';
void main() {
NyTest.init();
nyTest('my first test', () async {
expect(1 + 1, equals(2));
});
}
NyTest.init() 设置测试环境,并在 autoReset: true(默认值)时启用测试之间的自动状态重置。
编写测试
nyTest
编写测试的主要函数:
nyTest('can save and read from storage', () async {
backpackSave("name", "Anthony");
expect(backpackRead<String>("name"), equals("Anthony"));
});
选项:
nyTest('my test', () async {
// test body
}, skip: false, timeout: Timeout(Duration(seconds: 30)));
nyWidgetTest
用于使用 WidgetTester 测试 Flutter 组件:
nyWidgetTest('renders a button', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: ElevatedButton(
onPressed: () {},
child: Text("Tap me"),
),
),
));
expect(find.text("Tap me"), findsOneWidget);
});
Widget 测试工具
NyWidgetTest 类和 WidgetTester 扩展提供了辅助方法,用于以正确的主题支持泵送 Nylo Widget、等待 init() 完成以及测试加载状态。
配置测试环境
在 setUpAll 中调用 NyWidgetTest.configure() 以禁用 Google Fonts 获取,并可选地设置自定义主题:
nySetUpAll(() async {
NyWidgetTest.configure(testTheme: ThemeData.light());
await setupApplication(providers);
});
您可以使用 NyWidgetTest.reset() 重置配置。
两个内置主题可用于无字体测试:
ThemeData light = NyWidgetTest.simpleTestTheme;
ThemeData dark = NyWidgetTest.simpleDarkTestTheme;
泵送 Nylo Widget
使用 pumpNyWidget 将 Widget 包装在带有主题支持的 MaterialApp 中:
nyWidgetTest('renders page', (tester) async {
await tester.pumpNyWidget(
HomePage(),
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
themeMode: ThemeMode.light,
settleTimeout: Duration(seconds: 5),
useSimpleTheme: false,
);
expect(find.text('Welcome'), findsOneWidget);
});
使用无字体主题快速泵送:
await tester.pumpNyWidgetSimple(HomePage());
等待初始化
pumpNyWidgetAndWaitForInit 持续泵送帧直到加载指示器消失(或达到超时),适用于具有异步 init() 方法的页面:
await tester.pumpNyWidgetAndWaitForInit(
HomePage(),
timeout: Duration(seconds: 10),
useSimpleTheme: true,
);
// init() has completed
expect(find.text('Loaded Data'), findsOneWidget);
泵送辅助方法
// Pump frames until a specific widget appears
bool found = await tester.pumpUntilFound(
find.text('Welcome'),
timeout: Duration(seconds: 5),
);
// Settle gracefully (won't throw on timeout)
await tester.pumpAndSettleGracefully(timeout: Duration(seconds: 5));
生命周期模拟
在 Widget 树中的任何 NyPage 上模拟 AppLifecycleState 变化:
await tester.pumpNyWidget(MyPage());
await tester.simulateLifecycleState(AppLifecycleState.paused);
await tester.pump();
// Assert side effects of the paused lifecycle action
加载和锁定检查
检查 NyPage/NyState Widget 上的命名加载键和锁定:
// Check if a named loading key is active
bool loading = tester.isLoadingNamed(find.byType(MyPage), name: 'fetchUsers');
// Check if a named lock is held
bool locked = tester.isLockedNamed(find.byType(MyPage), name: 'submit');
// Check for any loading indicator (CircularProgressIndicator or Skeletonizer)
bool isAnyLoading = tester.isLoading();
testNyPage 辅助方法
一个便捷函数,泵送 NyPage,等待初始化完成,然后运行您的期望:
testNyPage(
'HomePage loads correctly',
build: () => HomePage(),
expectations: (tester) async {
expect(find.text('Welcome'), findsOneWidget);
},
useSimpleTheme: true,
initTimeout: Duration(seconds: 10),
skip: false,
);
testNyPageLoading 辅助方法
测试页面在 init() 期间是否显示加载指示器:
testNyPageLoading(
'HomePage shows loading state',
build: () => HomePage(),
skip: false,
);
NyPageTestMixin
提供常用页面测试工具的 mixin:
class HomePageTest with NyPageTestMixin {
void runTests(WidgetTester tester) async {
// Verify init was called and loading completed
await verifyInitCalled(tester, HomePage(), timeout: Duration(seconds: 5));
// Verify loading state is shown during init
await verifyLoadingState(tester, HomePage());
}
}
nyGroup
将相关测试分组在一起:
nyGroup('Authentication', () {
nyTest('can login', () async {
NyTest.actingAs<User>(User(name: "Anthony"));
expectAuthenticated<User>();
});
nyTest('can logout', () async {
NyTest.actingAs<User>(User(name: "Anthony"));
NyTest.logout();
expectGuest();
});
});
测试生命周期
使用生命周期钩子设置和拆卸逻辑:
void main() {
NyTest.init();
nySetUpAll(() {
// Runs once before all tests
});
nySetUp(() {
// Runs before each test
});
nyTearDown(() {
// Runs after each test
});
nyTearDownAll(() {
// Runs once after all tests
});
}
跳过测试和 CI 测试
// Skip a test with a reason
nySkip('not implemented yet', () async {
// ...
}, "Waiting for API update");
// Tests expected to fail
nyFailing('known bug', () async {
// ...
});
// CI-only tests (tagged with 'ci')
nyCi('integration test', () async {
// Only runs in CI environments
});
身份验证
在测试中模拟已认证用户:
nyTest('user can access profile', () async {
// Simulate a logged-in user
NyTest.actingAs<User>(User(name: "Anthony", email: "anthony@example.com"));
// Verify authenticated
expectAuthenticated<User>();
// Access the acting user
User? user = NyTest.actingUser<User>();
expect(user?.name, equals("Anthony"));
});
nyTest('guest cannot access profile', () async {
// Verify not authenticated
expectGuest();
});
登出用户:
NyTest.logout();
expectGuest();
时间穿越
使用 NyTime 在测试中操纵时间:
跳转到指定日期
nyTest('time travel to 2025', () async {
NyTest.travel(DateTime(2025, 1, 1));
expect(NyTime.now().year, equals(2025));
NyTest.travelBack(); // Reset to real time
});
前进或后退时间
NyTest.travelForward(Duration(days: 30)); // Jump 30 days ahead
NyTest.travelBackward(Duration(hours: 2)); // Go back 2 hours
冻结时间
NyTest.freezeTime(); // Freeze at the current moment
DateTime frozen = NyTime.now();
await Future.delayed(Duration(seconds: 1));
expect(NyTime.now(), equals(frozen)); // Time hasn't moved
NyTest.travelBack(); // Unfreeze
时间边界
NyTime.travelToStartOfDay(); // 00:00:00.000
NyTime.travelToEndOfDay(); // 23:59:59.999
NyTime.travelToStartOfMonth(); // 1st of current month
NyTime.travelToEndOfMonth(); // Last day of current month
NyTime.travelToStartOfYear(); // Jan 1st
NyTime.travelToEndOfYear(); // Dec 31st
作用域时间穿越
在冻结时间的上下文中执行代码:
await NyTime.withFrozenTime<void>(DateTime(2025, 6, 15), () async {
expect(NyTime.now(), equals(DateTime(2025, 6, 15)));
});
// Time is automatically restored after the callback
API 模拟
按 URL 模式模拟
使用支持通配符的 URL 模式模拟 API 响应:
nyTest('mock API responses', () async {
// Exact URL match
NyMockApi.respond('/users/1', {'id': 1, 'name': 'Anthony'});
// Single segment wildcard (*)
NyMockApi.respond('/users/*', {'id': 1, 'name': 'User'});
// Multi-segment wildcard (**)
NyMockApi.respond('/api/**', {'status': 'ok'});
// With status code and headers
NyMockApi.respond(
'/users',
{'error': 'Unauthorized'},
statusCode: 401,
method: 'POST',
headers: {'X-Error': 'true'},
);
// With simulated delay
NyMockApi.respond(
'/slow-endpoint',
{'data': 'loaded'},
delay: Duration(seconds: 2),
);
});
按 API 服务类型模拟
按类型模拟整个 API 服务:
nyTest('mock API service', () async {
NyMockApi.register<UserApiService>((MockApiRequest request) async {
if (request.endpoint.contains('/users')) {
return {'users': [{'id': 1, 'name': 'Anthony'}]};
}
return {'error': 'not found'};
});
});
调用历史和断言
追踪和验证 API 调用:
nyTest('verify API was called', () async {
NyMockApi.setRecordCalls(true);
// ... perform actions that trigger API calls ...
// Assert endpoint was called
expectApiCalled('/users');
// Assert endpoint was not called
expectApiNotCalled('/admin');
// Assert call count
expectApiCalled('/users', times: 2);
// Assert specific method
expectApiCalled('/users', method: 'POST');
// Get call details
List<ApiCallInfo> calls = NyMockApi.getCallsFor('/users');
});
创建模拟响应
Response<Map<String, dynamic>> response = NyMockApi.createResponse(
data: {'id': 1, 'name': 'Anthony'},
statusCode: 200,
statusMessage: 'OK',
);
工厂
定义工厂
定义如何创建模型的测试实例:
NyFactory.define<User>((NyFaker faker) => User(
name: faker.name(),
email: faker.email(),
phone: faker.phone(),
));
支持覆盖:
NyFactory.defineWithOverrides<User>((NyFaker faker, Map<String, dynamic> attributes) => User(
name: attributes['name'] ?? faker.name(),
email: attributes['email'] ?? faker.email(),
phone: attributes['phone'] ?? faker.phone(),
));
工厂状态
定义工厂的变体:
NyFactory.state<User>('admin', (User user, NyFaker faker) {
return User(name: user.name, email: user.email, role: 'admin');
});
NyFactory.state<User>('premium', (User user, NyFaker faker) {
return User(name: user.name, email: user.email, subscription: 'premium');
});
创建实例
// Create a single instance
User user = NyFactory.make<User>();
// Create with overrides
User admin = NyFactory.make<User>(overrides: {'name': 'Admin User'});
// Create with states applied
User premiumAdmin = NyFactory.make<User>(states: ['admin', 'premium']);
// Create multiple instances
List<User> users = NyFactory.create<User>(count: 5);
// Create a sequence with index-based data
List<User> numbered = NyFactory.sequence<User>(3, (int index, NyFaker faker) {
return User(name: "User ${index + 1}", email: faker.email());
});
NyFaker
NyFaker 为测试生成逼真的假数据。它在工厂定义中可用,也可以直接实例化。
NyFaker faker = NyFaker();
可用方法
| 分类 | 方法 | 返回类型 | 描述 |
|---|---|---|---|
| 姓名 | faker.firstName() |
String |
随机名字 |
faker.lastName() |
String |
随机姓氏 | |
faker.name() |
String |
全名(名字 + 姓氏) | |
faker.username() |
String |
用户名字符串 | |
| 联系方式 | faker.email() |
String |
电子邮箱地址 |
faker.phone() |
String |
电话号码 | |
faker.company() |
String |
公司名称 | |
| 数字 | faker.randomInt(min, max) |
int |
范围内的随机整数 |
faker.randomDouble(min, max) |
double |
范围内的随机浮点数 | |
faker.randomBool() |
bool |
随机布尔值 | |
| 标识符 | faker.uuid() |
String |
UUID v4 字符串 |
| 日期 | faker.date() |
DateTime |
随机日期 |
faker.pastDate() |
DateTime |
过去的日期 | |
faker.futureDate() |
DateTime |
未来的日期 | |
| 文本 | faker.lorem() |
String |
Lorem ipsum 文字 |
faker.sentences() |
String |
多个句子 | |
faker.paragraphs() |
String |
多个段落 | |
faker.slug() |
String |
URL slug | |
| 网络 | faker.url() |
String |
URL 字符串 |
faker.imageUrl() |
String |
图片 URL(通过 picsum.photos) | |
faker.ipAddress() |
String |
IPv4 地址 | |
faker.macAddress() |
String |
MAC 地址 | |
| 位置 | faker.address() |
String |
街道地址 |
faker.city() |
String |
城市名称 | |
faker.state() |
String |
美国州缩写 | |
faker.zipCode() |
String |
邮政编码 | |
faker.country() |
String |
国家名称 | |
| 其他 | faker.hexColor() |
String |
十六进制颜色代码 |
faker.creditCardNumber() |
String |
信用卡号码 | |
faker.randomElement(list) |
T |
从列表中随机选取一项 | |
faker.randomElements(list, count) |
List<T> |
从列表中随机选取多项 |
测试缓存
NyTestCache 提供内存缓存,用于测试缓存相关功能:
nyTest('cache operations', () async {
NyTestCache cache = NyTest.cache;
// Store a value
await cache.put<String>("key", "value");
// Store with expiration
await cache.put<String>("temp", "data", seconds: 60);
// Read a value
String? value = await cache.get<String>("key");
// Check existence
bool exists = await cache.has("key");
// Clear a key
await cache.clear("key");
// Flush all
await cache.flush();
// Get cache info
int size = await cache.size();
List<String> keys = await cache.documents();
});
平台通道模拟
NyMockChannels 自动模拟常见的平台通道,防止测试崩溃:
void main() {
NyTest.init(); // Automatically sets up mock channels
// Or set up manually
NyMockChannels.setup();
}
已模拟的通道
- path_provider -- 文档、临时、应用支持、库和缓存目录
- flutter_secure_storage -- 内存安全存储
- flutter_timezone -- 时区数据
- flutter_local_notifications -- 通知通道
- sqflite -- 数据库操作
覆盖路径
NyMockChannels.overridePathProvider(
documentsPath: '/custom/documents',
temporaryPath: '/custom/temp',
);
测试中的安全存储
NyMockChannels.setSecureStorageValue("token", "test_abc123");
Map<String, String> storage = NyMockChannels.getSecureStorage();
NyMockChannels.clearSecureStorage();
路由守卫模拟
NyMockRouteGuard 允许您在没有真实身份验证或网络调用的情况下测试路由守卫行为。它扩展了 NyRouteGuard 并为常见场景提供工厂构造函数。
始终通过的守卫
final guard = NyMockRouteGuard.pass();
重定向的守卫
final guard = NyMockRouteGuard.redirect('/login');
// With additional data
final guard = NyMockRouteGuard.redirect('/error', data: {'code': 403});
自定义逻辑的守卫
final guard = NyMockRouteGuard.custom((context) async {
if (context.data == null) {
return GuardResult.handled; // abort navigation
}
return GuardResult.next; // allow navigation
});
跟踪守卫调用
守卫被调用后,您可以检查其状态:
expect(guard.wasCalled, isTrue);
expect(guard.callCount, 1);
// Access the RouteContext from the last call
RouteContext? context = guard.lastContext;
// Reset tracking
guard.reset();
断言
Nylo Website 提供自定义断言函数:
路由断言
expectRoute('/home'); // Assert current route
expectNotRoute('/login'); // Assert not on route
expectRouteInHistory('/home'); // Assert route was visited
expectRouteExists('/profile'); // Assert route is registered
expectRoutesExist(['/home', '/profile', '/settings']);
状态断言
expectBackpackContains("key"); // Key exists
expectBackpackContains("key", value: "expected"); // Key has value
expectBackpackNotContains("key"); // Key doesn't exist
身份验证断言
expectAuthenticated<User>(); // User is authenticated
expectGuest(); // No user authenticated
环境断言
expectEnv("APP_NAME", "MyApp"); // Env variable equals value
expectEnvSet("APP_KEY"); // Env variable is set
模式断言
expectTestMode();
expectDebugMode();
expectProductionMode();
expectDevelopingMode();
API 断言
expectApiCalled('/users');
expectApiCalled('/users', method: 'POST', times: 2);
expectApiNotCalled('/admin');
语言环境断言
expectLocale("en");
Toast 断言
验证测试期间记录的 Toast 通知。需要在测试 setUp 中调用 NyToastRecorder.setup():
setUp(() {
NyToastRecorder.setup();
});
nyWidgetTest('shows success toast', (tester) async {
await tester.pumpNyWidget(MyPage());
// ... trigger action that shows a toast ...
expectToastShown(id: 'success');
expectToastShown(id: 'danger', description: 'Something went wrong');
expectNoToastShown(id: 'info');
});
NyToastRecorder 在测试期间跟踪 Toast 通知:
// Record a toast manually
NyToastRecorder.record(id: 'success', title: 'Done', description: 'Saved!');
// Check if a toast was shown
bool shown = NyToastRecorder.wasShown(id: 'success');
// Access all recorded toasts
List<ToastRecord> toasts = NyToastRecorder.records;
// Clear recorded toasts
NyToastRecorder.clear();
锁定和加载断言
验证 NyPage/NyState Widget 中的命名锁定和加载状态:
// Assert a named lock is held
expectLocked(tester, find.byType(MyPage), 'submit');
// Assert a named lock is not held
expectNotLocked(tester, find.byType(MyPage), 'submit');
// Assert a named loading key is active
expectLoadingNamed(tester, find.byType(MyPage), 'fetchUsers');
// Assert a named loading key is not active
expectNotLoadingNamed(tester, find.byType(MyPage), 'fetchUsers');
自定义匹配器
使用自定义匹配器配合 expect():
// Type matcher
expect(result, isType<User>());
// Route name matcher
expect(widget, hasRouteName('/home'));
// Backpack matcher
expect(true, backpackHas("key", value: "expected"));
// API call matcher
expect(true, apiWasCalled('/users', method: 'GET', times: 1));
状态测试
使用状态测试辅助方法测试 NyPage 和 NyState Widget 中基于 EventBus 的状态管理。
触发状态更新
模拟通常来自其他 Widget 或控制器的状态更新:
// Fire an UpdateState event
fireStateUpdate('HomePageState', data: {'items': ['a', 'b']});
await tester.pump();
expect(find.text('a'), findsOneWidget);
触发状态操作
发送由页面中 whenStateAction() 处理的状态操作:
fireStateAction('HomePageState', 'refresh-page');
await tester.pump();
// With additional data
fireStateAction('CartState', 'add-item', data: {'id': 42});
await tester.pump();
状态断言
// Assert a state update was fired
expectStateUpdated('HomePageState');
expectStateUpdated('HomePageState', times: 2);
// Assert a state action was fired
expectStateAction('HomePageState', 'refresh-page');
expectStateAction('CartState', 'add-item', times: 1);
// Assert on the stateData of a NyPage/NyState widget
expectStateData(tester, find.byType(MyWidget), equals(42));
NyStateTestHelpers
跟踪和检查触发的状态更新和操作:
// Get all updates fired to a state
List updates = NyStateTestHelpers.getUpdatesFor('MyWidget');
// Get all actions fired to a state
List actions = NyStateTestHelpers.getActionsFor('MyWidget');
// Reset all tracked state updates and actions
NyStateTestHelpers.reset();
调试
dump
打印当前测试状态(Backpack 内容、认证用户、时间、API 调用、语言环境):
NyTest.dump();
dd(打印并终止)
打印测试状态并立即终止测试:
NyTest.dd();
测试状态存储
在测试期间存储和检索值:
NyTest.set("step", "completed");
String? step = NyTest.get<String>("step");
预填充 Backpack
使用测试数据预填充 Backpack:
NyTest.seedBackpack({
"user_name": "Anthony",
"auth_token": "test_token",
"settings": {"theme": "dark"},
});
示例
完整测试文件
import 'package:flutter_test/flutter_test.dart';
import 'package:nylo_framework/nylo_framework.dart';
void main() {
NyTest.init();
nyGroup('User Authentication', () {
nyTest('can authenticate a user', () async {
NyFactory.define<User>((faker) => User(
name: faker.name(),
email: faker.email(),
));
User user = NyFactory.make<User>();
NyTest.actingAs<User>(user);
expectAuthenticated<User>();
});
nyTest('guest has no access', () async {
expectGuest();
});
});
nyGroup('API Integration', () {
nyTest('can fetch users', () async {
NyMockApi.setRecordCalls(true);
NyMockApi.respond('/api/users', {
'users': [
{'id': 1, 'name': 'Anthony'},
{'id': 2, 'name': 'Jane'},
]
});
// ... trigger API call ...
expectApiCalled('/api/users');
});
});
nyGroup('Time-Sensitive Features', () {
nyTest('subscription expires correctly', () async {
NyTest.travel(DateTime(2025, 1, 1));
// Test subscription logic at a known date
expect(NyTime.now().year, equals(2025));
NyTest.travelForward(Duration(days: 365));
expect(NyTime.now().year, equals(2026));
NyTest.travelBack();
});
});
}