Testing in Flutter and Dart: Unit Testing II

Samuel Abada
ITNEXT
Published in
11 min readOct 24, 2023

--

Testing in flutter and dart

In the previous part of this article, we touched base on what unit testing is, how to write unit tests, organizing, running and interpreting unit tests. We also looked at how to write comprehensive unit tests by unit testing a changenotifier with no dependency and another that dependended on a service class. We looked at how to mock dependencies and handle edge cases. Incase you missed it, you can read it here.

This article serves as a concluding part to unit testing. In this article, we would look at advanced unit testing techniques, properly mocking dependencies, mocking method channels, handling code coverage, common mistakes/pitfalls and best practices to effective unit testing.

Advanced Unit Testing Techniques

Now, let’s delve into advanced unit testing techniques that enhance the effectiveness of your tests:

Using Test Fixtures

Test fixtures are a crucial part of unit testing, especially when you need to establish a consistent environment for your tests. They help set up and tear down any necessary resources or data before and after running tests. For example, when testing a database-driven app, fixtures can ensure a known database state before running tests. Let’s explore how to use test fixtures with code examples.

Code Sample: Using Test Fixtures

In this example, we’ll use the setUp and tearDown functions provided by the test library to create a test fixture.

import 'package:test/test.dart';
class Database {
List<String> data = [];
void open() {
// Simulate opening a database connection.
}
void close() {
// Simulate closing a database connection.
}
}


void main() {
group('Database Tests', () {
late Database database; // Declare the database instance.
setUp(() {
// Set up the database instance before each test.
database = Database();
database.open();
});

tearDown(() {
// Tear down the database instance after each test.
database.close();
});

test('Database should open', () {
expect(database.data, isEmpty);
});

test('Inserting data into the database', () {
database.data.add('Sample Data');
expect(database.data, contains('Sample Data'));
});
});
}

In this example, we create a Database class and use the setUp function to create an instance of the database before each test and the tearDown function to close it after each test. This ensures a clean and consistent environment for each test case. Other functions that could be used for similar operations are setupAll and tearDownAll.

In a test group, the setup function is called before each test is run and the tearDown function is called after each test is run. On the other hand, setupAll registers a function to be run once before all tests and tearDownAll registers a function to be run once after all tests.

Handling Asynchronous Code

Asynchronous code is common in modern app development. Common asynchronous operations includes network requests or async/await functions. Dart provides built-in support for asynchronous testing using the async and await keywords. Let's look at an example:

Code Sample: Testing an Asynchronous Function
Suppose you have an asynchronous function that fetches data from a remote API:

Future<String> fetchData() async {
await Future.delayed(Duration(seconds: 2)); // Simulate a network request delay.
return 'Data from API';
}

You can write a unit test for this function as follows:

import 'package:flutter_test/flutter_test.dart';

void main() {
test('Fetch Data from API', () async {
final result = await fetchData();
expect(result, 'Data from API');
});
}

In this example, we use the async keyword in the test function and await when calling the fetchData function. This ensures that the test waits for the asynchronous operation to complete before making assertions.

Mocking Dependencies

Mocking is a technique used to isolate code under test from external dependencies like APIs, databases, or services. The mockito library is a popular choice for creating mock objects in Dart. Another popular library is mocktail which i use alot in my projects. Let's see how to use it:

Code Sample: Mocking Dependencies with mockito/mocktail

Suppose you have a class that interacts with an external service:

class ApiService {
Future<String> fetchData() async {
// Simulate fetching data from an API.
await Future.delayed(Duration(seconds: 2));
return 'Data from API';
}
}

You can create a mock version of this class for testing:

Using mockito:

import 'package:mockito/mockito.dart';

class MockApiService extends Mock implements ApiService {}

void main() {
test('Test Function with Mock API Service', () async {
final mockApiService = MockApiService();
when(mockApiService.fetchData()).thenAnswer((_) async => 'Mocked Data');
final result = await someFunctionThatUsesApi(mockApiService);
expect(result, 'Mocked Data');
});
}
String someFunctionThatUsesApi(ApiService apiService) async {
final data = await apiService.fetchData();
return data;
}

Using mocktail:

import 'package:mocktail/mocktail.dart';

class MockApiService extends Mock implements ApiService {}

void main() {
test('Test Function with Mock API Service', () async {
final mockApiService = MockApiService();
when(()=>mockApiService.fetchData()).thenAnswer((_) async => 'Mocked Data');
final result = await someFunctionThatUsesApi(mockApiService);
expect(result, 'Mocked Data');
});
}


String someFunctionThatUsesApi(ApiService apiService) async {
final data = await apiService.fetchData();
return data;
}

