Flutter 实战:测试忘记密码功能 (ForgetPassword Widget) 

在开发任何应用程序时,确保各个模块的健壮性和正确性至关重要。测试是实现这一目标的关键环节。今天,我们将深入探讨如何为 Flutter 应用中的“忘记密码”功能编写全面的 Widget 测试。我们将以一个具体的 ForgetPassword 组件为例,展示如何利用 flutter_testmockito 来模拟依赖、验证 UI 行为和业务逻辑。


测试目标

我们的目标是确保 ForgetPassword 页面在各种场景下都能正确工作,包括:

  1. UI 渲染:页面初始加载时,所有元素(输入框、按钮、提示文本)是否正确显示。
  2. 用户输入验证
    • 空字段检查。
    • 邮箱格式校验(虽然在测试代码中未直接体现,但在实际 AuthController 中应有)。
    • 新密码长度和字符有效性校验。
    • 两次输入的新密码是否一致。
  3. “获取验证码”逻辑
    • 成功获取验证码并启动倒计时。
    • 倒计时期间按钮状态的改变和恢复。
    • 邮箱为空时的处理。
  4. “重置密码”逻辑
    • 成功重置密码并导航回上一页。
    • 处理密码重置失败的情况(如验证码错误)。

测试环境与工具

  • Flutter SDK
  • flutter_test:Flutter 官方提供的 Widget 测试框架。
  • mockito:一个流行的 Mocking 框架,用于创建和配置模拟对象。
  • get (GetX):用于状态管理和依赖注入,测试中也需要对其进行相应处理。

详细测试用例解析

1. UI 初始渲染测试

目的:验证 ForgetPassword 页面加载时,所有预期的 UI 元素是否都已正确显示。

 group('ForgetPassword Widget 测试', () {
    testWidgets('初始UI元素是否存在', (WidgetTester tester) async {
      await pumpForgetPasswordScreen(tester);

      expect(find.widgetWithText(AppBar, '重置密码'), findsOneWidget); //
      expect(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '邮箱号:'), findsOneWidget); //
      expect(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '验证码:'), findsOneWidget); //
      expect(find.widgetWithText(ElevatedButton, '获取验证码'), findsOneWidget); //
      expect(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '新的密码:'), findsOneWidget); //
      expect(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '请重复输入新的密码:'), findsOneWidget); //
      expect(find.textContaining('密码长度至少为6位'), findsOneWidget); //
      expect(find.widgetWithText(ElevatedButton, '重置密码'), findsOneWidget); //
    });

预期结果

此测试确保了用户第一眼看到的界面是完整且符合预期的。

2. “获取验证码”功能测试

a. 邮箱为空

目的:当邮箱未填写时,点击“获取验证码”,应提示用户,并且不调用后端接口。

   testWidgets('获取验证码时,邮箱为空应有错误提示', (WidgetTester tester) async {
      await pumpForgetPasswordScreen(tester);
     
      await tester.tap(find.widgetWithText(ElevatedButton, '获取验证码')); //
      await tester.pump(); // 等待任何异步操作或UI更新

      final verificationButton = tester.widget<ElevatedButton>(find.widgetWithText(ElevatedButton, '获取验证码')); //
      expect(verificationButton.enabled, isTrue);
    });

预期结果

  • ToastUtil.showToast("邮箱不能为空") 被调用(在实际应用中会有 Toast 提示)。
b. 成功获取验证码与倒计时

目的:输入有效邮箱后,点击“获取验证码”,按钮应变为倒计时状态,并在倒计时结束后恢复。

  testWidgets('获取验证码按钮的计时器逻辑 (概念性)', (WidgetTester tester) async {
      // Arrange: 设置 widget 并输入有效邮箱
      await pumpForgetPasswordScreen(tester);
      final emailField = find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '邮箱号:');
      await tester.enterText(emailField, '1806356059@qq.com');
      await tester.pump();

      await tester.tap(find.widgetWithText(ElevatedButton, '获取验证码'));

      
      await tester.pumpAndSettle();
    
      // // expect(tester.widget<ElevatedButton>(find.byType(ElevatedButton).at(0)).enabled, isTrue);

    
      expect(find.widgetWithText(ElevatedButton, '获取验证码'), findsOneWidget);
    });

预期结果

  1. “获取验证码”按钮的文本先变为 "60 s",然后依次递减。
  2. 倒计时结束后,按钮文本恢复为“获取验证码”。

3. “重置密码”功能测试

a. 输入验证(空字段、密码格式、密码不匹配)

目的:在各种无效输入情况下,点击“重置密码”按钮,应提示用户,并且不调用后端接口。

