Skip to main content

Coding with Confidence: Master Testing in Flutter in record time!

Coding with Confidence: Master Testing in Flutter in record time!

Coding your Flutter app is fairly easy. The real challenge is making it bug-free and smooth for your users to use.

That’s where testing comes in. It’s not just about checking if your app runs. We’re talking about testing every little part of your app to ensure a nearly bug-free user experience.

Today, we are going to take a look at Unit Testing, making sure the app acts right with data through APIs as well as Widget Testing to check the visuals, including animations, of our app.

Lastly, we will look at Integration Testing, where we test our whole app flow.

Whether you’re new to testing or know the basics, this guide gives you everything you need to get a test coverage of 100%!

What is Testing?

So there are two types of testing.

The first one is manual testing.

As the name suggests, you manually test your features and try to find bugs in your app. This can be completely okay for small apps, but as soon as your app gets larger or you are building for a broad user base, you will quickly experience the downside of it.

You spend a lot of time testing the whole app after implementing a new feature to ensure that nothing else is broken. If you are working for a company and do manual testing, the company would need to hire an expert tester to test the app from top to bottom. This quickly becomes pretty expensive.

So what is the solution to this problem?

The solution is called automated testing.

Automated testing is a way to prevent the software from expected bugs. It doesn’t prevent the software from unexpected bugs. But the big advantage is, that you do not need to test a feature over and over again manually.

You can write the test for a feature once, and afterward, you can always be sure that it functions properly, even if you change something in the code.

Here is a quick overview of when to use manual and automated testing, as well as the advantages and disadvantages:

Testing in Flutter

Now that we know what testing is, let’s take a look at how to do testing in Flutter.

Testing in Flutter is divided into three groups:

  1. Unit Testing: Unit Testing is used to test a single function, method or class. This means that unit testing is used to test the logic behind the app, so the things the user does not see

  2. Widget Testing: With widget testing, we test the behaviour and appearance of a single widget to ensure that the content displayed to the user is right.

  3. Integration Testing: Integration testing is a very special form of testing. It is used to test a complete app or a part of the app. For example the whole login flow, including typing the mail and password, ensuring that error handling is doing what it’s supposed to do, but also ensuring that the app navigates to a new screen if the login was successful.

Unit Testing

A unit test is a test that verifies the behaviour of an isolated piece of code. It can be used to test a single function, method or class.

A unit test helps us find bugs faster, refactor code without having to worry that other logic breaks, as well as debug faster because we immediately know where the bug is.

Unit Test a Counter App

Let’s start by unit testing the standard counter app we get in Flutter.

Start by creating a project with flutter create intro_to_testing.

This is our boilerplate code (chopped down a bit for readability):

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
         Text(
           '$_counter',
           style: Theme.of(context).textTheme.headlineMedium,
         ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        child: const Icon(Icons.add),
      ),
    );
  }
}

Now, this code is not testable.

Why?

Because the logic, the incrementCounter function, is not isolated from our UI code.

Let’s fix this by creating a separate Counter class:

  1. Create a counter.dart file.

  2. Define a class counter with a incrementCounter function:

class Counter {
  int _counter = 0;

  int get count => _counter;

  void incrementCounter() {
    _counter++;
  }
}

Now, modify your widget code like this:

class _MyHomePageState extends State<MyHomePage> {
  final Counter counter = Counter(); // Initialize a Counter
  
