Test-Driven Development with Flutter

Arnela Jasarevic
upday devs
Published in
7 min readFeb 2, 2021

Find out how to build a Flutter App with a Test-Driven Development approach. Get used to TDD with the Red-Green-Refactor rhythm, and never be afraid to make changes to your code.

“If you want your systems to be flexible, write tests. If you want your systems to be reusable, write tests. If you want your systems to be maintainable, write tests.”

— Uncle Bob Martin

TEST-DRIVEN DEVELOPMENT

TDD is the process of creating software by writing tests in advance to prove the implementation fulfills the requirements. For most developers, it’s not something that comes naturally and the hardest part is to get used to it. But the good news is that the process of TDD is rhythmic, and because of that, I will try to show you by example how easy it is when you feel the rhythm. Basically, through all your work you need to repeat 3 steps/colors:

  1. Write a failing test (Red phase)
  2. Make the test pass — Write just enough code to pass the test (Green phase)
  3. Improve the code — Clean up the mess (Refactoring phase)

FLUTTER AUTOMATED TESTS

Flutter has 3 categories of tests:

  1. A widget test (tests a single widget)
  2. A unit test (tests a single function, method, or class)
  3. An integration test (tests a complete app or a large part of an app)

In this article, I will present widget tests, and how you can be sure that everything is shown to the user as you wanted. You can check the type of widgets, texts, colors, paddings, tap detections, etc.

Unit and integration tests will be included in the next article, so stay tuned!

Our task is to implement a simple UI. We want to build a screen that includes Text and Button widgets.

Don’t worry about the simplicity of the screen, because our goal is to get used to the rhythm of TDD. Let’s start.

If you have opened your project and you began with anything else instead of creating a test file, stop there!

Before implementing anything, we will write the first test which will check if our screen is created at all. Remember, Red-Green-Refactor.

══╡RED: Home page

testWidgets('home page is created', (WidgetTester tester) async {
final testWidget = MaterialApp(
home: HomePage(),
);

await tester.pumpWidget(testWidget);
await tester.pumpAndSettle();
});

Our first test is written and the syntax error will be shown because we didn’t create the home page widget at all. We are deep in the red zone and if we want to get to the green, creating a home page is our next step.

══╡GREEN: Home page

class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container();
}
}

If we check our test, the syntax error is gone. Run the test with the command flutter test and check if we are in the green zone. Nice, we did it!

══╡REFACTOR: Home page

Since there is nothing to refactor, our 1st Red-Green-Refactor cycle is done. Easy enough?

Congratulations!

Now, when we have the base, the next step is to create a test that will check if we have the Text widget on our screen and if the text is correct.

══╡RED: Text

testWidgets('home page contains hello world text',
(WidgetTester tester) async {
final testWidget = MaterialApp(
home: HomePage(),
);

await tester.pumpWidget(testWidget);
await tester.pumpAndSettle();

expect(find.text('Hello World!'), findsOneWidget);
});

If you run the command flutter test the exception caught by the Flutter test framework will be:

The following TestFailure object was thrown running a test:
Expected: exactly one matching node in the widget tree
Actual: _TextFinder:<zero widgets with text “Hello World!” (ignoring offstage widgets)>
Which: means none were found but one was expected

The test has failed because there is no text at all and that’s great! We need to add text to our home page to pass the test and to get to the green zone.

══╡GREEN: Text

The rule is to add just enough code to pass the test.

class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Text('Hello World!');
}
}

Run the flutter test and we have successfully finished the green step.

══╡REFACTOR: Text

Do we need to refactor this? Usually, yes! Probably you will need to do something like adding localization, format code, etc. But for now, as this is a small example app and our code is formatted, we are good to go. Our 2nd Red-Green-Refactor cycle is done!

It’s time to implement a button inside our home page.

First, we need to test a few things:

  • the button is created
  • the icon and text are shown
  • background color is blue
  • callback on pressed is correctly called

Usually, it’s better to have separated tests for everything, but to keep this article short, we will create one that will include button creation, icon, text and background color, and the second one for the onPressed callback.

══╡RED: Button, icon, text, background-color

testWidgets('home page contains button', (WidgetTester tester) async {
final testWidget = MaterialApp(
home: HomePage(),
);

await tester.pumpWidget(testWidget);
await tester.pumpAndSettle();

final buttonMaterial = find.descendant(
of: find.byType(ElevatedButton),
matching: find.byType(Material),
);

final materialButton = tester.widget<Material>(buttonMaterial);

expect(materialButton.color, Colors.blue);
expect(find.text('Weather today'), findsOneWidget);
expect(find.byKey(Key('icon_weather')), findsOneWidget);
});

Run the flutter test and the exception caught by the Flutter test framework is:

The following StateError was thrown running a test:
Bad state: No element

Let’s create the button and get to a green paradise.

══╡GREEN: Button, icon, text, background-color

class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(children: [
Text('Hello World!'),
ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(primary: Colors.blue),
child: Row(children: [
Icon(
Icons.wb_sunny,
key: Key('icon_weather'),
),
Text('Weather today')
])),
]);
}
}

Run the flutter test and… tests passed! It’s time for the 3rd step.

══╡REFACTOR: Button, icon, text, background-color

This is our time to clean up the mess we have just made.

class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Hello World!'),
ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(primary: Colors.blue),
child: Row(
children: [
Icon(
Icons.wb_sunny,
key: Key('icon_weather'),
),
Text('Weather today'),
],
),
),
],
);
}

Format the code and make it beautiful.

Let’s start with our last Red-Green-Refactor rhythm for the onPressed callback and we are done! Or, maybe not?

Okay, we are done with the widget tests and UI. In the next article, we will go through unit and integration tests and our button will present today’s weather in the dialog. But first, let’s finish this.

══╡RED: Callback on pressed is correctly called

We need to forward onPressed callback to our home page class, so while writing the test, a syntax error will be shown and that’s okay. We will fix that on our way to the green zone.

testWidgets('notify when button is pressed', (WidgetTester tester) async {
var pressed = false;
final testWidget = MaterialApp(
home: HomePage(
onPressed: () => pressed = true,
),
);

await tester.pumpWidget(testWidget);
await tester.pumpAndSettle();

await tester.tap(find.byType(ElevatedButton));
await tester.pumpAndSettle();

expect(pressed, isTrue);
});

The error that we got is:

Error: No named parameter with the name ‘onPressed’.

So, let’s do some changes and make this test pass.

══╡GREEN: Callback on pressed is correctly called

class HomePage extends StatelessWidget {
const HomePage({Key key, this.onPressed}) : super(key: key);

final VoidCallback onPressed;

@override
Widget build(BuildContext context) {
...
ElevatedButton(
onPressed: () => onPressed?.call(),
...
}

We don’t know if our callback is null, so to avoid errors, we need to add conditional member access. We are good to check our test again! Time to run the flutter test one more time before refactoring.

══╡REFACTOR: Callback on pressed is correctly called

The last refactoring is for polishing the final look.
While we are in the Red-Green-Refactor rhythm, we don’t care for pixels, fonts, paddings, and similar. There is no need to run the application to check the UI until all TDD is done.
For full implementation of the home screen, please check here.

And you have one more important task:

Run the magic command flutter test and enjoy your time in the green zone!

Conclusion

Building apps with a TDD approach is definitely something that at first feels weird and unnatural for many developers. You may know and understand the Red-Green-Refactor rhythm but somehow it’s still hard to get used to it. But with time (and I hope this article) you will have more confidence in your code, and when the time for refactoring of the app comes, you will love your tests!

Until the next article! Thanks!

Test code can be found on Github here.

--

--