In this example, we create a MockApiService using mockitoand mocktail and specify how it should behave when its fetchData method is called in the test. This allows us to isolate our code under test from the actual API service.

In a later article in this series, we will deep dive into the world of mocking and faking in testing. We’ll explore how to use libraries like mockito and mocktail , create custom fakes to isolate your code during testing.

Mocking Method Channels for Testing

In Flutter, you often need to interact with platform-specific features using method channels. However, when it comes to unit testing your Dart code that relies on method channels, it’s essential to isolate your tests and avoid executing actual platform-specific code. This is where mocking method channels comes into play.

Why Mock Method Channels?

Mocking method channels allows you to simulate the behavior of platform-specific code without making real method channel calls. This isolation is crucial for true unit testing, as it focuses on testing your Dart code in isolation from external dependencies.

How to Mock Method Channels

To mock method channels in your Flutter tests, follow these steps:

  1. Use a Testing Framework: Flutter provides a testing framework, such as flutter_test, for writing tests.
  2. Mock the Method Channel: Within your test code, create mock implementations of method channels using the MethodChannel.setMockMethodCallHandler method. This method enables you to intercept method calls made by your Dart code and provide custom responses.
  3. Write Your Dart Code Tests: Craft your unit tests to exercise the Dart code that interacts with the method channels. When your Dart code invokes methods on the channel, the mock handler intercepts those calls and returns predefined results.

Note:
You need to identify the channel name of the method channel you want to mock, the method which your dart code invokes and the return type of the method.

import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
const MethodChannel channel = MethodChannel('my_method_channel');

// Define a method channel handler
Future methodHandler(MethodCall methodCall) async {
if (methodCall.method == 'fetchData') {
// Provide a mock response.
return 'Mocked Data';
}
return null;
};

setUp(() {
// Set up the mock method channel handler.
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, handler);
});

tearDown(() {
// Remove the mock method channel handler.
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});

test('Test method channel interaction', () async {
// Your Dart code that interacts with the method channel.
final result = await fetchDataFromMethodChannel();
// Verify the result.
expect(result, 'Mocked Data');
});
}

In this example, we set up a mock method channel handler for the my_method_channel channel. We have an arbitrary method fetchDataFromMethodChannel which is basically our dart code/function/method that invokes a method channel. When the Dart code under test invokes the fetchData method on the channel, the mock handler responds with Mocked Data. This allows you to test your Dart code’s behavior when interacting with method channels without executing platform-specific code.

These advanced unit testing techniques, including using test fixtures, handling asynchronous code, and mocking dependencies, enhance the effectiveness of your unit tests and ensure that your code is thoroughly tested in various scenarios. Incorporating these techniques into your testing strategy will help you build more reliable and robust applications in the Flutter and Dart ecosystem.

Measuring Code Coverage

Code coverage is a crucial metric in the world of software testing. It measures the percentage of your codebase that is exercised by your unit tests. In other words, it answers the question, “How much of my code is being tested?”

To measure code coverage in Dart, we can use tools like the coverage package. This tool helps you analyze your test suite's effectiveness by providing insights into which parts of your code are covered by tests and which are not.

Here’s how you can set up in your Flutter project:

  1. Generate code coverage reports by running your tests with coverage instrumentation:
flutter test --coverage

This command will run your tests and collect coverage data.

Generate a coverage report in human readable form by doing the following:

  • Install lcov
brew install lcov
  • Run the below command to generate an HTML file that presents the code coverage report more clearly
genhtml coverage/lcov.info -o coverage/html

View the coverage report by opening the generated coverage/html/index.html file in your web browser. This report provides detailed information about which lines of code are covered by tests and which are not.

Importance of Code Coverage

Code coverage is not just a vanity metric; it’s a valuable tool for assessing the thoroughness of your tests and the overall quality of your code. Here’s why code coverage is important:

  1. Identifying Untested Code: Code coverage reports highlight parts of your codebase that are not covered by tests. These “untested” areas are potential breeding grounds for bugs and issues. By identifying them, you can prioritize writing tests for critical code paths.
  2. Measuring Test Effectiveness: High code coverage doesn’t necessarily mean your tests are effective, but low code coverage almost certainly indicates ineffective testing. It helps you evaluate the quality of your test suite and identify areas that may need improvement.
  3. Boosting Confidence: Comprehensive test coverage gives you confidence that your code behaves as intended. It reduces the risk of introducing regressions when making changes to your codebase, enabling you to refactor and enhance your applications with peace of mind.
  4. Enforcing Best Practices: Code coverage encourages best practices like writing modular, testable code. When you aim for higher coverage, you naturally design your code to be more testable and maintainable.
  5. Continuous Improvement: Monitoring code coverage over time allows you to track your testing progress. You can set coverage goals and work towards increasing coverage in areas that matter most.