  void _incrementCounter() {
    setState(() {
      counter.incrementCounter(); // Instead of setting a counter variable directly, call incrementCounter
    });
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text(
          '${counter.count}', // Change from _counter to counter.count
          style: Theme.of(context).textTheme.headlineMedium,
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

This way, our app is doing the exact same thing, but our logic is separated from the UI.

Now, let’s start by writing our test.

Ensure that you have flutter_test added in your dev dependencies:

dev_dependencies:
  flutter_test:
    sdk: flutter

After that, in the test folder, create a new file called counter_test.dart. It is required that your file ends with _test.dart. Only this way Flutter will recognize that this file is a test file. It is typical that what you write before _test.dart is the same as the file name for which you are testing, in our case counter.dart.

In your counter_test.dart, add the following lines so that we have a basis to work with:

// This is our basis for testing. It gives us all the tools we need for unit testing
import 'package:flutter_test/flutter_test.dart'; 

// Our tests operate like a seperate program. And because every program in 
//dart needs a main function, we need to declare it here too.
void main() {
    // We write our tests here
} 

Great. The first thing we want to test is the initialization of our Counter class. We want to ensure, that when Counter is initialized, our count is set to 0.

We can create a new test case by using the test function provided by flutter_test. This function takes a description, as well as a function (where we write our test case).

The description should describe what this test tests. A very good approach is the “given when then” method. In our example, we would write something like:

Given the counter class when it is initialized then the count should be 0”. You can use this for any test, and I am going to use this approach for the rest of this tutorial because this is one of the best approaches there is out there.

The function we are supposed to provide is going to be the test itself. There, we use the “arrange, act, assert” method (For me, it’s always the 3-A method, idk why).

What does this mean?

First, we want to set everything up for the test (arrange). This is the initialization of our Counter class.

Then we want to read the current count (act).

In the end, we want to test if the count is 0. Let’s take a look at our first test case:

// We need to import our counter file in order to access Counter class.
import 'package:intro_to_testing/counter.dart';

void main() {
  test(
      'Given the counter class when it is initialized then the count should be 0',
      () {
    // Arrange
    final Counter counter = Counter();

    // Act
    final value = counter.count;

    // Assert

    // The expect() function is provided by the flutter_test package.
    // The first paramater is the actual value we get. The second paramter is
    // the expected value. If they do not match, the test does not pass.
    // If they do match, the test passes.
    expect(value, 0);
  });
}

You can execute your first test by using flutter test in your command line, or, if you are in VSCode, go to the Testing tab and execute your tests. If you have chosen the command line approach, your output should look something like this:
00:06 +1: All tests passed!.

Congratulations, you just wrote your first test.

Let’s write a second for incrementing the counter. Stop reading for a moment if you want to try it yourself.

Here is the solution:

test(
    'Given the counter class when it is incremeneted then the count should be 1',
    () {
      // Arrange
      final Counter counter = Counter();

      // Act
      counter.incrementCounter();

      // Assert

      // You do not need to create a separate value for counter.count. 
      // As long as your code stays readable, you can simply pass the output itself
      expect(counter.count, 1);
    },
  );

Now, you might notice two things:

  1. Both of the tests initialize a counter. If we would have a test for decrementing the counter, resetting the counter, etc., we would write the initialization again and again. This might not be a problem for this little example, but for tests that require more initialization logic, this can become quite messy.

  2. These two tests are both about the Counter. Isn’t there a way to group them? (Spoiler: Yes, there is)

Okay, to address the first thing, we can use the setUp or setUpAll function. What do they do? These two functions are responsible for our “arrange” part. setUp is being called before each test, while setUpAll is called before all the tests are executed.

Let’s visualize this:

setUp -> test -> setUp -> test -> setUp -> test ...

setUpAll -> test -> test -> test ...

We want to set up our counter class before each test is run. That’s why we need the setUp function.

void main() {
  // We still need a way to access our counter in our test functions,
  // that's why we create a new variable accessible to all test functions
  late Counter counter;

  // Called before each test
  setUp(() {
    counter = Counter();
  });

  test(
      'Given the counter class when it is initialized then the count should be 0',
      () {
    // Arrange
    //Now empty

    // Act
    // we can still access counter because of our initialization at the top
    final value = counter.count;

    //...
  });

  test(
    'Given the counter class when it is incremeneted then the count should be 1',
    () {
      // Arrange
      // Now empty

      //...
    },
  );
}

Great. This saves us some code. There is also a tearDown and tearDownAll function, which is being called AFTER the test has been executed.

Now to our second problem:

Is there a way to group these tests? Yes, there is. With the group function.

Let’s take a look at it and I will explain how it works in the code:

void main() {
  group(
    // The hyphen is helping us for better visualisation in VSCode and the debug console. 
    // It's not an obligation to use it, but a nice-to-have
    'Counter class - ',
    () {
      // Notice that I also moved the counter and setUp function to this group.
      // This means that they are only accessible in this group and that the
      // setup function is only being called for the tests in this group
      late Counter counter;

      setUp(() {
        counter = Counter();
      });
      test(
          '...',
          () {
        //...
      });

      test(
        '...',
        () {
          //...
        },
      );
    },
  );
}

Great. We have written a full test for our counter application. Let’s take a look at a more complex example.

Unit Tests with APIs and 3rd party libraries

Let’s consider the following example:

We have a basic application that fetches articles from Medium. Our widget is the following:

class _MyHomePageState extends State<MyHomePage> {
  Future<List<String>> fetchArticles = ArticleRepository().fetchArticles();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: FutureBuilder(
        future: fetchArticles,
        builder: (context, snapshot) {
          if (snapshot.hasData) {
            // We do not care about the widget yet
            return Container(color: Colors.green);
          }
          if (snapshot.hasError) {
            return Container(color: Colors.red);
          }
          return CircularProgressIndicator();
        },
      ),
    );
  }
}

Our ArticleRepository class looks like this:

import 'package:http/http.dart' as http;

class ArticleRepository {
  Future<List<String>> fetchArticles() async {
    final response =
        await http.get(Uri.parse('https://medium.com/feed/@tomicriedel'));

    if (response.statusCode == 200) {
      // Imagine we convert the XML to a list of titles
      return [
        "4 Mistakes I Won't Do Again After Releasing My First App",
        "How Adopting SOLID Principles Can Save Your Flutter App from Disaster",
        "Understanding Impeller: A deep-dive into Flutter's Rendering Engine",
      ];
    }
    
    throw Exception('An error occured');
  }
}

There are a lot of new things we need to consider now:

  1. Our function is asynchronous. This means we need to make our test asynchronous.

  2. We work with a third-party library, HTTP. This is going to cause us some real headaches later.

  3. There is a case where an exception is thrown.

We are going to work through these problems step by step. For now, let’s first start by creating a article_test.dart file and use the following starting point:

import 'package:flutter_test/flutter_test.dart';
import 'package:intro_to_testing/articles.dart';

void main() {
  group(
    'ArticleRepository class - ',
    () {
      group(
        'fetchArticles function',
        () {
          late ArticleRepository articleRepository;

          setUp(() {
            articleRepository = ArticleRepository();
          });
          test(
              'Given the article repository class when articles fetched successfully then the returned value should be a list of strings',
              () {
            //...
          });

          test(
            'Given the article repository class when fetching articles caued an error then an exception should have been thrown',
            () {
              //...
            },
          );
        },
      );
    },
  );
}

As you can see, we create two test cases for our fetchArticles function, because there are two possible outputs.

Btw: Yes, you can nest groups. For example, you could also have a function deleteArticle function. You would create a new group inside the ArticleRepository group just for this function.

Let’s start by implementing our first test case. This is pretty straightforward (for now):

test(
 'Given the article repository class when articles fetched successfully then the returned value should be a list of strings',
 () async {
  // Arrange (done in setup)

  // Act
  final articles = await articleRepository.fetchArticles();

  // Assert
  expect(articles, isA<List<String>>());
});

Notice two things:

  1. Our function is now asynchronous. This is needed to fetch the articles

  2. Especially when working with APIs, we do not know what the exact output is going to be. That’s why we can’t just define a List<String> with a pre-defined list of items in the assert part. We can use isA (provided by flutter_test) to check if the type of articles is the right one.

Okay, let’s try to implement our second test. You might think it would look similar to this:

test(
 'Given the article repository class when fetching articles caued an error then an exception should have been thrown',
 () async {
  // Arrange (done in setup)

  // Act
  final articles = await articleRepository.fetchArticles();

  // Assert
  expect(articles, throwsException);
 },
);

But this test wouldn’t pass. Why?

Let’s take a look at the error together:

Expected: throws <Instance of 'Exception'>
Actual: [
  '4 Mistakes I Won't Do Again After Releasing My First App',
  'How Adopting SOLID Principles Can Save Your Flutter App from Disaster',
  'Understanding Impeller: A deep-dive into Flutter's Rendering Engine'
]

If we think about it, this is expected. We never say that the status code in our fetchArticles function is supposed to be 300, 400, or 500. It’s 200.

Btw: Turn off your wifi and try to test the first test. It’s not going to work. And this is bad because tests are supposed to work isolated, without any environmental inputs, including wifi.

Okay. How do we solve this? We can’t just tell our HTTP client to not work. That’s why we are going to introduce a concept named “mocking”.

Mocking means, that we replace the real object (our http Client) with a simulated object (the mock). We can modify our mock to get our desired output.

We can use two libraries to do so: mocktail or mockito. Because mockito uses code generation and this article is supposed to be understandable for everyone, we are going to use mocktail for this tutorial. But you can do the same thing we are going to do in mocktail with mockito, there is just a tiny difference in implementing it.

Add mocktail to your dev dependencies first:

dev_dependencies:
  flutter_test:
    sdk: flutter
  mocktail: ^1.0.3

Okay, there are several adjustments we need to make:

First, our ArticleRepository class now needs to accept an HTTP client. This ensures that we can pass a real client in our app and a mocked client in our test:

class ArticleRepository {
  final http.Client client;

  ArticleRepository(this.client);

