Testing in Flutter and Dart: Unit Testing I

Samuel Abada
ITNEXT
Published in
8 min readOct 22, 2023

--

Testing in flutter and dart

Unit testing is a fundamental practice in software development that plays a crucial role in ensuring the reliability and stability of your codebase. In this comprehensive guide, we will delve into the world of unit testing in the context of Flutter and Dart. Whether you’re just starting your testing journey or you’re an experienced developer looking to refine your skills, this article will walk you through the essentials of unit testing, setting up your testing environment, and best practices for writing effective tests. By the end of this guide, you’ll have a foundation in unit testing that will empower you to build robust Flutter and Dart applications.

What unit testing is about?

Unit testing is a fundamental aspect of software testing that focuses on verifying the correctness of individual units or components of your code in isolation. These units can be functions, methods, or classes, and unit tests are designed to ensure that they behave as expected when given specific inputs.

The Importance of Unit Testing

Unit testing serves several critical purposes in the software development lifecycle:

  1. Catching Bugs Early: Unit tests help identify and rectify issues at an early stage of development, reducing the cost and effort required to fix them later.
  2. Improving Code Quality: Writing unit tests encourages developers to write modular and well-structured code, making it more maintainable and less error-prone.
  3. Enabling Refactoring: Unit tests provide a safety net when making changes to existing code. If a change introduces a regression, the unit test will catch it.

Setting Up Unit Testing Environment

Before you can start writing unit tests in Flutter and Dart, you need to set up your testing environment.

Tools and Libraries

Flutter and Dart provide a powerful testing framework through the test package. This package offers a robust set of tools for writing and running unit tests.

Setting Up a Flutter Project for Unit Testing

To start unit testing in Flutter, follow these steps:

  1. Create a new Flutter project or open an existing one.
  2. Ensure that the test/flutter_test package is included in your project's pubspec.yaml file as a development dependency.
  3. Configure your project to run tests by adding a test runner configuration.

With these steps completed, you’re ready to write and run unit tests in your Flutter project.

Generally with tests, you check that a certain behaviour or criteria is met. You assert a behaviour, verify or expect a behaviour. Fortunately, the test package comes with assert, verify and expect as keywords hence we are able to make a mental model of our tests.

Writing Your First Unit Test

Let’s dive into writing your first unit test in Dart. For this example, consider a simple function that adds two numbers:

int add(int a, int b) {
return a + b;
}

void main() {
test('Addition Test', () {
expect(add(2, 3), equals(5));
});
}

In this example:

  • We define an add function that takes two integers and returns their sum.
  • Within the test function, we use the expect function to specify the behavior we expect from the add function.
  • If the actual result of add(2, 3) matches the expected value 5, the test passes.

Organizing Unit Tests

Organizing your unit tests is crucial for maintaining a clean and manageable codebase. Here are some best practices:

  1. Structure: Create a dedicated directory for your tests within your project and organize tests by functionality.
  2. Naming Conventions: Follow consistent naming conventions for test files and test cases. Typically, test files are named after the module they test, with _test.dart appended.
  3. Separation of Concerns: Keep your test code separate from your production code to maintain clarity.

Running and Interpreting Unit Tests

To run unit tests in Flutter, you can use the command line or an integrated development environment (IDE) like Android Studio, IntelliJ or Visual Studio Code. After running the tests, you’ll receive feedback on whether they passed or failed. If a test fails, you’ll receive information to help you diagnose and fix the issue.

Writing Comprehensive Unit Tests

Unit testing is a fundamental aspect of software development that helps ensure the reliability and correctness of your code. In this section, we’ll dive deeper into writing comprehensive unit tests by exploring two scenarios: unit testing a ChangeNotifier with no other dependencies and unit testing a ChangeNotifier with dependencies that are mocked.

Unit Testing a ChangeNotifier with No Other Dependencies

A common scenario in Flutter development involves using the ChangeNotifier pattern to manage and update the state of widgets. When your ChangeNotifier has no other external dependencies, you can write unit tests to verify its behavior in isolation.

Let’s consider a simple example where you have a Counter class that extends ChangeNotifier:

import 'package:flutter/foundation.dart';

class Counter extends ChangeNotifier {
int _count = 0;

int get count => _count;

void increment() {
_count++;
notifyListeners();
}

void decrement() {
_count--;
notifyListeners();
}
}

To write comprehensive unit tests for this Counter class:

  1. Initialize the ChangeNotifier: Create an instance of the Counter class.
  2. Test State Changes: Use test cases to verify that the state changes correctly when calling the increment() and decrement() methods.
  3. Verify Notifications: Ensure that the notifyListeners() method is called when the state changes.
  4. Check Initial State: Test the initial state of the Counter object.

Here’s an example unit test for the Counter class:

import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/counter.dart';

void main() {
test('Counter increments and notifies listeners', () {
final counter = Counter();

expect(counter.count, 0);

counter.increment();
expect(counter.count, 1);

counter.decrement();
expect(counter.count, 0);
});
}

This test checks the basic functionality of the Counter class and verifies that it correctly increments and decrements the count while notifying listeners.

Unit Testing a ChangeNotifier with Dependencies (Mocking)

In real-world scenarios, your ChangeNotifier might depend on external services or repositories, such as APIs or databases. To comprehensively test such ChangeNotifier classes, you'll want to mock these dependencies.

First, let’s define the UserService class, which is responsible for fetching user data from an external source. Here's what the dependency class might look like:

class UserService {
Future<String> getUserFullName(int userId) async {
// Simulate fetching user data from a remote API.
await Future.delayed(Duration(seconds: 2));

// Return the user's full name.
return 'John Doe';
}
}

The UserService class fetches a user's full name based on their userId asynchronously.

Next, we have a UserManager class, which extends ChangeNotifier and depends on the UserService for user data retrieval:

import 'package:flutter/foundation.dart';
import 'user_service.dart';

class UserManager extends ChangeNotifier {
final UserService userService;
String _userFullName = '';

UserManager({required this.userService});

String get userFullName => _userFullName;

Future<void> fetchUserFullName(int userId) async {
try {
final fullName = await userService.getUserFullName(userId);
_userFullName = fullName;
notifyListeners();
} catch (e) {
// Handle error or log it.
}
}
}

The UserManager class has a method, fetchUserFullName(), that fetches the user's full name from the UserService and updates the _userFullName property accordingly.

Here’s how you can do it:

  1. Mock Dependencies: Use a mocking library like mockito to create mock versions of the dependencies your ChangeNotifier relies on.
  2. Inject Dependencies: Modify your ChangeNotifier to accept dependencies as parameters or provide a mechanism to inject them. This can be done through constructor injection or setter methods.
  3. Write Unit Tests: Write unit tests for your ChangeNotifier that use the mocked dependencies to simulate various scenarios and behaviors.

Here’s a simplified example of unit testing a ChangeNotifier that depends on an external UserService:

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'user_manager.dart';
import 'user_service.dart';

class MockUserService extends Mock implements UserService {}

void main() {
test('UserManager fetches user full name from UserService', () async {
final mockUserService = MockUserService();
final userManager = UserManager(userService: mockUserService);

// Define the expected behavior of the mock UserService.
when(mockUserService.getUserFullName(1)).thenAnswer((_) async => 'John Doe');

// Ensure that the initial userFullName is empty.
expect(userManager.userFullName, '');

// Fetch the user's full name.
await userManager.fetchUserFullName(1);

// Verify that the userFullName is updated with the result from the mock service.
expect(userManager.userFullName, 'John Doe');
});
}

In this test, we use the MockUserService to simulate fetching user data from the UserService. We define the expected behavior of the mock service using when() and verify that the UserManager correctly updates the userFullName property based on the mocked data.

Including Edge Cases and Complex Functions

Writing comprehensive unit tests goes beyond covering basic scenarios; it also involves testing edge cases and complex functions to ensure your code behaves correctly in all situations. Here’s how you can effectively include edge cases and handle complex functions in your unit tests:

  1. Identify Edge Cases: Before writing unit tests, identify potential edge cases and boundary conditions in your code. Edge cases are situations where the code is most likely to behave differently or encounter unexpected behavior. Examples include empty input, boundary values, or extreme conditions.
  2. Write Test Cases for Edge Cases: Create specific test cases that target these identified edge cases. For example, if you’re testing a function that calculates the factorial of a number, consider writing tests for 0!, 1!, and negative numbers to cover various edge cases.
  3. Use Parameterized Tests: In some cases, you can use parameterized tests to run the same test logic with multiple input values. This approach simplifies testing for a range of values and helps ensure consistency in your tests.
  4. Assertions and Expectations: Use assertions and expectations to validate the output of your code under different conditions. Ensure that the expected outcome matches the actual result. Libraries like Flutter’s test package provide various assertion methods for this purpose.
  5. Test Complex Functions Step by Step: When dealing with complex functions, break them down into smaller, testable units. Write unit tests for each smaller unit independently to ensure that they work as expected. This approach makes it easier to pinpoint issues when a test fails.
  6. Edge Case Testing with Mocking: If your code interacts with external dependencies or services, consider mocking them to simulate edge cases. For example, if your app communicates with an API, create mock responses that mimic unusual or error conditions to test how your code handles them.
  7. Test Error Handling: Don’t forget to test error-handling scenarios. Verify that your code correctly raises exceptions or returns error states when it encounters unexpected or erroneous conditions.

Here’s an example illustrating the concept of testing edge cases for a function that calculates the factorial of a number in Dart:

int calculateFactorial(int n) {
if (n < 0) {
throw ArgumentError('Factorial is not defined for negative numbers.');
}
if (n == 0 || n == 1) {
return 1;
}
return n * calculateFactorial(n - 1);
}


void main() {
test('Calculate factorial for edge cases', () {
// Test for 0! (edge case)
expect(calculateFactorial(0), 1);

// Test for 1! (edge case)
expect(calculateFactorial(1), 1);

// Test for a positive integer (e.g., 5!)
expect(calculateFactorial(5), 120);

// Test for a negative number (edge case)
expect(() => calculateFactorial(-2), throwsArgumentError);
});
}

In this example, we’ve included tests for edge cases such as 0!, 1!, and a negative number to ensure that the calculateFactorial function handles them correctly.

By including edge cases and thoroughly testing complex functions, you can enhance the robustness and reliability of your unit tests, making your codebase more resilient to unexpected conditions.

In the next part of this article, we would look at advanced unit testing techniques, handling asynchronous code, method channels, code coverage, some common pitfalls and best practices.

If you missed the introductory article, you can follow up here Testing in Flutter and Dart: A comprehensive series.

--

--

Google Developer Expert Flutter & Dart | Mobile Engineer | Technical Writer | Speaker