[Flutter翻译]用Mockingjay模拟Flutter路由

325 阅读12分钟

image.png

为什么以及如何在测试Flutter代码时对路由进行模拟?

原文地址:verygood.ventures/blog/mockin…

原文作者;verygood.ventures/team/jeroen…

发布时间:2021年8月18日

测试你的应用程序的代码是很重要的。在Very Good Ventures,这种情绪从一开始就伴随着我们,我们已经多次解释过我们的测试方法。我们使用无数的指标和方法来给我们一定的信心,以确保我们编写的代码能够按照我们想要的方式工作--不仅仅是现在,尤其是当我们需要在未来回过头来修改某个特定功能的时候。我们希望确保我们能提前发现我们可以预防的错误,在它们进入应用商店之前,或者更好的是,在它们被合并到我们的代码库之前。

多年来,我们已经建立并测试了Flutter开发的多个方面的测试方法;我们使用bloc_test来保证我们的bloc中的逻辑按预期工作,而mocktail包帮助我们模拟任何与API、存储库和数据模型相关的东西。

然而,长期以来,有一个方面一直被证明比其他方面更难测试,那就是导航路线。我们想确保我们的应用程序中的按钮和逻辑都能指向正确的屏幕和对话框--事实上,我们100%的代码覆盖率要求迫使我们测试代码库中的每一行可执行文件,包括导航调用。然而,在测试导航路线时,会出现一些有趣的问题。

好消息! 我们已经建立了新发布的🕊Mockingjay包,正是为了帮助解决这个问题。在这篇文章中,我们将介绍我们在Flutter应用中测试路由时发现的问题,以及Mockingjay如何帮助我们更容易地测试和验证任何路由逻辑。

注意,这篇文章假设你已经知道了bloc状态管理模式和Flutter测试的基本知识。如果你想了解这些主题,我们建议你看看这些文章。Flutter测试。一个非常好的指南为什么我们使用flutter_bloc进行状态管理

今天的测试路线

对我们来说,在Flutter应用程序中测试UI是有多个目标的。在其他方面,小部件测试应该是。

  • 原子的;它应该只测试测试的应用程序的一部分,并模拟任何外部的依赖性。
  • 详尽的;我们应该能够很容易地测试UI的所有部分,包括在给定的状态下哪些部件被渲染,以及在按下任何按钮时发生什么动作。
  • 可重复性;我们希望有一个一致的方法来构建用户界面本身以及我们为其编写的测试。

一个例子

如前所述,小工具测试的大多数方面已经有了我们所遵循的完善的模式。例如,以一个聊天程序为例,它有一个可以与之聊天的朋友列表的页面。这个聊天概述页面的右上方可能有一个按钮,会带你到你的个人资料设置。正如我们在每个项目中所做的那样,使用blocs和repositories模式,这个ChatOverviewPage的代码可能看起来是这样的。

class ChatOverviewPage extends StatelessWidget {
  const ChatOverviewPage({Key? key}) : super(key: key);
  static Route route() {
    return MaterialPageRoute(
      builder: (context) {
        return BlocProvider(
          create: (context) => ChatBloc(
            chatRepository: context.read<ChatRepository>(),
          ),
          child: const ChatOverviewPage(),
        );
      },
    );
  }
  @override
  Widget build(BuildContext context) {
    final state = context.watch<ChatBloc>().state;
    return Scaffold(
      appBar: AppBar(
        title: const Text('Chats'),
        actions: [
          IconButton(
            onPressed: () {
              Navigator.of(context).push(UserProfileSettingsPage.route());
            },
            icon: const Icon(Icons.settings),
          ),
        ],
      ),
      body: ListView(
        children: [
          for (final conversation in state.conversations)
            ChatConversationListTile(
              conversation: conversation,
            ),
        ],
      ),
    );
  }
}