  Future<List<String>> fetchArticles() async {
    final response =
        await client.get(Uri.parse('https://medium.com/feed/@tomicriedel'));

   //...
  }
}

Now we also need to adjust the implementation in our widget to the following:

import 'package:http/http.dart' as http;

// We now pass a http client to ArticleRepository
Future<List<String>> fetchArticles =
      ArticleRepository(http.Client()).fetchArticles();

We also need to pass a client when using ArticleRepository in our tests.

But we want to pass a mocked client there. I am going to provide a big chunk of code with our finished test here. This is simply because it is easier to explain everything at once in the code than separately. If you have any questions, just ask in the comments:

import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart';
import 'package:intro_to_testing/articles.dart';
import 'package:mocktail/mocktail.dart';

// We create our mock client. Extending Mock ensures that we don't have to
// write all the implementations for the HTTP Client ourself, but can focus
// on only the functions we want to override.
class MockHTTPClient extends Mock implements Client {}

void main() {
  group(
    'ArticleRepository class - ',
    () {
      group(
        'fetchArticles function',
        () {
          late ArticleRepository articleRepository;
          // We create a new MockHTTPClient for each test to ensure
          // every test is isolated
          late MockHTTPClient mockHTTPClient;

          setUp(() {
            mockHTTPClient = MockHTTPClient();
            articleRepository = ArticleRepository(mockHTTPClient);
          });
          test(
              'Given the article repository class when articles fetched successfully then the returned value should be a list of strings',
              () async {
            // Arrange

            // Now we need to "override" the get of our mockHTTPClient.
            // We do not really override it, but rather say that when
            // mockHTTPClient.get is called, we want to return a response
            // that is then being handled by our fetchArticles function
            //
            // The when function is provided by mocktail.
            when(
              () => mockHTTPClient
                  .get(Uri.parse('https://medium.com/feed/@tomicriedel')),
            ).thenAnswer(
              (invocation) async => Response(
                '<our expected response data>',
                // Everything went fine
                200,
              ),
            );

            // The rest stays the same 
          });

          test(
            'Given the article repository class when fetching articles caued an error then an exception should have been thrown',
            () async {
              // Arrange

              // We do a similar thing as above, but this time, we return a response
              // with status code 500, meaning the request wasn't successful
              when(
                () => mockHTTPClient
                    .get(Uri.parse('https://medium.com/feed/@tomicriedel')),
              ).thenAnswer(
                (invocation) async => Response(
                  '{}',
                  // Internal Server Error
                  500,
                ),
              );

              // Act

              // We need to remove the await, because then we would run the entire
              // function up here. When fetchArticles throws an exception, we basically
              // would say that our test function also throws an exception and thus
              // fails.
              final articles = articleRepository.fetchArticles();

              // Assert
              expect(articles, throwsException);
            },
          );
        },
      );
    },
  );
}

Try to read through this code and understand the code by reading the comments.

But hey. If you understand it, this means you now know how to write 95% of unit test cases in your Flutter app. This is great, congratulations again!

Widget Testing

A Widget Test is a test focused on testing the behavior and appearance of the UI components in response to user actions.

This means that a Widget Test is a way to check that the parts you see and interact with on a screen, like buttons and menus, work as they’re supposed to. It makes sure that when you tap, click, or do something with these parts, they react correctly and look the way they should.

Widget Testing a Counter App

Let’s start by widget testing the standard counter app.

We first separate the logic to increase the counter from the widget logic. This is not necessary for widget testing but is:

  1. Good practice: You should always separate logic from UI

  2. Required for Unit Testing. And because you usually do both, Unit Tests and Widget Tests, we would do it in a real application anyway.

To separate the logic from the UI, create a counter.dart file first and paste in the following code:

class Counter {
  int _counter = 0;

  int get count => _counter;

  void incrementCounter() {
    _counter++;
  }
}

Also, create a file called home_page.dart that contains the following stateful widget (a bit chopped away for readability):

class _MyHomePageState extends State<MyHomePage> {
  final Counter counter = Counter();

