Chapters

Hide chapters

Flutter Apprentice

Fourth Edition · Flutter 3.16.9 · Dart 3.2.6 · Android Studio 2023.1.1

Section II: Everything’s a Widget

Section 2: 5 chapters
Show chapters Hide chapters

Section IV: Networking, Persistence & State

Section 4: 6 chapters
Show chapters Hide chapters

17. Introduction to Testing
Written by Alejandro Ulate Fallas

In this chapter, you’ll revisit work on the Recipe Finder app from previous chapters. While doing so, you’ll learn:

  • About the importance of testing your code.
  • The types of tests you can carry out in a Flutter project.
  • How to perform unit testing.
  • Good practices while testing.
  • Mocking dependencies when necessary.

Improving Code Quality With Tests

Ensuring the quality of your Flutter project is essential for its success, that’s where testing comes in. It’ll help you identify defects, errors or issues within your project and increase your confidence in your code.

With testing, you can ensure that your project functions as expected, meets the specified requirements and delivers a reliable and high-quality user experience. Here are a few reasons why you should consider adding tests in all your projects:

  1. Error Identification: Testing helps identify and locate software errors, defects or bugs. These errors can be simple syntax mistakes or more complex logic issues. Identifying and fixing these issues is important to prevent them from causing problems for your end-users.

  2. Risk Mitigation: It helps manage and reduce project risks by detecting issues early in the development process. That way, developers can address them quickly, minimizing the potential impact on project timelines, budgets and customer satisfaction.

  3. Requirement Verification: Testing also verifies that the software meets the specified requirements and aligns with the project’s goals. It ensures that the software does what it’s supposed to do and doesn’t introduce unexpected behavior.

  4. Continuous Improvement: Testing isn’t a one-time activity. It’s an ongoing process. It allows you to gather feedback, make improvements and release updates that enhance the software’s performance, reliability and security.

  5. Regression Prevention: As your app evolves and you add new features, there’s a risk of introducing new defects while fixing existing ones. Testing, especially regression testing, helps prevent these regressions by ensuring that changes don’t break existing functionality.

  6. Security and Compliance: Testing is essential for identifying security vulnerabilities and ensuring compliance with industry standards and regulations. It helps protect sensitive data, user privacy and the overall integrity of the software.

  7. Cost-Efficiency: Early detection and resolution of defects through testing are typically more cost-effective than addressing issues that arise after the software is in production. Testing reduces the expenses associated with fixing bugs in the later stages of development.

  8. Confidence and Trust: Thorough testing increases confidence in both the development team and end-users. It demonstrates a commitment to quality and reliability.

In summary, testing ensures that your Flutter project is high quality, meets user expectations and is free from defects.

Learning About Tests

There are three main kinds of tests: unit tests, widget tests and integration tests. Each one has a different utility and effort related to them.

  • Unit Tests: Focus on testing individual functions, classes or methods in isolation. They’re a great place to validate your business logic.
  • Widget Tests: Used to test widgets in isolation. They verify that a widget looks and behaves as expected. They’re excellent for testing the visual components of your app.
  • Integration Tests: Evaluate a complete app or a large part of it. They’re useful to verify that all the widgets and services they’re testing work together as expected. Integration tests run on real devices or an OS emulator, such as an iOS Simulator or Android Emulator.

You can think of these types of tests as a pyramid in which the base is unit testing. This is because having your business logic working as expected is key to your project and business’s goals.

A well tested app should have a good balance between the different types of tests. However, it’s important to note that the effort required to write and the confidence in each type of test is different.

The above table is a CMDE table. CMDE stands for Confidence, Maintenance cost, Dependencies, and Execution speed.

  • Confidence: How confident you can be that the test is actually testing what you want it to.
  • Maintenance cost: How much effort it takes to maintain the test.
  • Dependencies: How many dependencies the test has.
  • Execution speed: How fast the test runs.