关于这个屏幕,有几件事需要注意。正如你所看到的,ChatOverviewPage只依赖ChatBloc,而ChatBloc又依赖ChatRepository。这是我们在整个代码库中看到的常见模式,有助于创建几乎可以扩展到任何规模和复杂性的应用程序。这意味着,在我们的测试中,我们可以在测试路由()方法时简单地模拟ChatRepository。对于UI的其余部分,我们所要做的就是提供一个ChatBloc的模拟实例。这样一来,这个屏幕上的所有依赖都被模拟了,屏幕的每一个方面都可以被测试!此外,一个按钮显示在屏幕上。

此外,在AppBar中显示了一个按钮,它的路径是UserProfileSettingsPage。让我们来看看它的源代码是什么样子的。

class UserProfileSettingsPage extends StatelessWidget {
  const UserProfileSettingsPage({Key? key}) : super(key: key);
  static Route route() {
    return MaterialPageRoute(
      builder: (context) {
        return BlocProvider(
          create: (context) => UserSettingsBloc(
            userSettingsRepository: context.read<UserSettingsRepository>(),
          ),
          child: const UserProfileSettingsPage(),
        );
      },
    );
  }
  @override
  Widget build(BuildContext context) {
    final state = context.watch<UserSettingsBloc>().state;
    return Scaffold(
      appBar: AppBar(
        title: const Text('Profile Settings'),
      ),
      body: ListView(
        children: [
          ListTile(
            title: const Text('Status'),
            subtitle: Text(state.settings.isOnline ? 'Online' : 'Offline'),
          ),
          ListTile(
            title: const Text('Phone number'),
            subtitle: Text(state.settings.phoneNumber),
          ),
          ListTile(
            title: const Text('Email address'),
            subtitle: Text(state.settings.emailAddress),
          ),
        ],
      ),
    );
  }
}

与ChatOverviewPage类似,这个UserProfileSettingsPage只依赖一个UserSettingsBloc,而这个Bloc又依赖一个UserSettingsRepository。这个屏幕的测试设置将与ChatOverviewPage的测试设置几乎相同;可以为存储库和Bloc做一个模拟,我们可以分别测试路由()和构建()方法。

让我们开始测试吧

让我们看看ChatOverviewPage的测试文件是什么样子的,首先是一些一般的设置。我们首先为我们的资源库和块声明一些模拟,创建一些假的数据来驱动用户界面,并确保在用户界面试图获取最近的块时这些数据是可见的。

// import '...';
import 'package:mocktail/mocktail.dart';
class MockChatRepository extends Mock implements ChatRepository {}
class MockChatBloc extends MockBloc<ChatEvent, ChatState> implements ChatBloc {}
void main() {
  group('ChatOverviewPage', () {
    late ChatRepository chatRepository;
    late ChatBloc chatBloc;
    const conversations = [
      Conversation(
        chatName: 'mock-conversation-1',
        unreadMessages: 0,
      ),
      Conversation(
        chatName: 'mock-conversation-2',
        unreadMessages: 5,
      ),
      Conversation(
        chatName: 'mock-conversation-3',
        unreadMessages: 10,
      ),
    ];
    setUpAll(() {
      registerFallbackValue(const ChatState());
      registerFallbackValue(const ChatEvent());
    });
    setUp(() {
      chatRepository = MockChatRepository();
      when(() => chatRepository.getConversations()).thenAnswer((_) async => []);
      chatBloc = MockChatBloc();
      when(() => chatBloc.state).thenReturn(const ChatState(
        conversations: conversations,
      ));
    });
    // ...
  });
  // ...
}

现在我们都准备好了,让我们为route()方法创建一个简单的测试。

testWidgets('route method creates route normally', (tester) async {
  await tester.pumpWidget(
    RepositoryProvider.value(
      value: chatRepository,
      child: MaterialApp(
        onGenerateRoute: (_) => ChatOverviewPage.route(),
      ),
    ),
  );
  expect(find.byType(ChatOverviewPage), findsOneWidget);
  expect(tester.takeException(), isNull);
});

