Flutter is remembered when you need to quickly make a beautiful and responsive application for several platforms at once, but how to guarantee the quality of the “fast” code?
You will be surprised, but Flutter has the means to not only ensure the quality of the code, but also guarantee the operability of the visual interface.
In the article, we will examine how things are with tests on Flutter, we will analyze widget tests and integration testing of the application as a whole.
I started studying Flutter more than a year ago, before its official release, during the study it was not a problem to find any development information. And when I wanted to try TDD , it turned out that information about testing was disastrously small. In Russian, in general, almost none. Testing issues had to be studied independently, according to the source code of Flutter tests and rare articles in English. Everything that I studied on testing visual elements, I described in an article to help those who are just starting to delve into the topic.
A widget test tests a single widget. It can also be called a component test. The purpose of the test is to prove that the widget's user interface looks and interacts as planned. Testing a widget requires a test environment that provides the appropriate context for the widget's life cycle.
The tested widget has the ability to receive user actions and events, and respond to them, build a tree of child widgets. Therefore, widget tests are more complex than unit tests. However, like the unit test, the widget testing environment is a simple simulation, much simpler than a full-fledged user interface system.
Widget testing allows you to isolate and test the behavior of a single element of the visual interface. And, what is noteworthy, to carry out all the checks in the console, which is ideal for tests that run as part of the CI / CD process.
Files that contain tests are usually located in the test subdirectory of the project.
Tests can be run either from the IDE or from the console with the command:
$ flutter test
In this case, all tests with the * _test.dart mask from the test subdirectory will be executed.
You can run a separate test by specifying the file name:
$ flutter test test/phone_screen_test.dart
The test is created by the testWidgets function, which receives a tool as the tester parameter, with the help of which the test code interacts with the test widget:
testWidgets(' ', (WidgetTester tester) async { // });
To combine tests into logical blocks, test functions can be combined into groups, inside the group function:
group(' ', (){ testWidgets(' ', (WidgetTester tester) async { // }); testWidgets(' ', (WidgetTester tester) async { // }); });
The setUp and tearDown functions allow you to execute some code “before” and “after” each test. Accordingly, the setUpAll and tearDownAll functions allow you to run the code “before” and “after” all the tests, and if these functions are called inside the group, they will be called “before” and “after” the execution of all tests of the group:
setUp(() { // }); tearDown(() { // });
In order to perform some actions on a nested widget, you need to find it in the widget tree. To do this, there is a global find object that allows you to find widgets:
The WidgetTester class provides functions for creating a test widget, waiting for its state to change, and for performing some actions on these widgets.
Any change in the widget causes a change in its state. But the test environment does not rebuild the widget at the same time. You must independently indicate to the test environment that you want to rebuild the widget by calling the pump or pumpAndSettle functions .
Tests can implement both positive scenarios, checking planned opportunities, and negative ones to make sure that they do not lead to fatal consequences, for example, when a user clicks in the wrong direction and enters not what is required:
await tester.enterText(find.byKey(Key('phoneField')), 'bla-bla-bla');
After any actions with widgets, you need to call tester.pumpAndSettle () to change states.
Many are familiar with the Mockito library. This library from the Java world turned out to be so successful that there are implementations of this library in many programming languages, including Dart.
To connect, add a dependency to the project. Add the following lines to the pubspec.yaml file:
dependencies: mockito: any
And connect in the test file:
import 'package:mockito/mockito.dart';
This library allows you to create moque classes, on which the tested widget depends, in order to make the test simpler and cover only the code that we are testing.
For example, if we test the PhoneInputScreen widget, which, when pressed, using the AuthInteractor service, executes a request to the authInteractor.checkAccess () backend , then substituting the mock instead of the service, we can check the most important thing - the fact of accessing this service.
Dependency mocks are created as descendants of the Mock class and implement the dependency interface:
class AuthInteractorMock extends Mock implements AuthInteractor {}
A class in Dart is also an interface, so there is no need to declare the interface separately, as in some other programming languages.
To determine the functionality of the mok, the when function is used, which allows you to determine the response of the mok to the call of a particular function:
when( authInteractor.checkAccess(any), ).thenAnswer((_) => Future.value(true));
Moki can return errors or erroneous data:
when( authInteractor.checkAccess(any), ).thenAnswer((_) => Future.error(UnknownHttpStatusCode(null)));
During the test, you can check for widgets on the screen. This allows you to make sure that the new state of the screen is correct in terms of the visibility of the desired widgets:
expect(find.text(' '), findsOneWidget); expect(find.text(' '), findsNothing);
After running the test, you can also check which methods of the mob class were called during the test, and how many times. This is necessary, for example, to understand whether this or that data is requested too often, whether there are any unnecessary changes in the application state:
verify(appComponent.authInteractor).called(1); verify(authInteractor.checkAccess(any)).called(1); verifyNever(appComponent.profileInteractor);
Tests are performed in the console without any graphics. You can run tests in debug mode and set breakpoints in the widget code.
To get an idea of what is happening in the widget tree, you can use the debugDumpApp () function, which, when called in the test code, displays the textual representation of the hierarchy of the entire widget tree at a given time in the console.
To understand how the widget uses moki there is a logInvocations () function. It takes as a parameter a list of moxas and issues to the console a sequence of method calls on these moxas that were carried out in the test.
An example of such a conclusion is below. The VERIFIED mark is on calls that were checked in the test using the verify function:
AppComponentMock.sessionChangedInteractor [VERIFIED] AppComponentMock.authInteractor [VERIFIED] AuthInteractorMock.checkAccess(71111111111)
All dependencies should be submitted to the tested widget in the form of a mok:
class SomeComponentMock extends Mock implements SomeComponent {} class AuthInteractorMock extends Mock implements AuthInteractor {}
The transfer of dependencies to the tested component should be carried out in some way accepted in your application. For simplicity of storytelling, consider an example where dependencies are passed through the constructor.
In the code example, PhoneInputScreen is a test widget based on StatefulWidget wrapped in Scaffold . It is created in a test environment using the pumpWidget () function:
await tester.pumpWidget(PhoneInputScreen(mock));
However, a real widget can use alignment for nested widgets, which requires MediaQuery in the widget tree, possibly gets Navigator.of (context) for navigation, so it’s more practical to wrap the widget under test in MaterialApp or CupertinoApp :
await tester.pumpWidget( MaterialApp( home: PhoneInputScreen(mock), ), );
After creating a test widget and after any actions with it, you need to call tester.pumpAndSettle () so that the test environment handles all changes in the state of the widget.
Unlike widget tests, the integration test checks the entire application or some large part of it. The goal of the integration test is to make sure that all widgets and services work together as expected. The operation of the integration test can be observed in the simulator or on the device screen. This method is a good substitute for manual testing. In addition, integration tests can be used to test application performance.
The integration test is usually performed on a real device or emulator, such as iOS Simulator or Android Emulator.
Files containing integration tests are usually located in the test_driver subdirectory of the project.
The application is isolated from the test driver code and starts after it. The test driver allows you to control the application during the test. It looks like this:
import 'package:flutter_driver/driver_extension.dart'; import 'package:app_package_name/main.dart' as app; void main() { enableFlutterDriverExtension(); app.main(); }
The tests are run from the command line. If the launch of the target application is described in the app.dart file, and the test script is called app_test.dart , then the following command is enough:
$ flutter drive --target=test_driver/app.dart
If the test script has a different name, then you need to specify it explicitly:
$ flutter drive --target=test_driver/app.dart --driver=test_driver/home_test.dart
A test is created by the test function, and grouped by the group function.
group('park-flutter app', () { // , FlutterDriver driver; // setUpAll(() async { driver = await FlutterDriver.connect(); }); // tearDownAll(() async { if (driver != null) { driver.close(); } }); test(' ', () async { // }); test(' ', () async { // }); }
This example shows the code for creating a test driver through which tests interact with the application under test.
The FlutterDriver tool interacts with the test application through the following methods:
There may be a situation when you need to influence the global state of the application from the test code. For example, to simplify the integration test by replacing part of the services within the application with moki. In the application, you can specify a request handler, which can be accessed through a call to driver.requestData ('some param') in the test code:
void main() { Future<String> dataHandler(String msg) async { if (msg == "some param") { // return 'some result'; } } enableFlutterDriverExtension(handler: dataHandler); app.main(); }
The search for widgets during integration testing using the global find object differs in the composition of methods from similar functionality in testing widgets. However, the general meaning practically does not change:
We looked at ways to organize testing an application interface written using Flutter. We can both implement tests to verify that the code meets the requirements of the technical specifications, and make tests with this very task. Of the noted shortcomings of integration testing - there is no way to interact with the system dialogs of the platform. But, for example, permissions requests can be avoided by issuing permissions from the command line at the application installation stage, as described in this ticket .
This article is a starting point for exploring a testing topic that briefly introduces the reader to how user interface testing works. It will not save you from reading documentation, from which it is easy enough to find out how a particular class or method functions. After all, the study of a new topic for yourself requires, first of all, an understanding of all the ongoing processes as a whole, without excessive detail.