Now that you’ve taken a closer look at testing, it’s time to dive back into code.

Open pubspec.yml and add the following package to your dev_dependencies declaration:

test: ^1.24.3

This package contains most of the utilities needed for testing your app.

Create a new directory in the root of the project called test. Test files should generally reside inside a folder located at the root of your Flutter application or package.

A good way to organize your tests is to make your file structure in test match the one in your lib folder.

Now, add your first unit test by adding a new file called ingredient_test.dart in the same folder structure as lib. Your test file should be located at test/data/models/ingredient_test.dart.

Test files should always end with _test.dart, this is the convention used by the test runner when searching for tests.

Run your tests by running the CLI command below:

flutter test test/data/models/ingredient_test.dart

You should see an error like this:

This happens because the test runner needs a main() function to run the tests in the file. The test runner is a Dart program itself and needs to know where to start.

Fix that by adding a main function like the code below:

void main() {
}

Run your tests again. You should see the following:

Now that main() exists, the test runner can successfully try to run the tests in the file. However, there are no tests yet.

Adding Unit Tests

Now that you’ve created a new test file, it’s time to add some tests.

As previously stated, unit tests are a great place to test your business logic. That’s why you’ll start by testing Ingredient.

Testing the Ingredient Class

Add the following imports at the top of ingredient_test.dart to import the model and testing libraries:

import 'package:recipes/data/models/ingredient.dart';
import 'package:test/test.dart';

Then, add the following code inside main:

// 1.
group('Ingredient', () {
  // 2.
  test('can instantiate', () {
  });
});

Here’s a quick rundown of the code above:

  1. group(): is a helper function that allows you to group tests. You can set the group’s name via a parameter, and all the tasks within the function will group together when you run the tests.
  2. test(): is another helper function from the test package. It receives two parameters: the description of the test and a function that actually performs the test.

There are multiple ways to organize your test, but an easy one to remember is the AAA system: Arrange, Act, Assert.

The basic idea is that you first declare your test requirements, perform the desired action, and verify that the output matches the desired result.

Here’s how it looks in practice. Paste the following code inside test:

// Arrange
late Ingredient ingredient;

// Act
ingredient = const Ingredient();

// Assert
expect(ingredient, isNotNull);
  • In Arrange, you’ve declared your requirements for the test. In this case, it’s about testing that you can instantiate the class.
  • In Act, you’ve instantiated the class, which is the functionality to test.
  • In Assert, you’ve verified that you instantiated the object correctly, and it’s no longer null.

Run your tests using the Android Studio this time by clicking “Run” like in the screenshot below.

Make sure to enable Show Passed to display the tests that are succeeding:

Now, there are a few more behaviors that you can test here. You could verify that the default parameters are correct when instantiated. You could also test that creating an Ingredient with parameters works as expected, and you could test that you can create Ingredients from JSON maps.

Copy the following code and add it at the bottom of group, after the previous test:

test('can set default properties', () {
  // Arrange
  late Ingredient ingredient;

  // Act
  ingredient = const Ingredient();

  // Assert
  expect(ingredient.id, isNull);
  expect(ingredient.recipeId, isNull);
  expect(ingredient.name, isNull);
  expect(ingredient.amount, isNull);
});

expect is a helper function from the test package that allows you to verify that a certain condition is met. It receives two parameters: the actual value and the expected value. If the condition is met, the test passes. Otherwise, it fails.

This test ensures that when you create a new Ingredient, all parameters have the correct default value, which in this case is null.

Add another test by placing the following code inside group:

test('can receive parameters', () {
  // Arrange
  late Ingredient ingredient;
  const id = 123;
  const recipeId = 54321;
  const name = 'Parmesan Cheese';
  const amount = 1.0;

  // Act
  ingredient = const Ingredient(
    id: id,
    recipeId: recipeId,
    name: name,
    amount: amount,
  );

  // Assert
  expect(ingredient.id, equals(id));
  expect(ingredient.recipeId, equals(recipeId));
  expect(ingredient.name, equals(name));
  expect(ingredient.amount, equals(amount));
});

