This content originally appeared on DEV Community and was authored by João Pimenta
Introduction
Testing is a critical, yet overlooked, part of modern app development as it helps prevent bugs from slipping through the cracks and affecting users. It should be an integral part of a healthy code base and must be maintained like any other code written in the repository.
Flutter provides a robust framework and excellent tools for writing and running tests at different levels of your application. Whether you’re building a small app or scaling a large codebase, testing ensures that your code is reliable, maintainable, and free from regressions.
In this article, we’ll explore the general idea of automated testing and the three main types of testing in Flutter: Unit Testing, Integration Testing, and Widget Testing. Each serves a specific purpose in the development of a lifecycle and offers tools tailored for different testing scopes.
Testing Fundamentals
Testing Pyramid
First introduced by Mike Cohn in his book Succeeding with Agile: Software Development Using Scrum, the testing pyramid is a conceptual framework that describes the recommended structure and proportion of automated tests in a software project. The idea is:
- A large base of fast, isolated tests.
- A smaller middle layer of more integrated tests.
- A very small top layer of slow, comprehensive tests
Testing Essentials
1) What makes a test a good test?
- It should be easy to understand, implement and maintain
- Readable – clear title, subtitle, and description
- Reliable – produces consistent results every time it runs
- Independent – does not depend on other tests or external states
- Follows DRY – Do Not Repeat Yourself (Avoid redundant code)
- Follow DAMP – Descriptive And Meaningful Phrases
2) Configuring Automated Tests in Flutter
By convention, all test files should be placed inside the test/
folder at the root of your Flutter project. It’s also good practice to name your test files with the suffix _test.dart
, so the Flutter test runner can detect and execute them automatically.
lib/
my_app.dart
test/
widgets/
my_widget_test.dart
integration_test/
my_integration_test.dart
unit_test/
my_unit-test.dart
Each test file in Flutter must define its own main()
as the flutter test command executes each test file in isolation, meaning each file gets its own separate memory and process. This strict isolation guarantees that one test can never interfere with another. It ensures a clean slate and predictable results for every test run and without a main()
, there’s nothing for the test runner to execute, and the file will be ignored.
import 'package:flutter_test/flutter_test.dart';
void main() {
test('example test', () {
expect(2 + 2, equals(4));
});
}
3) Arrange, Act, Assert
It is a common testing pattern that helps structure your test cases clearly and consistently by dividing each test into three distinct phases.
Arrange: dedicated to setting up the necessary preconditions for your test. This includes initializing objects, configuring mock dependencies, or preparing any specific state required for the scenario you’re testing.
Act: execute the specific action or behavior that you intend to test. This could involve calling a method, triggering a user interaction on a widget, or invoking a specific function.
Assert: verify the outcome of the action performed in the “Act” stage. This involves making assertions to confirm that the resulting state, return value, or side effects are precisely what you expect.
Flutter’s Automated Testing
Unit Tests: The Base of the Pyramid
Unit testing focuses on testing individual components of your code in isolation. These components are typically small, self-contained units like functions, methods, or classes. The primary goal of unit testing is to ensure that each isolated piece of code behaves as expected. It’s ideal for catching logic errors and verifying that your core algorithms and business logic work correctly. External dependencies (e.g., APIs, databases, services) are often replaced with mock objects, fakes, or stubs to isolate the unit being tested.
To perform a unit test in Flutter, we will be using the test() function. It takes two arguments: a String description (red) and a callback function containing the test logic (green).
Let’s say I want o test the following function – getAllUserDecks(), which gets all the decks of the user from the SharedPreference.
Arrange Phase: Setting the Stage
1) Prepare Mock Data (fakeDecks
): First, a List named fakeDecks
is initialized. This list will mock the data we expect to be both stored in and retrieved from SharedPreferences.
2) SharedPreferences.setMockInitialValues()
is used. This method, provided by the shared_preferences package, allows us to pre-populate SharedPreferences with our fakeDecks
data specifically for the test’s execution. This way, when the controller attempts to read from SharedPreferences, it receives our predefined mock data.
3) Initialize the Controller: Finally, an instance of the DeckPageController
(the class containing the function under test) is created. This prepares the object on which we will invoke the method being tested.
Act Phase: Executing the Action
4) Call the Function Under Test: In the “Act” phase, the getAllUserDecks()
method is called on the initialized controller. This simulates the real application flow where the controller would attempt to load data from SharedPreferences.
Assert Phase: Verifying the Outcome
5) Validate Expected Results with expect() from the flutter_test package to verify the results. expect()
receives the actual result (of dynamic type) and compares it against the expected value (also of dynamic type). In this particular test, expect()
is used multiple times to:
- Confirm that
controller.userDeck
is not null, indicating data was loaded. - Verify the correct number of decks were loaded.
- Check specific properties of the loaded decks (e.g., deckName, identity) to ensure accurate deserialization and data integrity.
Widget Test: The Middle Layer
Widget tests targets the user interface of your Flutter app. It allows you to interact with widgets, and assert their visual output. The main goal is to verify that widgets render correctly. It’s a middle ground between unit and integration testing — more comprehensive than the former, but faster and more isolated than the latter. Widget tests use real Flutter widgets within a simulated environment.
Instead of the test()
funcion used, which is used for unit tests, we are going to use testWidgets() for the widget tests.
The testWidgets()
function is the entry point for defining a widget test in Flutter, and it is provided by the flutter_test package.
It takes two arguments:
- a String description of the test, and
- a WidgetTesterCallback, which contains the test logic.
This callback is executed inside the Flutter test environment, and it receives a WidgetTester instance as its argument. For each testWidgets()
call, Flutter creates a new, isolated WidgetTester instance, ensuring that tests don’t interfere with each other’s state.
pumpWidget, pump and pumpAndSettle
In order to render a widget tree in the test environment, Flutter provides the pumpWidget() method. It Calls runApp
with the given widget, triggers a frame, and flushes microtasks (by calling pump internally). It builds and renders the provided widget in the test environment.
However, in flutter’s automated tests, the test environment runs in a FakeAsync zone, which lets you control time and frames explicitly. For example, when a widget triggers a setState() (e.g., when a button is pressed), the widget tree doesn’t automatically rebuild. You need to call pump() to simulate a frame and rebuild the widget tree.
Similarly, you can use pumpAndSettle() to keep pumping frames until all animations and microtasks are complete.
Finding the Widgets We Want to Test:
There are more than 10 built-in ways to find the widgets. On this article Only the most reliable and used will be shown, but, if you want to see all of them, check them here.
1) find.byKey
Finds widgets that have a specific Key assigned to them.
Benefits:
- Most reliable: Keys are unique identifiers within a widget tree, making this the most precise and stable way to find a widget, especially if multiple widgets of the same type exist or their text/icon content might change.
- Ideal for critical interactions: Use keys for widgets you need to interact with directly (e.g., buttons, text fields) or whose presence is crucial to the test.
Drawbacks:
- Requires explicit key assignment: You must explicitly add Key objects to your widgets in your application code, which can add verbosity if not already done.
Note: ValueKey is commonly used to differentiate keys with meaningful values.
2) find.byType
Finds widgets of a specific Type (e.g., Text, ElevatedButton, TextField).
Benefits:
- Easy to use for common widget types: Great for finding all instances of a particular widget type on the screen.
- No code changes required: You don’t need to modify your application code to add keys or other identifiers.
Drawbacks:
- Can be ambiguous: If there are multiple widgets of the same type on the screen, this method will return all of them, which might not be what you want. You’ll then need to refine your finder (e.g., find.byType(TextField).at(0) for the first one).
- Doesn’t distinguish instances: It finds based on the class, not a specific instance of that class.
3) find.byText
Finds widgets that display the exact given text. This typically works for Text widgets and their descendants.
Benefits:
- Very readable: Tests often become self-documenting (e.g., find.text(‘Submit’)).
- Convenient for UI verification: Useful for asserting that certain text content is displayed on the screen.
Drawbacks:
- Fragile if text changes: If the displayed text changes (e.g., due to internationalization, dynamic content), the test will break.
- Case-sensitive: Matches exactly, so “Submit” is different from “submit”.
- Can be ambiguous: If the same text appears in multiple places, it will find all of them.
4) find.byIcon
Finds Icon widgets displaying a specific IconData (e.g., Icons.add).
Benefits:
- Useful for icon-based interactions: Good for verifying the presence of specific icons or interacting with buttons that primarily use an icon.
Drawbacks:
- Limited to Icon widgets: Only works for Icon widgets.
- Fragile if icons change: If the icon is replaced, the test will break.
5) find.bySemanticsLabel
Finds widgets based on their semantic label, which is used for accessibility purposes. Can match by exact string or RegExp.
Benefits:
- Robust for accessibility: Good for ensuring that your widgets are accessible and for testing interactions with screen readers.
- Less fragile than text: Semantic labels are less likely to change frequently than visible text, especially for static elements.
Drawbacks:
- Requires Semantics widget or explicit label: You need to set a semanticsLabel property on your widget or wrap it in a Semantics widget.
- Not always present: Many widgets might not have a semanticsLabel if it’s not explicitly provided.
6) find.byWidgetPredicate
A highly flexible method that takes a WidgetPredicate function. This function receives a Widget and returns true if the widget matches your criteria.
Benefits:
- Most powerful and flexible: Allows for complex and custom matching logic. You can check any property of the Widget (e.g., its color, specific controller properties, etc.).
Drawbacks:
- More verbose: Requires writing a custom function, which can be more complex than simple direct finders.
- Can be less performant: If the predicate is complex, it might take longer to traverse the widget tree.
- Can be prone to errors: Requires careful implementation of the predicate to avoid unintended matches or missed widgets.
7) find.descendant
Finds widgets that are descendants of a widget found by of and that also match the matching finder.
Benefits:
- Scoping searches: Useful when you have multiple similar widgets but want to find one within a specific parent (e.g., a “Submit” button within a “Login Form”).
- Reduces ambiguity: Helps narrow down the search space.
Drawbacks:
- More complex syntax: Requires two finders.
- Can be slow: If the of finder matches a large subtree, the search for matching can be extensive.
8) find.ancestor
Finds widgets that are ancestors of a widget found by of and that also match the matching finder.
Benefits:
- Finding parent widgets: Useful for asserting properties of a parent based on a child, or for interacting with a parent widget when you only have a reference to a child.
Drawbacks:
- Less common for direct interaction: You typically interact with descendants more than ancestors in testing.
- More complex syntax: Similar to descendant, it requires two finders.
Let’s use the following code to show an example of widget test
Integration Test: The Top Layer
Integration tests verifies that different parts of your application work together cohesively by simulating “real” user interactions and flows across multiple widgets, screens, and external dependencies such as APIs or databases.
On Flutter, Integration tests work a bit different than the widget and unit test and it requires some set up and configuration.
First, is to add the integration_test package to your project (it is available by using the following command:
flutter pub add 'dev:integration_test:{"sdk":"flutter"}'
or by adding the following to the pubspec.yaml, under dev_dependencies.
The integration test should reside in a separate directory inside your flutter project. You can do this by:
1) Creating a new directory named integration_test
2) Add an empty file named app_test.dart in that directory.
Now we are ready to begin writing our automated tests.
There are one important new thing on this example:
IntegrationTestWidgetsFlutterBinding.ensureInitialized()
In Flutter, integration tests use a different testing binding than regular flutter_test widget tests.
Regular widget tests run in a simulated (headless) environment with a fake WidgetsBinding (TestWidgetsFlutterBinding), which is fast but limited — no real device features.
Integration tests, however, run on a real device or emulator, so they need to hook into the real system properly.
Therefore, IntegrationTestWidgetsFlutterBinding.ensureInitialized()
its going to set up a testing environment to communicate test results back to the host via the Flutter Driver extension, allowing interaction with real platform channels, plugins and async events as they happen on a real device.
Since every testWidgets()
instantiate a WidgetTester object, we can use it to simulate user interactions like tapping, dragging, creating gestures and much more.
Tap
It dispatches a pointer down / pointer up sequence at the center of the given widget, assuming it is exposed.
enterText
Give the text input widget specified by finder the focus and replace its content with text, as if it had been provided by the onscreen keyboard.
Drag
Attempts to drag the given widget by the given offset, by starting a drag in the middle of the widget.
longPress
Dispatch a pointer down / pointer up sequence (with a delay of kLongPressTimeout + kPressTimeout between the two events) at the center of the given widget, assuming it is exposed.
Fling
Starts a drag gesture with a specific velocity & offset (useful for testing scrolls with momentum).
scrollUntilVisible
Scrolls a scrollable until a widget matching the finder becomes visible.
All the methods of the WidgetController are available here.
Extras
group, setUp and tearDown
Group()
It is used when we want to run a series of related tests. It categorize the tests and once tests are put into a group, all of them are going to be ran by a single command.
setup()
You should use a setUp()
function when you have common initialization code that needs to run before each test in a group. This is especially useful when multiple tests need to create and configure the same objects.
The setUp()
function registers a callback that is executed before each test. The body of the callback can be synchronous or asynchronous — if it’s asynchronous, it must return a Future.
If you call setUp()
inside a test group (group()
), it only applies to the tests within that group. The setup code will run after any setUp()
callbacks defined in parent groups or at the top level.
When there are multiple setUp()
callbacks at the same level (either top-level or within a group), they are executed in the order they were declared before each test runs.
teardown()
You should use a tearDown()
function when you need to clean up resources after each test in a group. This is useful if your tests create objects, connections, or state that should be reset or disposed of after each test runs.
The tearDown()
function registers a callback that is executed after each test. Like setUp()
, its body can be synchronous or asynchronous — if asynchronous, it must return a Future.
If you call tearDown()
inside a test group (group()
), it applies only to the tests within that group. The teardown code runs before any tearDown()
callbacks in parent groups or at the top level, which is the opposite order of setUp()
.
When multiple tearDown()
callbacks are declared at the same level, they execute in the order they were declared after each test completes.
Mocking API Responses
When your app or service fetches data from an API, you don’t want your tests to actually call the real server.
Instead, you mock the API response so that your tests are fast, reliable, and don’t depend on the network.
In Dart/Flutter tests, you can use the http package together with mockito or just a custom fake client to mock HTTP calls. In this example, we’ll use the latter: a custom fake client.
Suppose we have a service that fetches data from an API.
The ApiService class is responsible for fetching post data from the endpoint https://example.com/posts.
It takes an http.Client in its constructor. This allows you to inject either a real client or a mock client.
The method fetchPostTitles()
Sends a GET request to the API and If the response status code is 200 OK, it decodes the JSON response body into a list and then extracts the title field from each post and returns a list of titles. Otherwise, if the response has an error status, it throws an exception.
This test checks that fetchPostTitles()
works as expected when the API responds successfully.
A MockClient is created, which simulates the HTTP client. The mock client is configured to always return a “200 OK” response with a JSON array.
Then, An instance of ApiService is created with this mock client and the fetchPostTitles()
method is called. If the result matches, the test passes; otherwise, it fails.
Conclusion
By applying unit, widget, and integration tests appropriately, you can catch bugs early, prevent regressions, and build confidence in your codebase as it grows. Flutter provides powerful tools and a flexible testing framework to help you write clear, reliable, and efficient tests at every layer of the testing pyramid. Embracing automated testing with Flutter means fostering a development environment where confidence in code changes is high, regressions are minimized, and the user experience remains consistently excellent. By integrating these practices into your workflow, you’ll not only deliver higher-quality applications but also cultivate a more efficient and enjoyable development process.
Sources
Others
Flutter Engineering by Majid Hajian
https://www.leapwork.com/blog/testing-pyramid
Flutter’s Documentation
https://docs.flutter.dev/cookbook/testing/widget/introduction
https://api.flutter.dev/flutter/flutter_test/Finder-class.html
https://api.flutter.dev/flutter/flutter_test/CommonFinders-class.html
https://docs.flutter.dev/cookbook/testing/widget/introduction
https://docs.flutter.dev/testing/overview
https://api.flutter.dev/flutter/flutter_test/expect.html
https://api.flutter.dev/flutter/flutter_test/testWidgets.html
https://api.flutter.dev/flutter/flutter_test/WidgetTester-class.html
https://api.flutter.dev/flutter/flutter_test/WidgetTester/pumpWidget.html
https://api.flutter.dev/flutter/package-fake_async_fake_async/FakeAsync-class.html
https://api.flutter.dev/flutter/flutter_test/WidgetTester/pump.html
https://api.flutter.dev/flutter/flutter_test/WidgetTester/pumpAndSettle.html
https://github.com/flutter/flutter/blob/master/packages/flutter_test/lib/src/finders.dart
https://github.com/flutter/flutter/tree/main/packages/integration_test#integration_test
https://api.flutter.dev/flutter/flutter_driver_extension/
https://api.flutter.dev/flutter/flutter_test/WidgetController/tap.html
https://api.flutter.dev/flutter/flutter_test/WidgetTester/enterText.html
https://api.flutter.dev/flutter/flutter_test/WidgetController/drag.html
https://api.flutter.dev/flutter/flutter_test/WidgetController/longPress.html
https://api.flutter.dev/flutter/flutter_test/WidgetController/fling.html
https://api.flutter.dev/flutter/flutter_test/WidgetController/scrollUntilVisible.html
https://api.flutter.dev/flutter/flutter_test/WidgetController-class.html
https://api.flutter.dev/flutter/flutter_test/group.html
https://api.flutter.dev/flutter/flutter_test/setUp.html
https://api.flutter.dev/flutter/flutter_test/tearDown.html
This content originally appeared on DEV Community and was authored by João Pimenta