  void _incrementCounter() {
    setState(() {
      counter.incrementCounter();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text(
          '${counter.count}', 
          style: Theme.of(context).textTheme.headlineMedium,
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

Our main.dart file now looks like this:

import 'package:flutter/material.dart';
import 'home_page.dart';

void main() {
  runApp(MaterialApp(home: MyHomePage()));
}

Great. Now we can start testing. First, in the test folder, create a file called home_page_test.dart. We follow the same naming convention as we did with Unit Testing. First the name of the file we want to test(home_page), followed by _test.dart.

In our newly created home_page_test.dart file, we are going to add our basis to work with, like we did with Unit Testing:

import 'package:flutter_test/flutter_test.dart'; 


void main() {}

Great. The first thing that we want to test is, that if the increment button is clicked, the counter displays 1.

We can create a new widget test case by using the testWidget function provided by flutter_test. Like the test function for unit testing, this function also takes a description, as well as a function (where we write our test case). For the description, we are going to use the given, when, then method again.

For this widget test, it would look something like this:

Given the counter is 0 when the increment button is clicked, then the counter should be one.

Let’s take a look at how this would look like in practice:

import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets(
    'Given the counter is 0 when the increment button is clicked, then the counter should be one',

    // The widgetTester is from class WidgetTester. It allows us to find widgets
    // read the properties, click on them etc.
    (widgetTester) {
      // Exactly as in Unit Testing, we use the arrange, act, assert method

      // Arrange

      // Act

      // Assert
    },
  );
}

Let’s start with the Arrange part.

We always want to test widgets in isolation, right? This also means that no matter what happens in the lib folder or any other test, the test does not care.

That’s why we will build our widget tree inside the test function. To do so, we are going to use the widgetTest.pumpWidget() function. After building our widget tree, we want to get our widget where the text is 0. To do so, we can use find.text('0'). There are many other methods to find, e.g., .byType.byKey or .byWidget.

We can then write our first “test”. We only expect to find one single widget that has the text 0. As in unit testing, we can use the expect function.

We also want to ensure, that the text is not 1 already (before clicking the button) and we want to find our FloatingActionButton to click on it later.

Let’s take a look at how this all would look like:

(widgetTester) async {
  // Arrange
  await widgetTester.pumpWidget(
    MaterialApp(
      home: MyHomePage(),
    ),
  );
  final counter = find.text('0');
  final counter2 = find.text('1');
  final button = find.byType(FloatingActionButton);

  // There is also findsNWidgets(n), findsAtLeastNWidget(n) etc.
  // but we only want to find one single widget
  expect(counter, findsOneWidget);
  expect(counter2, findsNothing);
  expect(button, findsOneWidget);

  // Act
  // Assert
},

What do we want to do next? We want to click on the button. For this, we can use widgetTester.tap(). After tapping, we now expect to find one widget with text 1 and no widget with text 0:

(widgetTester) async {
  // Arrange
  await widgetTester.pumpWidget(
    MaterialApp(
      home: MyHomePage(),
    ),
  );
  final counter = find.text('0');
  final counter2 = find.text('1');
  final button = find.byType(FloatingActionButton);
  
  // There is also findsNWidgets(n), findsAtLeastNWidget(n) etc.
  // but we only want to find one single widget
  expect(counter, findsOneWidget);
  expect(counter2, findsNothing);
  expect(button, findsOneWidget);
  
  // Act
  await widgetTester.tap(button);
  
  // Now here is the problem: If we would immediately check a widget with
  // text "1" is present, it would fail. Why? Because we do not wait till
  // the widget is rebuild with setState(). To ensure our widget is fully
  // rebuild after clicking the button, we need to use widgetTester.pump()
  // The difference to pumpWidget() is, that pumpWidget() creates a completely
  // new widget tree (and does not preserve the state), while pump()
  // "just" udpates it, while preserving the state.
  await widgetTester.pump();
  
  // Assert
  expect(counter, findsNothing);
  expect(counter2, findsOneWidget);
},

You just wrote your first widget test! Congratulations!

Here is a little challenge for you: Try to write a widget test for decrementing the counter. You need to create a new Button for decrementing, you need to update the Counter class and you need to create a new test. If you have any questions or are stuck, just leave a comment.

Widget Testing an API Application

Let’s widget test our API application. We have the same article repository that we had for Unit Testing, but now we do care about the widget. Our code now looks like this:

  1. The article_repository.dart :
import 'package:http/http.dart' as http;

class ArticleRepository {
  Future<List<String>> fetchArticles() async {
    final response =
        await http.get(Uri.parse('https://medium.com/feed/@tomicriedel'));

    if (response.statusCode == 200) {
      // Imagine we convert the XML to a list of titles
      return [
        "4 Mistakes I Won't Do Again After Releasing My First App",
        "How Adopting SOLID Principles Can Save Your Flutter App from Disaster",
        "Understanding Impeller: A deep-dive into Flutter's Rendering Engine",
      ];
    }
    
    throw Exception('An error occured');
  }
}

2. Our widget (we now only care about the widget):

class _MyHomePageState extends State<MyHomePage> {
  Future<List<String>> fetchArticles = ArticleRepository().fetchArticles();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: FutureBuilder(
        future: fetchArticles,
        builder: (context, snapshot) {
          if (snapshot.hasData) {
            ListView.builder(
              itemBuilder: (context, index) {
                return ListTile(
                  title: Text(
                    snapshot.data![index],
                  ),
                );
              },
            );
          }
          if (snapshot.hasError) {
            return Text('An unexpected error occured');
          }
          return CircularProgressIndicator();
        },
      ),
    );
  }
}

Now we have about the same scenario as we had in Unit Testing. We have multiple states: When it is loaded and when an error occurred, but this time also when it is loading.

We also want to test our widget in isolation, so we need to mock the fetchArticles function. So let’s set everything up. We first need to change our widget constructor so that we can pass the fetchArticles function we want to use:

import 'package:flutter/material.dart';

class MyHomePage extends StatefulWidget {
  final Future<List<String>> futureArticles;
  const MyHomePage({super.key, required this.futureArticles});

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: FutureBuilder(
        future: widget.futureArticles,
        builder: (context, snapshot) {
           // ...
        },
      ),
    );
  }
}

This also requires us to change the main.dart file:

import 'package:flutter/material.dart';

import 'article_repository.dart';
import 'home_page.dart';

void main() {
  runApp(MaterialApp(MyHomePage(futureArticles: ArticleRepository().fetchArticles())));
}

Now, let’s set up our test:

void main() {
  testWidgets(
    'Given the fetch article function when it sucessfully loaded then the app should display the articles.',
    (widgetTester) async {
      // We first mock the fetchArticles function
      final List<String> articles = [
        "4 Mistakes I Won't Do Again After Releasing My First App",
        "How Adopting SOLID Principles Can Save Your Flutter App from Disaster",
        "Understanding Impeller: A deep-dive into Flutter's Rendering Engine",
      ];

      Future<List<String>> mockFetchArticles() =>
          Future.delayed(Duration(seconds: 1), () => articles);

      await widgetTester.pumpWidget(
        MaterialApp(
          home: MyHomePage(mockFetchArticles()),
        ),
      );
    },
  );

   testWidgets(
    'Given the fetch article function when it failed loading then the app should display an error.',
    (widgetTester) async {
      Future<List<String>> mockFetchArticles() =>
          Future.delayed(Duration(seconds: 1), () => throw Exception('An error occured'));

      await widgetTester.pumpWidget(
        MaterialApp(
          home: MyHomePage(mockFetchArticles()),
        ),
      );
    },
  );
}

Great. Let’s start working on the first widget test. We first expect to find a CircularProgressIndicator.

Afterward, we expect our list of articles. But if we just have two expect functions, it’s not going to work!

Why?

Well, we didn’t wait till mockFetchArticles is done. And if it is done, we will still not see anything, as our widget is not updated. How do we fix this? Luckily the widget_test package gives us the function widgetTester.pumpAndSettle(). This function first waits until everything is done running and afterward updates the widget. Let’s see what this would look like:

testWidgets(
  'Given the fetch article function when it sucessfully loaded then the app should display the articles.',
  (widgetTester) async {
    // We first mock the fetchArticles part
    final articles = [
      "4 Mistakes I Won't Do Again After Releasing My First App",
      "How Adopting SOLID Principles Can Save Your Flutter App from Disaster",
      "Understanding Impeller: A deep-dive into Flutter's Rendering Engine",
    ];
  
    Future<List<String>> mockFetchArticles() {
      return Future.delayed(
        const Duration(seconds: 1),
        () => articles,
      );
    }
  
    await widgetTester.pumpWidget(
      MaterialApp(
        home: HomeScreen(
          futureArticles: mockFetchArticles(),
        ),
      ),
    );
  
    expect(find.byType(CircularProgressIndicator), findsOneWidget);
  
    // Wait till everything is loaded
    await widgetTester.pumpAndSettle();
    expect(find.byType(ListView), findsOneWidget);
  
    // We expect exactly 3 list tiles, because our articles list is 3 items long
    expect(find.byType(ListTile), findsNWidgets(articles.length));
  
    // We can also check if each article is displayed correctly
    for (final article in articles) {
      expect(find.text(article), findsOneWidget);
    }
  },
);

Great! Our first API Widget Test is done. Now we only need to test the case where an error occurs. We need to adjust the mockFetchArticles function as well as the code that does the widget checking:

testWidgets(
  'Given the fetch article function when it failed loading then the app should an error.',
  (widgetTester) async {
    // mockFetchArticles will throw an error after one second
    Future<List<String>> mockFetchArticles() {
      return Future.delayed(
        const Duration(seconds: 1),
        () => throw Exception('An error occured'),
      );
    }
  
    await widgetTester.pumpWidget(
      MaterialApp(
        home: HomeScreen(
          futureArticles: mockFetchArticles(),
        ),
      ),
    );
  
    expect(find.byType(CircularProgressIndicator), findsOneWidget);
  
    await widgetTester.pumpAndSettle();
  
    expect(find.text('An unexpected error occured'), findsOneWidget);
  },
);

We just widget-tested the complete process for fetchArticles! Congratulations!

Widget Testing an Animation

Lastly, let’s widget test an animation. This is our starting code:

import 'package:flutter/material.dart';

class AnimationScreen extends StatefulWidget {
  const AnimationScreen({super.key});