The code above verifies that when Ingredient is created with parameters, said parameters are assigned to the right properties of the class.

Finally, test if you can create Ingredients from JSON maps with the following test:

test('can instantiate from JSON', () {
  late Ingredient ingredient;
  // 1. 
  final jsonMap = <String, dynamic>{
    'id': 123,
    'recipeId': 54321,
    'name': 'Parmesan Cheese',
    'weight': 50.0,
    'amount': 1,
  };
  const id = 123;
  const recipeId = 54321;
  const name = 'Parmesan Cheese';
  const amount = 1.0;

  // 2.
  ingredient = Ingredient.fromJson(jsonMap);

  expect(ingredient.id, equals(id));
  expect(ingredient.recipeId, equals(recipeId));
  expect(ingredient.name, equals(name));
  expect(ingredient.amount, equals(amount));
});

Run your tests again. They should all pass.

Good job! You’ve added your first tests to the project, and Ingredient is now fully tested and production-ready!

Testing Recipe Class

Now it’s time to do the same for Recipe.

Create a new file at test/data/models/recipe_test.dart and add the following code inside:

import 'package:recipes/data/models/models.dart';
import 'package:test/test.dart';

void main() {
  group('Recipe', () {
    test('can instantiate', () {
      // Arrange
      late Recipe recipe;

      // Act
      recipe = const Recipe();

      // Assert
      expect(recipe, isNotNull);
    });
  });
}

This first test is essentially the same one you did for Ingredient. Which ensures it can be instantiated with the default values in the constructor.

Run your tests and check the results.

If you remember, Recipe is a class that’s a bit more complex since it has a list of Ingredients. This means that Recipe is partially dependent on the behavior of Ingredient.

This scenario is fairly common when developing software. Testing these sorts of relations between classes enables the developers to catch possible errors when modifying the code.

This will be your next test. Copy the following code and paste it at the end of group():

test('can receive parameters', () {
  late Recipe recipe;
  const id = 123;
  const label = 'Pasta with Garlic, Scallions, Cauliflower & Breadcrumbs';
  const image = 'https://spoonacular.com/recipeImages/716429-556x370.jpg';
  const description =
      'Pasta with Garlic, Scallions, Cauliflower & Breadcrumbs might be a good recipe to expand your main course repertoire. One portion of this dish contains approximately <b>19g of protein </b>,  <b>20g of fat </b>, and a total of  <b>584 calories </b>. For  <b>\$1.63 per serving </b>, this recipe  <b>covers 23% </b> of your daily requirements of vitamins and minerals. This recipe serves 2. It is brought to you by fullbellysisters.blogspot.com. 209 people were glad they tried this recipe. A mixture of scallions, salt and pepper, white wine, and a handful of other ingredients are all it takes to make this recipe so scrumptious. From preparation to the plate, this recipe takes approximately  <b>45 minutes </b>. All things considered, we decided this recipe  <b>deserves a spoonacular score of 83% </b>. This score is awesome. If you like this recipe, take a look at these similar recipes: <a href="https://spoonacular.com/recipes/cauliflower-gratin-with-garlic-breadcrumbs-318375">Cauliflower Gratin with Garlic Breadcrumbs</a>, < href="https://spoonacular.com/recipes/pasta-with-cauliflower-sausage-breadcrumbs-30437">Pasta With Cauliflower, Sausage, & Breadcrumbs</a>, and <a href="https://spoonacular.com/recipes/pasta-with-roasted-cauliflower-parsley-and-breadcrumbs-30738">Pasta With Roasted Cauliflower, Parsley, And Breadcrumbs</a>.';
  const bookmarked = true;
  // 1.
  const ingredients = [
    Ingredient(
      id: 1123,
      recipeId: 123,
      name: 'Pasta',
      amount: 1.0,
    ),
    Ingredient(
      id: 1124,
      recipeId: 123,
      name: 'Garlic',
      amount: 1.0,
    ),
    Ingredient(
      id: 1125,
      recipeId: 123,
      name: 'Breadcrumbs',
      amount: 5.0,
    ),
  ];

  // 2.
  recipe = const Recipe(
    id: id,
    label: label,
    image: image,
    description: description,
    bookmarked: bookmarked,
    ingredients: ingredients,
  );

  // Assert
  expect(recipe.id, equals(id));
  expect(recipe.label, equals(label));
  expect(recipe.image, equals(image));
  expect(recipe.description, equals(description));
  expect(recipe.bookmarked, equals(bookmarked));
  // 3.
  expect(recipe.ingredients, equals(ingredients));
});

