Unit Testing With Flutter: Getting Started

In this Unit Testing with Flutter tutorial, you’ll improve your programming skills by learning how to add unit tests to your apps. By Lawrence Tan 🇸🇬.

4.4 (27) · 1 Review

Download materials
Save for later
Share

So you’re a Flutter developer who wants to improve your skills and write better apps. Great! One way to be a better developer is to apply Unit Testing to your Flutter apps.

Writing unit tests for your Flutter app can help you create resilient and bug-free code.

In this tutorial, you’ll learn how to write unit tests for your flutter app by building and testing a simple shopping cart app. In the process you’ll learn:

  • Testable Architecture (MVVM)
  • Mocking in Dart
  • How to use GetIt for dependency injection
  • How to write unit tests for a Flutter app
Note: This tutorial assumes you have some basic knowledge of Flutter. If you are new to this family, take a look at our Getting Started With Flutter tutorial before proceeding.

What is Unit Testing?

Unit testing is a process where you check quality, performance or reliability by writing extra testing code that ensures your app logic works the way you expect before putting it into widespread use. Picture walking into your favorite apparel shop, taking a trendy shirt and going into the fitting room.

Developer in a fitting room

In that fitting room, you’re performing a unit test. You’re ensuring that your hands can slot through the shirt’s sleeves, the buttons are in place and the style and size match your figure.

Writing unit tests helps you to confidently build new features, minimize bugs and painlessly refactor existing code. It also forces you to write better, more maintainable code that can be easily tested.

Unit testing focuses on testing the logic of your app rather than the actual user interface.

Testable Architecture

Before you can start writing unit tests, you need to structure your code in such a way that you can easily test a single component of your app.

Traditional patterns like Model-View-Controller (MVC) force you to perform testing on the view controller, which is often tightly coupled with the view. That means that instead of testing a single piece of logic, you’re instead testing the entire view in addition to that piece of logic.

One rule of thumb when building a testable architecture is that you should never import any UI-related packages into your unit test suite. If you are importing UI-related packages, it means your unit tests are testing Flutter widgets instead of app logic, which isn’t what you want.

The Model-View-ViewModel (MVVM) architecture separates business and presentational logic to make writing unit tests much easier. The MVVM pattern allows you to transform data models into another representation for a view.

MVVM architecture diagram

For example, in the app you’ll test in this tutorial, you use a view model to transform JSON data about server-side products into a list of objects. This allows the list view to display a list of products without having to be concerned about how the data got there.

All the processing logic resides in view models, which are isolated from the views. Isolating processing logic from views allows you to focus your testing efforts on your implementation logic.

Note: There are many different ways to make your code testable. Follow the style this tutorial covers, then customize it for your use. By the time you’ve gone through the concepts and examples, you’ll be ready to write your own unit tests.

With these concepts equipped, you’re ready to dive into the starter project to see it in action!

Getting Started

To start, download the begin project by clicking the Download Materials button at the top or bottom of the tutorial, then explore the starter project in Visual Studio Code. You can also use Android Studio, but this tutorial uses Visual Studio Code in its examples.

For this tutorial, you’ll write unit tests for an e-commerce app called ShopNBuy.

ShopNBuy app

ShopNBuy allows users to:

  • View a list of products from Firebase.
  • Add product(s) to a shopping cart.
  • Perform a cart checkout.

That said, before you start writing tests, take a look around the project.

Explore the Begin Project

An explorer in safari clothing

The begin package includes the entire implementation of the app so that you can focus on testing. Take a quick look at the contents of lib to understand what’s there.

Project Structure

The Product class, under models, is a simple model object which represents a shopping list product item.

In services, you’ll find an API class that handles making a network call to fetch a list of products.

Under viewmodels, you’ll find three important classes:

  • A BaseModelclass that inherits from ChangeNotifier, which uses the Observer pattern to notify listeners of any changes in the model. Calling notifyListeners() rebuilds the widget tree, allowing the UI to react as the model is updated.
  • CartModel extends and inherits BaseModel so it can be easily observed by the UI. You’ll flesh out this class later on.
  • Finally, ProductListModel is a container for a list of products.

In helpers, you’ll find important constants in constants.dart. You’ll also see dependency_assembly.dart, where the dependencies are setup using GetIt, a dependency injection package.

The last important piece of code is the BaseView class under ui/views. BaseView is a wrapper widget which contains the provider state management logic. It also contains a model property that you’ll inject as a dependency.