  @override
  State<AnimationScreen> createState() => _AnimationScreenState();
}

class _AnimationScreenState extends State<AnimationScreen>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _widthAnimation;
  late Animation<Color?> _colorAnimation;

  @override
  void initState() {
    super.initState();

    _controller =
        AnimationController(vsync: this, duration: const Duration(seconds: 2));

    _widthAnimation = Tween<double>(begin: 50, end: 200)
        .animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));

    _colorAnimation = ColorTween(begin: Colors.blue, end: Colors.green).animate(
        CurvedAnimation(parent: _controller, curve: Curves.easeInCubic));

    _controller.forward();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: AnimatedBuilder(
        animation: _controller,
        builder: (context, child) {
          return Center(
            child: Container(
              width: _widthAnimation.value,
              height: _widthAnimation.value,
              color: _colorAnimation.value,
            ),
          );
        },
      ),
    );
  }
}

We have quite a simple animation:

Once the screen is loaded, a Container animates from a height and width of 50 to a height and width of 200. At the same time, the Container color changes from blue to green.

Let’s start our widget test by first setting up the basics and finding the container:

void main() {
  testWidgets(
    'Test the animation of animation screen',
    (widgetTester) async {
      await widgetTester.pumpWidget(AnimationScreen());

      final containerFinder = find.byType(Container);
      expect(containerFinder, findsOneWidget);
    },
  );
}