Here’s what that code does:

  1. Defines the list of Ingredient objects for your recipe. If Ingredient fails instantiation, then it would fail while creating this list. This would mean that the test failed, and you could catch this error before merging failing code.
  2. Creates a new Recipe object with the predefined parameters. This includes your Ingredient list.
  3. Verifies that the ingredients in your recipe actually match the predefined ingredients you arranged earlier.

Run your tests again and check the result.

Great! You’ve tested Recipe. Now, your business logic models are covered by tests, and you can detect bugs early while developing.

Understanding Mocks

If you’ve ever done testing before, you might be familiar with the term mocking. But if you aren’t, you’ll understand the basics after this chapter.

Think of mocking like magic in the world of testing! Imagine you have a friendly wizard who can create look-alike or “mock” versions of things you need for your tests. These mock objects are like stunt doubles for real components such as databases, web services or other pieces of code.

Real components might be too slow, expensive or just too big to set up for testing. In those cases, you can use your magical mock skills to avoid needing them and make your tests more reliable.

Here are a few other reasons why you’d want to mock:

  1. Testing in Isolation: When creating unit tests, it’s important to isolate the unit of code under test from “external dependencies”. This ensures that you’re testing in isolation and not the behavior of other components. Mocking allows you to replace real dependencies with simulated objects that behave as you want.

  2. Predictable Behavior: Mocking allows you to define the behavior of dependencies in a controlled manner. You can specify how mock objects should respond to method calls, ensuring that the test focuses on the specific scenario you want to evaluate. This predictability helps in reproducing different test cases and edge conditions.

  3. Speed and Efficiency: Real dependencies, such as databases, APIs or external services, can be slow or have limited availability during testing. Mocks are typically lightweight and readily available, allowing tests to execute quickly and efficiently without external dependencies.

  4. Fault Injection: Mocking enables you to simulate error conditions or exceptional situations that are hard to create with real dependencies. You can force a mock to throw exceptions, return unexpected values or simulate network errors, allowing you to test how your code handles such situations.

  5. Development Speed: During test-driven development (TDD), mocking dependencies allow you to write tests for code that depends on components that have not been fully implemented yet. You can create mock objects to define expected interactions and design tests before implementing the actual dependencies.

In the context of Flutter and Dart, mocking dependencies is widely used in unit testing, particularly when testing the logic of your code. Mocking packages like mockito provide developers with the ability to create mocks for classes and dependencies, making it easier to write focused and isolated unit tests.

Wizard! It’s time you use the magical mocking skills you’ve read about. Open pubspec.yml and add the following package to your dev_dependencies declaration:

mockito: ^5.4.2

Run your tests and ensure that every test is passing.

Now, add a new test file, test/data/repositories/db_repository_test.dart and paste in the following code:

import 'package:recipes/data/repositories/db_repository.dart';
import 'package:test/test.dart';

void main() {
  group('DBRepository', () {
    test('can instantiate', () {
      // Arrange
      late DBRepository dbRepository;

      // Act
      dbRepository = DBRepository();

      // Assert
      expect(dbRepository, isNotNull);
      expect(dbRepository.recipeDatabase, isNotNull);
    });
  });
}