The base widget is wrapped in a ChangeNotifierProvider, which listens to a ChangeNotifier, exposes it to its descendants and rebuilds dependencies whenever you call ChangeNotifier.notifyListeners.

Finally, switch to widgets, where you’ll find a few UI-related widgets that build the Cart Screen and the Product List Screen. Read through them now to understand how they are implemented.

Dependency Injection With GetIt

GetIt Dependency Injection

When writing testable code you’ll find that you need to supply dependencies to the classes that you want to test. That way when you’re writing a unit test you can test the relevant component in isolation. For example, if you’re trying to test an object that makes a network call and then parses the response, you’ll want to make sure that the object isn’t actually making a call over the network, since that would be very slow and brittle. Instead, you’ll often want to inject another object that actually makes the network call into the component so you can provide a fake version in your unit test.

GetIt allows you to register a class and request it from anywhere. Most notable for this app, you can register a view model as a Factory, which returns a new instance every time a view opens.

You can register classes like API which you’ll normally only need one of as a LazySingleton, which will always return the same instance.

Project Architecture Summary

Here’s a quick recap of how the app is architected.

The product list feature is split into a Model, View, and ViewModel as follows:

Product List

  • Model: Products
  • View: ProductList View
  • ViewModel ProductList Model

And the cart feature is split like so:

Cart

  • Model: Products
  • View: Cart View
  • ViewModel Cart Model

All unit tests will be performed on the ViewModel. Being able to test the ViewModel is one of the main benefits of using the MVVM architecture.

Now that you’ve explored the starter package, you can setup your own instance of Firebase and then get testing!

Setting up Firebase

In order to have products for your users to buy, you need to use a database for ShopNBuy. In this case, you’ll use the Firebase Realtime Database, which allows you to seamlessly upload data using JSON files.

Firebase

If you’re already familiar with setting up a Firebase Realtime Database, you can jump right into the next section, Importing JSON Files.

Creating a Firebase Project

Head to the Firebase Console and log in with your Gmail account. Click Add Project and name the project ShopNBuy, then click Continue.

Creating a project in Firebase

Disable Google Analytics; you won’t need them for this tutorial. Click Create Project, wait for the setup to finish, and continue to the Firebase Dashboard.

Disabling Google Analytics

From the left menu, click Database.

Selecting a database

Next, scroll down and click on Create Database under Realtime Database. Then check Test Mode and click Enable.

Realtime database

Note: Set the database to Test Mode so that you don’t have to implement an authentication mechanism to use this tutorial.

Importing JSON Files

Now that you have your database set up, it’s time to import some products.

In the Data section, click on the Menu icon, which looks like three vertical dots, then choose Import JSON. Then click Browse, navigate to the Download Materials root folder and look for products_list.json. Finally, click Import to upload all the data you need for this tutorial.

Importing JSON

Copy the URL from the top of the card. In constants.dart, replace [YOUR OWN FIREBASE URL] with that Firebase URL. For example, https://shopnbuy-12345.firebaseio.com/products.json.

Build and run the app. You should now see a list of products in your app.

Now that you have Firebase ready, it’s time to start unit testing the app!

Unit Testing the Product List Feature

Your next step is to add tests to make sure that the product list feature works properly. Before you can add the tests, you need to know the feature’s requirements:

Requirements: The product list page should display a list of products from Firebase. Each product should show at least its product title and pricing.

Before you start writing the tests, take a moment to learn about an important concept in testing: mocking.

Mocking

Two monsters

Mocking is a way of simulating parts of your app that are outside the scope of the test so that they behave in a specific way. Using mocking in your unit tests prevents tests from failing for reasons other than a flaw in your code, like an unstable network. It allows you to conveniently assert and validate outcomes and gives you the ability to control input and output.

Unit testing means you test individual units of code without external dependencies to keep the integrity of each function or method pure. By mocking your data models and classes, you remove dependencies from your tests.

If you made real network calls in your unit tests, your test suite could fail due to an internet problem. To avoid this, you’ll mock API so you can dictate its response.

Add this code at TODO 2 in product_list_test.dart:

import 'package:shopnbuy/core/models/product.dart';
import 'package:shopnbuy/core/services/api.dart';

