How many times have you broken a part of your app while adding a new feature or fixing a bug? I'm pretty sure we all have been there, just a small feature could take days of fixing and manual testing, which is pretty inefficient and a waste of developers time and effort.
Writing tests is an important part of the development cycle, it's not something you do after you finish writing all your code, because by then you wouldn't really find time for it. Furthermore, even if you're a beginner, you shouldn't postpone learning tests until you gain enough experience, you should start as early as you can.
In this blog, we walk through a getting started guide to writing tests for Flutter and Dart apps, then make these tests run automatically on every push and Pull Request using GitHub workflows.
What you will learn: #
- Why is it important to write automated tests?
- Types of tests in Flutter.
- Test packages.
- Write a simple test.
- Setup GitHub Actions to run tests on every push and PR.
Automated Tests #
There are lots of types of tests, but the one we're interested in are automated tests, which is a code we write to test our app code.
One might wonder "why would I spend extra time and effort on writing a code that's not part of production code?", and to answer this question, let's see the benefits we get from writing tests:
- Make sure that we haven't broke a part of our app/package while adding a new feature or fixing a bug.
- Automation can eliminate the manual effort spent on testing before each release.
- Helps the collaborators or team members to work together more efficiently such that one PR is well-tested before getting merged which ensures you or team members or the outside collaborator haven't broken existing code.
- Focus on what matters instead of being frustrated by small bugs that would be caught by tests and wasting time finding them after each release.
- To write tests, our code should be well-structured and testable, so when we code with tests in mind, we will definitely write a better code.
- Deliver faster with more confidence and avoid lots of last-minute-changes headache.
Small example #
To better understand automation in tests, let's see an example.
sum function sums up a list of numbers, simple. Let's add another function that does exactly the same but with division.
Trying to print the division of
[1, 1] will result in
NaN. What's wrong? We assume that such simple calculations wouldn't result in errors, similar to small changes we make to fix
a small bug somewhere.
To fix this on a test driven way, let's first write test cases before fixing the problem. Tests go under
test folder in the root of any Dart or Flutter project.
Writing unit tests in Dart is fairly simple,
package/test provides us with all necessary utilities.
In our example, we notice that the test case goes inside a callback that is passed to a top-level function called
test, this function also
takes a first argument as a string describing the test case.
The next step is to write the actual test, in this case we use the same
division function, pass a known input, and expect a known output.
expect is provided by the test package, it helps us match the actual result with the expected result, using a matcher such as
Running the test will give the following output:
The first test failed, we expected dividing
1 to result in
1, but the actual result was
0. The second also failed,
the expectation was
Infinity, but it failed with
To break down why both failed, let's focus on the very first line in
In the summation case this wouldn't cause an issue, but if we start the division
0.0, then this will mess up the whole calculation:
- on the first loop,
1.0is divided by
0.0, which equals
- in the second loop,
0.0is divided by
0.0, which results in NaN.
We can fix this by assigning the first number of the input list to total on the first loop.
By running our tests again, we see that Dart is happy 😄.
Types of Tests in Flutter and Dart #
There are 3 types of tests in Flutter applications:
- Unit tests: test an individual unit such as a function, method or class.
- Widget tests: test a single widget or UI element.
- Integration tests: test the whole app or big parts of it.
To read more, please visit the official guide to testing in the Flutter documentation.
Test Packages #
Writing tests in any framework or language requires some utilities and tools which is usually exposed via a testing toolkit or packages. In Flutter's world, there are 2 official packages for testing:
The first one is used when writing Dart only apps, it doesn't include widget testing tools, therefore for Flutter apps, we use
flutter_test which provides necessary
methods to mimic a user behavior such as pressing a button and builds the widget tree without running the app, in addition to all the methods in
Usually these packages alone aren't enough, we still need to mock many other external dependencies. In most cases, creating a mock from the class we're testing
is easily done using
mockito package, which generates mocks for any class and helps run tests internally avoiding any call to external dependencies.
There are other packages for certain use cases. For instance, if you're using
http package to make http requests, it exposes a
testing API to mock all http requests and responses without making the actual request.
Write a Simple Test #
For this example, we will use a sample app that shows a fun cat image based on the status code of the response to any given link. Find the full code here.
To break it down, there're 2 folders we will work in on this example:
libwhere all the application code lives.
testwhere we write unit and widget tests for the application code.
If you navigate to
lib, you'll find in
main.dart all the code for the sample app,
but in this blog we are interested in tests, so I advice you to go over the code before moving forward.
Unit testing http #
To decide which cat image to show, we first need the status code for the link the user is trying to visit, to get it, we're using the
Let's take a look into how the CatsAPI class looks like.
It has only one instance method,
checkStatusCode() that takes a link and returns the status code. Additionally, we define a
http.Client private property, that only has a setter
for testing purposes as the
@visibleForTesting annotation indicates.
In the application flow, we won't need to change the http
Client used to make the real requests, default is sufficient, but for testing, we need to use a
because in a unit test, we don't want to test the actual backend rather than testing our logic on different http results. Therefore, we use the setter to instantiate a
Any test would start inside the
main() method, where will have a number of methods to define the test flow. The first method we used is
we can setup our initial instances and any other thing we need ready before prior to each unit test, for example, if something needs to be cleaned before each test,
it can be done here.
In our case, we set the http Client in the
CatsAPI to be a
http_test.MockClient, and we pass a callback. We can define all the cases and responses we want to test in this callback,
for instance, how would our logic act on a
404 response code?
Now everything is ready, the actual test for http will happen on 2 cases, success
200 and failure
First, we create a
group(), a top-level function from Flutter's test API, that can describe a group of related tests, in this case it's describing that all tests inside it are
Next, we test for 2 cases with 2 individual unit tests using
test() function, calling
checkStatusCode() and comparing the actual output with the status code we're expecting.
setUp() will be called before each
Widget testing #
In widget testing, the idea is pretty similar to unit testing, except that we're testing UI elements instead of pure logic.
For this example, we will test the Home widget. In the app, we expect a NetworkImage widget to show in the screen when the user types in a link, this is how the test code will look like.
We start a new
group() with the widget name, naming here is optional based on your own tests. Instead of
test(), we see here
this method is available in
flutter_test, and gives us a free WidgetTester to help mimic a user behavior, and build the widget tree,
all automatically without building the app.
Before we carry on the test steps, there's an extra package used for the purpose of mocking Network images. As we already have seen in http testing,
we never actually made any real network call, the same will apply on images since in the app they are displayed by making a network call,
we need a way to mock that. Luckily there's a community package called
that provides a mock response code for network images in Flutter. To use it, we call
mockNetworkImagesFor() async method, and inside it the test code goes.
Let's break down the test steps:
- Ask the WidgetTester to build our widget, in this case
- Type in the text field using
- Once done typing, pump it into the tree.
- Find a widget with type
IconButtonand tap on it.
- Ask the tester to rebuild the widget, similar to calling
- We know that our image widget has a String key 'cat', so we expect to find it by using
Next steps #
That's it! there are yet much to discover in this code, like the types of matchers available, and what options we have to find widgets other than type and key, but by reaching here, you learned about writing simple unit and widget tests.
As a next step, go through the testing codelab provided by Flutter.
GitHub Test Workflow #
To make things even faster and easier, let's automate running the test using GitHub Actions. The goal is to get our test to run on every push and pull request.
To setup a workflow in GitHub, create a
yaml file in the following path at the root of the repo:
This path is fixed, if you change it, GitHub will not recognize the workflow.
Inside the file, let's configure our test workflow.
Name the workflow:
Setup triggers using
Define the steps:
We can have multiple jobs in one workflow, each running on a different virtual machine, for example you might want to have a job for testing on macOS platform, and another on windows.
Each job runs a sequence of steps in order:
- Checkout into the current version of the code.
- Install Flutter with the stable channel.
analyzeto check for any issues reported by the analyzer.
formatto make sure the code is formatted correctly.
- Run the tests.
Once you push this workflow to a Flutter repository, it will run the tests in your test folder against every new push or new opened pull request.
You can check the final run result from here.