Perfect. Now we want to check the initial values. So the width and height are 50 and the color is blue. But how?

We can’t just use containerFinder.color, because containerFinder is of type Finder, not Container. So we need to find a way to access the widget.

And again, flutter_test comes to the rescue. We can easily access the widget by using widgetTester.widget() :

(widgetTester) async {
  await widgetTester.pumpWidget(
    MaterialApp(
      home: AnimationScreen(),
    ),
  );

  var containerFinder = find.byType(Container);
  expect(containerFinder, findsOneWidget);

  var container = widgetTester.widget<Container>(containerFinder);
}

Now, we can simply access our Container properties with container.xyz:

(widgetTester) async {
  //...

  var container = widgetTester.widget<Container>(containerFinder);

  // container.width and container.height do not exist, we need to use
  // the constraints of the container
  expect(container.constraints!.minHeight, 50);
  expect(container.constraints!.minWidth, 50);
  expect(container.color, Colors.blue);
}

Great. Now we do the same thing as we did with the API: wait till it’s done and update the widget with widgetTester.pumpAndSettle. But simply checking the new values afterward won’t work, because we still access the old Container. We need to find the Container again and get the values again (That’s why our containerFinder and container are not final). After that, we can finally check if the new values are correct. Our widget test now looks like this:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intro_to_testing/animation_screen.dart';

void main() {
  testWidgets(
    'Test the animation of animation screen',
    (widgetTester) async {
      await widgetTester.pumpWidget(
        MaterialApp(
          home: AnimationScreen(),
        ),
      );

      var containerFinder = find.byType(Container);
      expect(containerFinder, findsOneWidget);

      var container = widgetTester.widget<Container>(containerFinder);

      // container.width and container.height do not exist, we need to use
      // the constraints of the container
      expect(container.constraints!.minHeight, 50);
      expect(container.constraints!.minWidth, 50);
      expect(container.color, Colors.blue);

      // Wait till the
      await widgetTester.pumpAndSettle();

      // We need to find the container again to get the new Container values
      containerFinder = find.byType(Container);
      container =  widgetTester.widget<Container>(containerFinder);

      expect(container.constraints!.minHeight, 200);
      expect(container.constraints!.minWidth, 200);
      expect(container.color, Colors.green);
    },
  );
}

Integration Testing

So, what is Integration Testing?

Integration test is the process of testing multiple components of an app together, to ensure that they function correctly as a whole.

This means checking that the different parts of an app work well together, like making sure all pieces of a puzzle fit to form the right picture.

We also call Integration Testing “end-to-end testing”.

Starting Point for our Integration Testing

To get to know integration testing, we are going to test a simple login progress.

We have a login screen that has a text field for email and password, as well as a button “Login”. When the username is “tomic” and the password is “12345”, then the app pushes to the Home Screen (a simple screen with a text). If not, we are showing an error dialog stating that something went wrong.

We are going to use the following code as the starting point:

  1. main.dart
import 'package:flutter/material.dart';
import 'package:intro_to_testing/login_screen.dart';

void main() {
  runApp(
    const MaterialApp(
      home: const LoginPage(),
    ),
  );
}

2. login_screen.dart

import 'package:flutter/material.dart';
import 'package:intro_to_testing/home_screen.dart';

class LoginPage extends StatefulWidget {
  const LoginPage({super.key});

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final _usernameController = TextEditingController();
  final _passwordController = TextEditingController();

  void _login() {
    // Validate login credentials and navigate to home screen if valid
    if (_usernameController.text == 'tomic' &&
        _passwordController.text == '12345') {
      Navigator.pushReplacement(
        context,
        MaterialPageRoute(
          builder: (context) => const HomeScreen(),
        ),
      );
    } else {
      // Show error message if credentials are invalid
      showDialog(
        context: context,
        builder: (BuildContext context) {
          return AlertDialog(
            title: const Text('Error'),
            content: const Text('Invalid username or password'),
            actions: [
              TextButton(
                onPressed: () => Navigator.pop(context),
                child: const Text('OK'),
              ),
            ],
          );
        },
      );
    }
  }