class MockAPI extends API {
@override
Future<List<Product>> getProducts() {
  return Future.value([
      Product(id: 1, name: 'MacBook Pro 16-inch model', price: 2399, 
          imageUrl: 'imageUrl'),
      Product(id: 2, name: 'AirPods Pro', price: 249, imageUrl: 'imageUrl'),
    ]);
  }
}

You just created a new MockAPI class that extends the real API. You override getProducts() and hardcode two products. This makes the function return the hard-coded data instead of downloading live data from Firebase, which would make your test slow and unpredictable.

This is one of the main benefits of mocking: You can override a method to dictate its value as the test suite runs.

In the main() body, add the following code after TODO 3:

// 1
setupDependencyAssembler();
// 2
final productListViewModel = dependencyAssembler<ProductListModel>();
// 3
productListViewModel.api = MockAPI();

Step-by-step, here’s what’s happening:

  1. The test suite runs separately from main() in main.dart, so you need to call setupDependencyAssembler() to inject your dependencies.
  2. You create an instance of ProductListModel using GetIt.
  3. You create and assign an instance of the MockAPI class you defined above.

Make sure to import the product list model and the dependency assembler at the top of the file:

import 'package:shopnbuy/core/viewmodels/product_list_model.dart';
import 'package:shopnbuy/helpers/dependency_assembly.dart';

Now, it’s time to write your first few test cases!

Writing Test Cases

Start by adding this code after TODO 4:

group('Given Product List Page Loads', () {
  test('Page should load a list of products from firebase', () async {
    // 1
    await productListViewModel.getProducts();
    // 2
    expect(productListViewModel.products.length, 2);
    // 3
    expect(productListViewModel.products[0].name, 'MacBook Pro 16-inch model');
    expect(productListViewModel.products[0].price, 2399);
    expect(productListViewModel.products[1].name, 'AirPods Pro');
    expect(productListViewModel.products[1].price, 249);
  });
});

Welcome to unit testing in Flutter! Flutter uses the common group and test paradigm for writing unit tests. You can think of a group as a way to organize different unit tests around a specific theme. It’s often helpful to think of these testing blocks as sentences – the group function is an event that happened, and the test function is the result that you want to ensure. You can have as many test blocks within a group closure as you want.

Here, you took your requirement statement and broke it into test statements. You’re testing that when a user enters the product page, they see a list of products.

Here are some more details about what the code does:

  1. Since the function passed to the test method is marked as async, each line in the closure runs synchronously, so you start by calling getProducts().
  2. You then assert the length of the list based on the mock data you supplied in the MockAPI.
  3. Finally, you assert each product’s name and price.

To have access to the test and group methods, you’ll need to import the test package.

import 'package:flutter_test/flutter_test.dart';

With these test cases written, you’re ready to run your tests. If you’re still running your app, stop it using Control-C and run flutter test in the terminal. After a few seconds, you’ll see:

00:02 +1: All tests passed!

Hurray! You’ve successfully written your first Flutter test – and it passed.

If you’re using Visual Studio Code, you can also run your tests by going to Debug ▸ Start Debugging with product_list_test.dart open. In the left navigator panel, you’ll see green checks to the left of each statement signifying that the tests have all passed!

In Android Studio click the green Play button to the left of the test header to run your tests.

Unit Testing the “Add to Cart” Logic

Now that you’ve written the logic, it’s time to write your tests.

Head to product_list_test.dart and replace TODO 5 with this:

final Product mockProduct = Product(id: 1, name: 'Product1', price: 111, 
    imageUrl: 'imageUrl');

Here, you declare a mock product so you can simulate adding a product to the cart.

Next, replace TODO 6 with:

final cartViewModel = dependencyAssembler<CartModel>();

and add the required import:

import 'package:shopnbuy/core/viewmodels/cart_model.dart';

This will create an instance of the cart view model in the test suite.

Finally, replace TODO 7 with the following:

test('when user adds a product to cart, badge counter should increment by 1', () {
  cartViewModel.addToCart(mockProduct);

  expect((cartViewModel.cartSize), 1);
});

Here, you assert that adding a product to the cart will increment the cart size. Run Flutter test in the terminal, or if you are using Visual Studio Code, press Run ▸ Debug just above the start of the test closure to run the test.

You’ll see this:

00:02 +2: All tests passed!    

Gold star

Congratulations, you’ve written your second test and it passed!

Unit Testing the Shopping Cart Feature

The shopping cart appears to work properly, so it’s time to add your tests.