NOTE:

Code coverage measures the extent to which your code is executed by your unit tests, but it doesn’t guarantee the correctness of those tests. Even if your tests achieve high code coverage, it’s possible that they might not catch all possible edge cases or functional issues.

Avoiding Common Mistakes and Pitfalls

Unit testing is a powerful technique for ensuring the reliability of your code, but like any other skill, it’s easy to fall into common traps and pitfalls. Here are some of the most frequent mistakes developers make in unit testing, along with guidance on how to avoid them:

1. Writing Tests That Are Too Tightly Coupled to Implementation Details

One of the most common mistakes in unit testing is writing tests that are tightly coupled to the implementation details of the code being tested. This can lead to fragile tests that break every time you make a small change in the implementation. To avoid this pitfall:

  • Focus on testing the public interface of a unit, not its internal details.
  • Avoid testing private methods directly; instead, test them indirectly through the public methods that use them.
  • Refactor your code if necessary to make it more testable by separating concerns and reducing dependencies.

2. Neglecting Edge Cases and Boundary Conditions

Another common mistake is neglecting edge cases and boundary conditions in your tests. These are the scenarios where your code is most likely to fail, so it’s crucial to test them thoroughly. To avoid this pitfall:

  • Identify edge cases and boundary conditions in your code, such as minimum and maximum input values, empty collections, and unexpected inputs.
  • Create specific test cases to cover these scenarios and ensure your code behaves correctly in these situations.

3. Writing Unreadable or Fragile Tests

Readable and maintainable tests are essential for long-term success in unit testing. Writing tests that are hard to understand or fragile (i.e., prone to frequent breakage) can lead to frustration and decreased confidence in your test suite. To avoid this pitfall:

  • Follow a consistent naming convention for your test cases that describes what is being tested and the expected behavior.
  • Use meaningful and descriptive variable and method names in your test code.
  • Refactor your tests as your code evolves to ensure they remain clear and concise.
  • Consider using test frameworks and libraries that promote readability, such as test for Flutter unit testing.

4. Not Testing Error Handling and Exception Scenarios

Many developers focus solely on testing the “happy path” of their code and neglect error handling and exception scenarios. This oversight can result in unhandled exceptions and unexpected application behavior. To avoid this pitfall:

  • Write tests that deliberately trigger error conditions and exceptions to ensure your code handles them correctly.
  • Verify that your code produces the expected error messages or responses when errors occur.
  • Test scenarios where external dependencies, such as APIs or databases, return unexpected or erroneous data.

5. Failing to Maintain and Update Tests

Unit tests are not static artifacts; they need to evolve alongside your codebase. Failing to maintain and update your tests as your code changes can lead to outdated and inaccurate tests. To avoid this pitfall:

  • Regularly review and update your tests when you make changes to the code they are testing.
  • Use version control to track changes to your tests and ensure that they reflect the current state of your codebase.
  • Consider adopting a continuous integration and continuous deployment (CI/CD) pipeline that automatically runs your tests whenever code changes are made.

6. Ignoring the Principle of Isolation

Unit tests should be isolated, meaning they should not rely on external dependencies or resources that are not under your control. Ignoring this principle can lead to flaky tests and difficulty in identifying the source of failures. To avoid this pitfall:

  • Use mocking or faking techniques to isolate your code from external dependencies, such as APIs or databases, during testing.
  • Minimize reliance on global state or shared resources in your tests.
  • Ensure that each test case is independent and does not rely on the success or failure of other tests.

Best Practices for Effective Unit Testing

Writing effective unit tests requires adopting best practices:

  1. Edge Cases: Test edge cases and boundary conditions to uncover hidden issues.
  2. Fixtures: Use fixtures to set up and tear down the necessary environment for your tests.
  3. 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.
  4. Test Organization: Organize your tests by functionality to maintain a clean and manageable codebase.
  5. Structuring Tests: Structure tests for maintainability. Avoid overly large test functions and use fixtures when necessary.
  6. Independent tests: Tests should be standalone and not depend on another test.

In conclusion, unit testing is a critical component of software development that enables you to catch bugs early, improve code quality, and confidently refactor your code. In the world of Flutter and Dart, unit testing is essential for building reliable and robust applications.

In the next article of our comprehensive testing series, we will explore “Mocks and Fakes in Testing,” delving into the world of isolating code for effective testing. Stay tuned for more insights and practical examples on your journey to becoming a proficient tester in Flutter and Dart.

--

--

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