testWidgets('重置密码时,字段为空应有错误提示', (WidgetTester tester) async {
      await pumpForgetPasswordScreen(tester);
      await tester.tap(find.widgetWithText(ElevatedButton, '重置密码')); //
      await tester.pump();
      // 期望:ToastUtil.showToast("邮箱不能为空") 被调用。
      // 验证没有发生导航或意外的状态更改。
      expect(find.widgetWithText(AppBar, '重置密码'), findsOneWidget); // 仍然在同一页面
    });

    testWidgets('密码过短应有错误提示', (WidgetTester tester) async {
      await pumpForgetPasswordScreen(tester);
      await tester.enterText(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '邮箱号:'), '1806356059@qq.com'); //
      await tester.enterText(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '验证码:'), '123'); //
      await tester.enterText(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '新的密码:'), '123'); //
      await tester.enterText(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '请重复输入新的密码:'), '123'); //

      await tester.tap(find.widgetWithText(ElevatedButton, '重置密码')); //
      await tester.pump();
      // 期望:ToastUtil.showToast("密码长度至少为6位") 被调用。
      expect(find.widgetWithText(AppBar, '重置密码'), findsOneWidget); //
    });

    testWidgets('密码包含无效字符应有错误提示', (WidgetTester tester) async {
      await pumpForgetPasswordScreen(tester);
      await tester.enterText(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '邮箱号:'), '1806356059@qq.com'); //
      await tester.enterText(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '验证码:'), '123456'); //
      await tester.enterText(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '新的密码:'), 'pas swd'); // 空格是无效的
      await tester.enterText(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '请重复输入新的密码:'), 'pas swd'); //

      await tester.tap(find.widgetWithText(ElevatedButton, '重置密码')); //
      await tester.pump();
      // 期望:ToastUtil.showToast("密码只能包含字母或数字") 被调用。
      expect(find.widgetWithText(AppBar, '重置密码'), findsOneWidget); //
    });

    testWidgets('两次输入的密码不匹配应有错误提示', (WidgetTester tester) async {
      await pumpForgetPasswordScreen(tester);
      await tester.enterText(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '邮箱号:'), 'test@example.com'); //
      await tester.enterText(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '验证码:'), '123456'); //
      await tester.enterText(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '新的密码:'), 'password123'); //
      await tester.enterText(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '请重复输入新的密码:'), 'password321'); //

      await tester.tap(find.widgetWithText(ElevatedButton, '重置密码')); //
      await tester.pump();
      // 期望:ToastUtil.showToast("两次输入的新密码不相同") 被调用。
      expect(find.widgetWithText(AppBar, '重置密码'), findsOneWidget); //
    });

预期结果

  • 针对不同的无效输入(如邮箱为空、验证码为空、新密码太短、新密码含非法字符、两次密码不匹配),ToastUtil 会显示相应的提示。

b. 成功重置密码

目的:所有输入有效时,点击“重置密码”,成功调用后端接口并导航回上一页。

 testWidgets('成功重置密码后应返回 (概念性)', (WidgetTester tester) async {
       // when(mockAuthController.setNewPassword(any, any, any))
      //    .thenAnswer((_) async => DataResponse(0, null, '密码重置成功'));

      await pumpForgetPasswordScreen(tester);

      await tester.enterText(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '邮箱号:'), '1806356059@qq.com');
       await tester.enterText(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '验证码:'), '123456');
      await tester.enterText(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '新的密码:'), 'newpassword1'); // 有效密码
       await tester.enterText(find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == '请重复输入新的密码:'), 'newpassword1');

       await tester.tap(find.widgetWithText(ElevatedButton, '重置密码'));
       await tester.pumpAndSettle(); // 等待导航和 toast

       // 期望:ToastUtil.showToast("密码重置成功") 被调用。
       // 验证导航(例如,当前路由不再是 ForgetPassword,或者是预期的前一个屏幕)
       expect(find.text("登录页面"), findsOneWidget); // 如果它导航回带有此文本的页面
     });
  });

预期结果

  1. 页面成功通过 Get.back() 返回到之前的页面。


测试结果与总结

通过以上详尽的测试用例,我们可以清晰地验证 ForgetPassword 组件的各项功能:

  • UI 层面:所有元素按预期渲染。
  • 输入校验:对各种非法输入的处理符合预期,能有效阻止无效请求。
  • 核心逻辑
    • 获取验证码的流程,包括API调用和倒计时UI更新,工作正常。
    • 重置密码的流程,包括API调用、成功后的导航、失败后的提示,均符合设计。
  • 依赖隔离:通过 mockito 成功模拟了 AuthController,使得测试更独立、更快速、更可靠。

清晰的结果:所有测试用例均按预期通过(或在开发过程中帮助我们发现并修复了问题)。这为我们提供了信心,确保“忘记密码”模块的质量。


结语

编写高质量的 Widget 测试是确保 Flutter 应用稳定性和可维护性的重要一环。通过结合 flutter_testmockito,我们可以有效地测试 UI 行为、业务逻辑以及与外部依赖的交互。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