Head to cart_test.dart and add the following under TODO 8

List<Product> mockProducts = [
  Product(id: 1, name: 'Product1', price: 111, imageUrl: 'imageUrl'),
  Product(id: 2, name: 'Product2', price: 222, imageUrl: 'imageUrl'),
  Product(id: 3, name: 'Product3', price: 333, imageUrl: 'imageUrl'),
  Product(id: 4, name: 'Product4', price: 444, imageUrl: 'imageUrl'),
];

And import the Product class at the top of the file:

import 'package:shopnbuy/core/models/product.dart';

This declares new mock products.

Then, add the following to TODO 9:

// TODO 9: Call Dependency Injector
setupDependencyAssembler();

This calls the dependency injector just like you did in the other test. Again, make sure to import the setupDependencyAssembler function:

import 'package:shopnbuy/helpers/dependency_assembly.dart';

Next, add the following to TODO 10:

var cartViewModel = dependencyAssembler<CartModel>();

Just like before this will create an instance of your view model.

Again, import the CartModel class:

import 'package:shopnbuy/core/viewmodels/cart_model.dart';

Finally, add mock products to the cart, replacing TODO 11 with the following:

cartViewModel.addToCart(mockProducts[0]);
cartViewModel.addToCart(mockProducts[1]);
cartViewModel.addToCart(mockProducts[2]);
cartViewModel.addToCart(mockProducts[3]);
cartViewModel.addToCart(mockProducts[0]);
cartViewModel.addToCart(mockProducts[1]);

Testing Your Shopping Cart

Now that you’ve added lots of products to your view model, it’s time to write the tests!

Add this test suite at TODO 12:

group('Given Cart Page Loads', () {
  // 1
  test('Page should load list of products added to cart', () async {
    expect(cartViewModel.cartSize, 6);
    expect(cartViewModel.getCartSummary().keys.length, 4);
  });

  // 2
  test(
      'Page should consolidate products in cart and show accurate summary data',
      () {
    cartViewModel.getCartSummary();
    expect(cartViewModel.getProduct(0).id, 1);
    expect(cartViewModel.getProduct(1).id, 2);
    expect(cartViewModel.getProduct(2).id, 3);
    expect(cartViewModel.getProduct(3).id, 4);

    expect(cartViewModel.getProductQuantity(0), 2);
    expect(cartViewModel.getProductQuantity(1), 2);
    expect(cartViewModel.getProductQuantity(2), 1);
    expect(cartViewModel.getProductQuantity(3), 1);
  });
});

Again, make sure to add the following import at the top of the file:

import 'package:flutter_test/flutter_test.dart';

Here you have tests for:

  1. The assertion of the cart size and the unique products that the cart shows.
  2. The assertion that the products displayed in each index should match their equivalent data.

Run Flutter test and you’ll see:

00:02 +4: All tests passed! 

Double gold stars

Four test cases passed. Rejoice!

Challenge: Unit Testing the Checkout Feature

In this final step, you’ll unit test the checkout feature. Try it out on your own or take a peek at the solution first, then attempt the challenge. The best way to learn is to do it yourself!

There are two main requirements for this set of tests:

  • Test #1 When the user confirms the purchase, it should show the total cost of all of the items in the cart.
  • Test #2 When the user has finished the purchase, it should clear the cart.

If you got stuck or want to compare your answer with the final solution, click Reveal to open the solution section.

[spoiler]

Head to cart_test.dart and add these test cases after the previous two test cases within the group:

// 1
test('When user confirms the purchase, it should show total costs', () {
  expect(cartViewModel.totalCost, 1443);
});

// 2
test('When user has finished the purchase, it should clear the cart', () {
  cartViewModel.clearCart();
  expect(cartViewModel.cartSize, 0);
});

This checks:

  1. The assertion of the total costs, calculated by totaling all the selected products.
  2. The assertion that the cart clears after checkout.

Run Flutter test and you should see:

00:02 +6: All tests passed!  

Three congratulatory stars

Woohoo! All six test cases have passed and you deserve three stars! You’re now ready to start writing unit tests in your Flutter apps.

[/spoiler]

Where to Go From Here?

You can download the final project by clicking the Download Materials button at the top or bottom of this tutorial.

For your next steps, expand your Flutter testing knowledge by exploring the official UI tests cookbook from the Flutter team. You can also take your testing to the next level by exploring and applying Test-Driven Development.

We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!