Run your tests and check the results below.

Ka-boom! Your tests just failed! But why is that? Keep on reading to find out the answer and recover your powers!

Making Your Code Testable

DBRepository has a hidden dependency that is not exposed in the constructor of the class. This makes it crash when you try to access recipeDatabase, and it’s not initialized. To top it all, this property is key for other class variables and functions to work as expected.

recipeDatabase is assigned a value when calling init(), which isn’t called in your test. At a simple glance, this doesn’t look like that big of a deal, right? Should you just call init in the test to fix the issues? Well, the correct answer is kind of.

Consider the following scenario - A new team member is onboarded to work on the same app you’re working on. What happens if they try to use DBRepository in a different part of your app? They might not be aware that calling any other method before init will result in a crash.

So, what should you do? One solution is to have your code speak for itself by making the dependencies of DBRepository explicit in the constructor.

Open lib/data/repositories/db_repository.dart and add the following constructor to the class:

DBRepository({RecipeDatabase? recipeDatabase})
    : recipeDatabase = recipeDatabase ?? RecipeDatabase();

Then, change init to the following:

@override
Future init() async {
  _recipeDao = recipeDatabase.recipeDao;
  _ingredientDao = recipeDatabase.ingredientDao;
}

This makes the dependencies of DBRepository a bit clearer. It maintains the behavior, allowing you to run the app without problems. It also makes your code testable since you can now mock the dependencies and not require a fully functional RecipeDatabase implementation to test DBRepository.

Now, run db_repository_test.dart again. The result should match the image below:

Great, the tests are passing again!

When dependencies aren’t clear, errors can easily creep into your code, and a small modification can quickly turn into a headache. Unit testing can help you identify such scenarios in your code and fix them early in the development of your Flutter app.

Mocking With Mockito

You’ve already read that it’s important to isolate the code under test from external dependencies when creating unit tests. In this case, having a fully functional RecipeDatabase might not be what you want in the tests for DBRepository. So, it’s time to take out your magic wand and use some mocking spells.

mockito is a great toolbox to generate mocks without having to do too much work. This enables you to write focused and isolated tests without sacrificing time.

To get started, still in db_repository_test.dart, add the following import to the test file:

import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:recipes/data/database/recipe_db.dart';
import 'package:recipes/data/models/ingredient.dart';

mockito.dart includes all mocking functions that you’ll need to test. On the other hand, annotations.dart imports a couple of neat annotations for generating mocks via build runner.

Next, before the declaration of main, paste the following code:

@GenerateNiceMocks([
  MockSpec<RecipeDatabase>(),
  MockSpec<RecipeDao>(),
  MockSpec<IngredientDao>(),
])

This will tell mockito to generate mocks for all the classes listed in the parameter.

In the terminal, navigate to the root of your project and run dart run build_runner build --delete-conflicting-outputs to generate the mocked classes. After running it, a new file named test/data/repositories/db_repository_test.mocks.dart will show up. It’ll contain the mocks you requested above.

Import the generated file at the top of test/data/repositories/db_repository_test.dart:

import 'db_repository_test.mocks.dart';

Now, add the following code at the start of main():

// 1.
final mockDb = MockRecipeDatabase();
final mockIngredientDao = MockIngredientDao();
final mockRecipeDao = MockRecipeDao();

// 2.
final randomIngredients = [
  const Ingredient(
    id: 1123,
    recipeId: 123,
    name: 'Pasta',
    amount: 1.0,
  ),
  const Ingredient(
    id: 1124,
    recipeId: 123,
    name: 'Garlic',
    amount: 1.0,
  ),
  const Ingredient(
    id: 1125,
    recipeId: 123,
    name: 'Breadcrumbs',
    amount: 5.0,
  ),
];