  @override
  void dispose() {
    super.dispose();
    _usernameController.dispose();
    _passwordController.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Login'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextFormField(
              controller: _usernameController,
              decoration: const InputDecoration(
                labelText: 'Username',
              ),
            ),
            TextFormField(
              controller: _passwordController,
              obscureText: true,
              decoration: const InputDecoration(
                labelText: 'Password',
              ),
            ),
            const SizedBox(height: 16.0),
            ElevatedButton(
              onPressed: _login,
              child: const Text('Login'),
            ),
          ],
        ),
      ),
    );
  }
}

3. home_screen.dart

import 'package:flutter/material.dart';

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: const Center(
        child: Text('Home Screen'),
      ),
    );
  }
}

With these big code chunks out of the way, let’s finally start testing!

Writing our Integration Tests

To get started, first, add the integration_test package to your dev dependencies. It is from the Flutter SDK, so we do not rely on any third-party libraries:

dev_dependencies:
  flutter_test:
    sdk: flutter
  integration_test:
    sdk: flutter

Great. Now, instead of writing our tests in the test folder, we are going to create a separate folder named integration_test at the highest level of our project. Create a new file called app_test.dart inside of this folder. Of course, if you have a bigger application and test separate parts, you can use more descriptive names, but because we are literally going to test the whole app, we are going to leave it like this for now.

Great. Now let’s think about what we want to do:

  1. We obviously want to define a new test. We already learned that in Unit & Widget Testing.

  2. We want to build our whole app. Unlike Widget Testing, we want to run the whole app, including all the real functionalities, etc. This means we need to find a way to call the main function from our main.dart in the lib folder without confusing it with the main function of our integration test

  3. We want to type in the username and password

  4. We want to click on the “Login” button

  5. We want to check if the app actually navigates to the home screen

I am going to share our whole test here now and explain each part inside of the code. I think this makes it way easier to understand:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:intro_to_testing/home_screen.dart';

// We are going to prefix this with app, as we do not want to call the
// main function of our integration test over and over again
import 'package:intro_to_testing/main.dart' as app;

void main() {
  // This does the work of WidgetsFlutterBinding.ensureInitialized();
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets(
    'Login screen with valid username and password',
    (widgetTester) async {
      // Call the main function from our lib folder.
      // If we wouldn't have prefixed it with app., the program would not know
      // which main function we want to call
      app.main();

      // We wait till everything is loaded, just like we did in widget tests
      await widgetTester.pumpAndSettle();

      // We want to enter the text in our text fields. To find our widgets
      // we use find.byType. The problem: We have two TextFormFields
      // There are multiple approaches on how to solve this. One is to use
      // .at(n), just like we did below. A more complex, but also the recommended
      // approach is to give the TextFormFields some Key and then use
      // find.byKey().
      await widgetTester.enterText(
          find.byType(TextFormField).at(0), 'tomic');
      await widgetTester.enterText(
          find.byType(TextFormField).at(1), '12345');

      // Press the login button
      await widgetTester.tap(find.byType(ElevatedButton));

      // We wait till everything is loaded / the app pushed to the new screen
      await widgetTester.pumpAndSettle();

      // Now we want to check if the app actually displays our home screen
      expect(find.byType(HomeScreen), findsOneWidget);
    },
  );
}

But… normally you are supposed to see something happen in the Simulator. Why don’t we see anything?

The answer is simpler than you might think: The program executes everything unbelievably fast. If you want to see what happens step by step, just add a Future.delayed() with a duration of 1 or 2 seconds between each step.

Let’s implement our test for the case when the input is not valid. Try it by yourself, you only need to change 4 lines of code.

Okay, let’s take a look at it:

testWidgets(
    'Login screen with invalid username and password',
    (widgetTester) async {
      app.main();
      await widgetTester.pumpAndSettle();

      // Enter a wrong username and/or password
      await widgetTester.enterText(
          find.byType(TextFormField).at(0), 'ups');
      await widgetTester.enterText(
          find.byType(TextFormField).at(1), 'badpassword');

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

      // Check if the alert dialog is present
      expect(find.byType(AlertDialog), findsOneWidget);
    },
  );

And voilà, we just added integration testing for our whole application. It’s that easy!

Final Words

Today, we’ve taken a deep dive into the world of Flutter testing, from unit and widget testing to integration tests, ensuring our apps run smoothly and are free from bugs.

If you found this guide helpful, give it a clap and follow for more insights like this. Your support helps me keep sharing valuable tips and tricks to make your coding journey even better!

PS: Want to get notified when I post more high-quality content about Flutter? Join my newsletter! It’s 100% free!

Icons in the thumbnail by freepik