看起来很好! 我们提供了一个ChatRepository的模拟实例,对MaterialApp内部Navigator的一些巧妙使用使我们可以调用route()方法来确保它的功能符合预期。一个ChatOverviewPage被渲染出来,在创建ChatBloc的过程中没有发生任何异常。

接下来是实际视图中存在的ChatConversationListTiles的列表,每个对话都应该渲染其中一个。由于我们已经通过测试route()方法确保了ChatBloc可以成功构建,我们可以简单地提供一个模拟的ChatBloc用于我们所有剩下的测试。

testWidgets(
  'renders a ChatConversationListTile for each conversation',
  (tester) async {
    await tester.pumpWidget(
      BlocProvider.value(
        value: chatBloc,
        child: const MaterialApp(
          home: ChatOverviewPage(),
        ),
      ),
    );
    expect(find.byType(ChatConversationListTile), findsNWidgets(3));
  },
);

简单,但有效。在我们的设置中,我们确保chatBloc会返回一个有三个对话对象的ChatState,所以这就是我们在计算ChatConversationListTile呈现的数量时期望在UI中找到的东西。

现在,让我们测试一下配置文件设置按钮,以确保它的路径正确。

testWidgets(
  'routes to UserProfileSettingsPage when settings button is pressed',
  (tester) async {
    await tester.pumpWidget(
      BlocProvider.value(
        value: chatBloc,
        child: const MaterialApp(
          home: ChatOverviewPage(),
        ),
      ),
    );
    await tester.tap(find.byIcon(Icons.settings));
    await tester.pumpAndSettle();
    expect(find.byType(UserProfileSettingsPage), findsOneWidget);
  },
);

很好! 让我们试一试......

══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
The following ProviderNotFoundException was thrown building UserProfileSettingsPage(dirty,
dependencies: [_InheritedProviderScope<UserSettingsBloc>]):
Error: Could not find the correct Provider<UserSettingsRepository>=> above this
_InheritedProviderScope<UserSettingsBloc> Widget
This happens because you used a `BuildContext` that does not include the provider
of your choice.

哦,孩子,这是个问题。

为什么这种方法会失败

也许你已经在我们最后的测试中发现了问题所在。如前所述,我们的ChatOverviewPage依赖于ChatBloc和ChatRepository,如我们之前的测试所示,我们已经成功地模拟了它们。然而,在编写测试时,我们忘记了UserProfileSettingsPage也有两个依赖项,那就是UserSettingsBloc和UserSettingsRepository。在我们的最后一个测试中,我们按下按钮,执行UserProfileSettingsPage.route()方法。这个方法需要一个UserSettingsRepository来构建UserSettingsBloc。这意味着,只有在这个特定的测试中,除了与聊天相关的依赖,我们还需要提供一个模拟的UserSettingsRepository。这将使测试看起来像这样。

// ...
class MockUserSettingsRepository
  extends Mock
  implements UserSettingsRepository {}
void main() {
  group('ChatOverviewPage', () {
    // ...
  late UserSettingsRepository userSettingsRepository;
    // ...
    setUp(() {
      // ...
      userSettingsRepository = MockUserSettingsRepository();
      when(() => userSettingsRepository.getSettings())
        .thenAnswer((_) async => const Settings());
    });
    // ...
    testWidgets(
      'routes to UserProfileSettingsPage when settings button is pressed',
      (tester) async {
        await tester.pumpWidget(
          BlocProvider.value(
            value: chatBloc,
            child: RepositoryProvider.value(
              value: userSettingsRepository,
              child: const MaterialApp(
                home: ChatOverviewPage(),
              ),
            ),
          ),
        );
        await tester.tap(find.byIcon(Icons.settings));
        await tester.pumpAndSettle();
        expect(find.byType(UserProfileSettingsPage), findsOneWidget);
      },
    );
  });
}