// 3.
when(mockDb.ingredientDao).thenReturn(mockIngredientDao);
when(mockDb.recipeDao).thenReturn(mockRecipeDao);
  1. MockRecipeDatabase, MockIngredientDao and MockRecipeDao are generated by mockito using the build runner. These classes have the same variables and method signatures as the real implementations, with the exception that they can be controlled.
  2. You’re preparing a list of random ingredients that’ll be used later in your tests.
  3. when is a special function provided by mockito that allows you to control how a mock should behave. It indicates that whenever you try to access mockDb.ingredientDao or mockDb.recipeDao, the mocked version should be used.

Then, modify the test for instantiation so that when you create a DBRepository, you pass mockDb as the parameter like so:

dbRepository = DBRepository(
  recipeDatabase: mockDb,
);

This ensures that your test uses the mocked version of RecipeDatabase instead of the real one.

Run your tests and verify that they are all still passing.

Now, you’ll add a new test for findAllIngredients() and learn to mock methods.

Start by copying the following test to your group:

test('can findAllIngredients', () async {
  // TODO: Arrange
  // TODO: Act
  // TODO: Assert
});

Next, you’ll work on defining the test’s requirements. To call findAllIngredients(), you’ll need an instance of DBRepository with the mocked database version.

Replace // TODO: Arrange with the following code:

// 1.
final dbRepository = DBRepository(
  recipeDatabase: mockDb,
);
await dbRepository.init();
// 2.
when(mockIngredientDao.findAllIngredients()).thenAnswer(
  (_) async => randomIngredients
      .map((e) => DbIngredientData(
            id: e.id!,
            recipeId: e.recipeId!,
            name: e.name!,
            amount: e.amount!,
          ))
      .toList(),
);
  1. First, you are initializing a new instance of DBRepository using the mocked database mockDb.
  2. Then, you are mocking the call to mockIngredientDao.findAllIngredients. Mocking allows you to mock both variables and methods. This means you can also test behaviors that interact directly with the database and check that the right methods are called.

Next, replace // TODO: Act with the code below:

final result = await dbRepository.findAllIngredients();

Now that the calls to the database are mocked, you can run findAllIngredients() and store the result in a variable for later assertions.

Finally, replace // TODO: Assert with the code below:

// 3.
verify(mockIngredientDao.findAllIngredients()).called(1);
// 4.
expect(result, equals(randomIngredients));
  1. verify() is a special function exported by mockito that allows you to check the behavior of a mock and its variables and functions. With this code, you are ensuring that mockIngredientDao.findAllIngredients() is called once when running your repository’s code to find ingredients.
  2. Like in previous tests, you check that the actual result matches the expected output you used to mock the call to mockIngredientDao.findAllIngredients().

Run your tests again. They should all be passing at this point.

Congrats! You are now mocking parts of the database, to simplify the testing of DBRepository. Feel free to go on and add tests for the other methods.

Key Points

  • Testing ensures that your Flutter project is of high quality, meets user expectations and is free from defects.
  • Testing your code improves confidence when releasing a new version of your app.
  • There are multiple types of tests that vary according to different requirements.
  • Unit testing is great for building robust and maintainable Flutter apps.
  • You can bundle tests together with group().
  • To run unit tests, you’ll need to use test().
  • The complexity of a class matters when you think about testing them.
  • Consider mocking when dealing with external dependencies.

Where to Go From Here?

Unit testing is great for building robust and maintainable Flutter apps. In this chapter, you learned the essentials of unit testing a Flutter project, mocking dependencies with mockito, organizing and running tests, handling asynchronous testing and best practices.

By testing your Flutter projects, you can ensure that your apps are well-tested and reliable. Embrace unit testing as an everyday practice, and you’ll be on your way to delivering high-quality Flutter applications.

If you want to learn more about testing, check out this video course. It looks more in-depth at the topic of testing Flutter apps and how to make your code easier to test.

If you prefer reading, you can also check this tutorial about unit testing, which looks a bit more in-depth at the subject of Unit Testing.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.