为什么这是个糟糕的解决方案

虽然上面的测试可以正常工作,但我认为不言而喻,由于多种原因,这不是理想的方法。

  1. 专门用于验证ChatOverviewPage功能的测试不再是原子性的,因为我们把与UserProfileSettingsPage相关的依赖性带到了这个文件中。这增加了文件中的整体噪音,增加了行数,并给测试逻辑带来了它不应该得到的新责任。
  2. 尽管这种测试方法在某种意义上是可重复的,但由于它迫使我们在每一个与UI相关的测试中加入上述所有的模板,而这些测试都可以路由到UserProfileSettingsPage,所以简单性大大降低。换句话说,如果有三个页面可以路由到该屏幕,那么除了UserProfileSettingsPage测试本身的文件外,我们还需要在三个不同的测试中重复这一逻辑,每次都会增加我们测试的噪音量。

如果有一个方法可以防止这一切......

嘲笑鸟来拯救 🎉

介绍一下🕊 Mockingjay,一个来自Very Good Ventures的软件包,可以帮助你测试Flutter应用中的任何类型的路由。这个新发布的完全无效的软件包(完全开源)使用mocktail,允许你以模拟任何模型或块的方式轻松地模拟一个Navigator,使测试方法明显更干净、更简单。最好的部分之一是你的UI代码不需要改变;它可以作为一个开发依赖,只与你的测试文件有关。

让我们试试吧

开箱后,Mockingjay给你一个MockNavigator类。这是NavigatorState的一个子类,它给你模拟和验证任何你通常使用Navigator.of(context)调用的方法的能力,例如push、pop和replace。这使得我们可以完全省略添加额外的设置和逻辑来模拟那些与我们正在测试的UI位无关的依赖关系。

让我们拿我们之前的测试,对设置做一些补充。Mockingjay包括mocktail包,所以我们可以简单地替换导入语句,使我们的测试更小一些。此外,我们可以创建一个模拟的导航器实例,并给它一些默认行为。

// import '...';
import 'package:mockingjay/mockingjay.dart';
// ...
void main() {
  group('ChatOverviewPage', () {
    late ChatRepository chatRepository;
    late ChatBloc chatBloc;
    late MockNavigator navigator;
    // ...
    setUp(() {
      // ...
      navigator = MockNavigator();
      // When calling `Navigator.of(context).push(...)`, return void by default.
      when(() => navigator.push(any())).thenAnswer((_) async {});
    });
    // ...
  });
  // ...
}

现在我们都准备好了,我们可以在测试中使用MockNavigatorProvider来为widget树的任何部分提供这个模拟的导航器,让我们可以模拟任何导航调用。

确保你的MockNavigatorProvider被声明在任何MaterialApp或Navigator部件的下面,以确保UI找到MockNavigator而不是真正的NavigatorState。

testWidgets(
  'routes to UserProfileSettingsPage when settings button is pressed',
  (tester) async {
    await tester.pumpWidget(
      BlocProvider.value(
        value: chatBloc,
        child: MaterialApp(
          // Provide the mock navigator to any widget below
          // this point in the widget tree.
          home: MockNavigatorProvider(
            navigator: navigator,
            child: const ChatOverviewPage(),
          ),
        ),
      ),
    );
    await tester.tap(find.byIcon(Icons.settings));
    // Verify a new route was pushed once.
    verify(() => navigator.push(any())).called(1);
  },
);

太漂亮了! 我们的测试更简洁了,不再包含与ChatOverviewPage无关的依赖关系。

但是,等等,还有更多!

正如你可能已经发现的那样,我们现在可以完全控制嘲弄和验证我们应用程序中导航调用的确切类型。这意味着我们可以在模拟导航器上调用任何方法时,非常具体地确定我们想要返回什么。这给我们带来了比表面上看起来更多的优势。

Mockingjay带有一个叫做isRoute的匹配器,它可以很容易地验证执行了什么样的路由,包括返回类型,甚至它应该匹配的名称。

假设你的应用程序包含一个对话框,询问你的用户是否喜欢比萨饼或汉堡包。像这样的小组件可能看起来是这样的。

enum QuizOption {
  pizza,
  hamburger,
}
class QuizDialog extends StatelessWidget {
  const QuizDialog({Key? key}) : super(key: key);
  static Future<QuizOption?> show(BuildContext context) {
    return showCupertinoDialog<QuizOption>(
      context: context,
      // Important for compatibility with MockNavigator.
      useRootNavigator: false,
      builder: (context) => const QuizDialog(),
    );
  }
  @override
  Widget build(BuildContext context) {
    return CupertinoAlertDialog(
      content: const Text('Which food is the best?'),
      actions: [
        CupertinoDialogAction(
          onPressed: () => Navigator.of(context).pop(QuizOption.pizza),
          child: const Text('🍕'),
        ),
        CupertinoDialogAction(
          onPressed: () => Navigator.of(context).pop(QuizOption.hamburger),
          child: const Text('🍔'),
        ),
      ],
    );
  }
}

如果没有Mockingjay,对于显示这个对话框的页面,每个典型的widget测试都需要按一个按钮来打开对话框,然后找到特定的widget来提供pizza或hamburger的返回值,然后再验证其余的逻辑。然而,使用我们新获得的超能力,这样的测试变得微不足道。

testWidgets('shows quiz dialog when pressed', (tester) async {
  await tester.pumpWidget(
    MockNavigatorProvider(
      navigator: navigator,
      child: const QuizPage(),
    );
  );
  await tester.tap(find.byKey(showQuizDialogTextButtonKey));
  // Verify a new route of type `QuizOption?` was pushed
  // when the button was pressed.
  verify(() => navigator.push(any(that: isRoute<QuizOption?>())))
      .called(1);
});

需要这个路由的具体返回值吗?容易的小工具树。😎

testWidgets('displays snackbar when pizza was selected', (tester) async {
  // Whenever a route of type `QuizOption?` is pushed,
  // return `QuizOption.pizza`.
  when(() => navigator.push(any(that: isRoute<QuizOption>())))
      .thenAnswer((_) async => QuizOption.pizza);
  await tester.pumpWidget(
    MockNavigatorProvider(
      navigator: navigator,
      child: const QuizPage(),
    );
  );
  await tester.tap(find.byKey(showQuizDialogTextButtonKey));
  await tester.pumpAndSettle();
  expect(
    find.widgetWithText(SnackBar, 'Pizza all the way! 🍕'),
    findsOneWidget,
  );
});

测试正确的弹出行为也变得轻而易举。

void main() {
  group('QuizDialog', () {
    // ...
    testWidgets(
      'pops route with pizza option when pizza button is pressed',
      (tester) async {
        await tester.pumpTest(
          builder: (context) {
            return MockNavigatorProvider(
              navigator: navigator,
              child: const QuizDialog(),
            );
          },
        );
        await tester.tap(find.text('🍕'));
        // Verify the current route is popped with the correct
        // return value.
        verify(() => navigator.pop(QuizOption.pizza)).called(1);
      },
    );
  });
}

试试吧!

我们对在测试中使用这个包感到非常兴奋。它有助于保持测试的简单性和针对性,并带走了多个令人头痛的问题,同时使我们更容易达到100%代码覆盖率的目标。我们很想听听你对我们的包的看法。如果你好奇这些神奇的事情是如何发生的,请随时看一下仓库的情况。如果你喜欢它,一定要留个⭐️! 如果你有任何功能要求或其他反馈,请随时打开一个问题或拉动请求。

谢谢你的阅读,并祝你测试愉快! 👋🏻 🧪 🧑🔬


www.deepl.com 翻译