Flutter Music Search app with Unit and Widget Tests
In this tutorial, we are going to introduce a Music Search demo app in Flutter using iTunes search api, which implements the Domain-Driven Design architecture inspired by ResoCoder to separate this application into layers (presentation, application, domain, infrastructure), and demonstrate how to write Unit tests and Widget tests to achieve more than 90% test coverages.
Preview
Architecture
Of course, this demo application can be easily done without a complex architecture. However, in the real world, your app may have more complex external data coming from different endpoints that return unstructured data(e.g., JSON). At this point, you will want to structure your app into layers to isolate each job independently within your app. The goal is to make your app more maintainable, testable, and scalable.
There are many architectures out there such as MVVM, MVC, and they are all great! The reason I choose Domain-Driven Design(DDD) is just this is the architecture that I find comfortable to work with :)
- Presentation layer is all about Flutter Widgets and also the state of Widgets, we use flutter_bloc in the app for state management, if you are not familiar with BLoC pattern, you could check out the official tutorial for more details.
- Application layer has only one job, that is to decide “what to do next” with the data, no matter it comes from the user input or from the infrastructure, and it is away from all of the outside interfaces of an app. You aren’t going to find any UI code, network code, or database code here.
- Domain layer is where you put the data into entity classes that you used in your application. It does not depend on any other layer. It is nothing to do with domain layer if you change your backend service or change the UI codes. On the other hand, all the other layers do depend on domain. In addition, the domain layer is also the home of
Failure
s. Handling the exception from REST API or any other services is a pain as your project grows. We want to mitigate this pain with union types. Instead of using thereturn
keyword for "correct" data and thethrow
keyword when something goes wrong, we're going to haveFailure
unions. This will also ensure that we'll know about a method's possible failures without checking the documentation. - Infrastructure layer is at the boundary of our app, it contains:
- Data Transfer Object(DTO) whose purpose is to convert the entities from the Domain layer and plain data from remote service and local service.
- Data sources is at the lowest level to perform server request. Since the data format from outside world is mostly JSON format. Its purpose is to do the conversion between DTO and JSON data.
- Repositories is in between domain/application layer and the outside world. Its job is to take DTOs and unruly
Exception
s from data sources as their input, and returns either failure or success entityEither<Failure, Entity>
as their output.Either
is functional programming functionality provided from dartz package, as our purpose is to implements functional error handling.
Test in Flutter
If you want your app does exactly what it should do without any unexpected behaviors, or if you want to find bugs quickly after adding new features or performing refactors which may break existing code. Then, you should write automated tests in the very first stage. Flutter comes with a testing suite build into the SDK, make it more easier to write tests.
Test in Flutter can be generally falls into these categories:
- Unit Testing: test a single function, method, or class.
- Widget Testing: test a single UI component, to verify that the widget’s UI looks and interacts as expected.
- Integration Testing: test a complete app or a large part of an app, and it’s run on a real device or an OS emulator.
In the last part of this tutorial, I’ll show the code for some classes in different layers along with its test files.
DDD in this project
The top-level folder can be structured in compliance with each layer.
The following diagram shows how the DDD architecture is applied in this application, and the relationship between each layer
Let’s start with the Domain layer, it containsSongFailure
and Song
object which is the core entity that holds the information of a song, such as artist name, track name, preview url, etc.
And since it’s domain-driven design, all the other layers do depend on the domain. First of all, Application layer contains a SongCubit
(Cubit is a simplified version of BLoC), and its state, actually there is one more Cubit where we will discuss it later.
The purpose of SongCubit
is to manipulate its state, it will determine the UI widgets to be rendered in presentation layer. There is only one event in SongCubit
which will be triggered by the TextField in SearchBar after editing is completed or when the search button is being pressed. And it will first emit a state with loading status after being invoked, and then call the fetchSongs
method from SongRemoteRepository
, hence it will get either a List<Song>
or a SongFailure
, result in emitting a state with success status with songs, or a state with failure status.
We can then use BlocBuilder
exposed from flutter_bloc library to simply rebuild the UI widgets according to SongState
. The following diagram shows how the SongState
affects the UI in presentation layer.
The other one is AudioPlayerCubit
. We use an audio player package just_audio in our project to handle some basic player features such as setAudioSource()
, play()
, pause()
, seekToIndex()
, etc. Hence, the AudioPlayerCubit
have three main tasks:
- Subscribe to the stream of states exposed from
AudioPlayer()
, forward it and emit the changes to the correspondingAudioPlayerState
- Subscribe to the stream of
SongState
, and load the fetched songs into player when theSongState
turned out success status with validList<Song>
- Exposes methods from
AudioPlayer()
, these methods will be triggered byAudioPlayerButtons
widget in presentation layer, and theAudioPlayerState
will determine what UI it should be presented as shown in below diagram.
Implementation
I will go through and explain some part of this app in this section, or you can find full source codes here 👉 finished project.
Note: Remember to generate the data classes and (de)serialization code via
flutter pub run build_runner build --delete-conflicting-outputs
Setup
This project is created on Flutter 2.10.3. I’d like to note that the freezed and json_serializable are being used for generating unions/sealed classes, and help us doing toJson
, fromJson
functionality, and I did ignored the generated files in git, so you should run below command in the CLI before you run the project.
flutter pub run build_runner build --delete-conflicting-outputs
Domain layer
song.dart
and song_failure.dart
are all using freezed package for both data classes and unions, you will get automatically generated value equality
, copyWith
, exhaustive switch
, and even JSON serialization
support from one place.
It is nothing more than holds the song information.
Infrastructure layer
song_dto.dart
The data transfer objects (DTOs) are classes whose sole purpose is to convert data between entities from the domain layer and the plain data(JSON) of the outside world. A fromJson
method is enough for this app.
By the way. If your app has work with local database such as Hive, DTO is the place to put the code generation annotation provided by hive_generator.
song_remote_service.dart
SongRemoteService
is located at the bottom of the app and is used for make a request from iTunes search api to fetch songs and convert it into List<Song>
. For simplicity, we always throw a SongRequestException
if the http status code in response is not 200.
Starting from SongRemoteService
, we can start discussing about how to write unit tests. For simplicity, this tutorial does not follow the “Test Driven Development” approach. If you’re more comfortable with that style of development, you can always go that route.
In flutter, the test files are reside inside a test
folder located at the root of project, so it is better to manage your test files by following the structure in lib
folder. Also, test files should always end with _test.dart
.
The official test package provides a standard way of writing and running tests in Dart, the function naming is very intuitive. If you are not familiar with writing tests in flutter, you may check the official introduction with a well explained example code.
song_remote_service_test.dart
We should test the SongRemoteService
handles API calls correctly, including success and failure cases. We don’t want our tests to make real API calls based on network status, which would lead to unstable tests. In order to have a consistent, controlled test environment, we will use mocktail
to mock the http
client.
Tip: We can create some test data (.json) in test/fixtures/, and read it into our tests by a fixture_reader
We tested the following cases
- Constructor does not require an httpClient
- Make a correct http request
- Throws a
SongRequestException
on non-200 response - Returns an empty
List<SongDTO>
if the results in response is empty - Returns a
List<SongDTO>
if the results in response has value
song_remote_repository.dart
SongRemoteRepository
depends on SongRemoteService
, the purpose is to take List<SongDTO>
and unruly Exceptions
from SongRemoteService
as input, and return Either<SongFailure, List<Song>>
as its output. This functional error handling using Either
type forces us to handle failures in application with ease.
song_remote_repository_test.dart
We will mock the underlying SongRemoteService
in order to unit test the SongRemoteRepository
logic in an isolated, controlled environment.
- Returns a SongFailure on fetchSongs failed
- Returns correct songs on fetchSongs succeed
Application layer
song_state.dart
SongState holds songs and a sealed class that represent four states our app can be in:
initial
before anything loadsloading
during the API callsuccess
if the API call is successfulfailure
if the API call is failed
song_cubit.dart
Cubit
is a simplified version of Bloc
, which can expose functions to be invoked to trigger state changes.
Here we have SongCubit
which will expose fetchSongs
method uses our song remote repository to retrieve a Either<SongFailure, List<Song>>
object, and update the state according to the Either
type. which is used to represent a value that has any one of the two specified types.
song_cubit_test.dart
Similar to the infrastructure layer, it is critical to unit test the application layer to ensure that the logic of use cases behaves as expected.
Since we are using flutter_bloc library in this layer for state management, we will be relying on bloc_test package which allows us to easily prepare our blocs for testing, handle state changes and check results in a consistent way.
SongCubit
depends on the remote repository, so we will mock the underlying SongRemoteRepository
for unit testing.
There are 5 cases we’re going to test:
1. Initial state is correct
2. Emits nothing when song is null
3. Emits nothing when song is empty
4. Emit [loading, failure] when fetchSongs returns left<SongFailure>
5. Emit [loading, success] when fetchSongs returns right<List<Song>>
Presentation layer
song_page.dart
SongPage
does not have any states since it extends StatelessWidget
. We use BlocBuilder
to rebuild the central part widget in between SearchBar
and AudioPlayerButtons
according to SongState
.
- Note that the status in
SongState
is a sealed class created by freezed, we can then use thewhen
method to exhaustively switch over all possible cases. - Assign a unique key to the widget make it more easy for testing later.
song_page_test.dart
We have to write widget test in order to test the UI as presentation layer is all about widgets. The bloc_test library also expose MockBlocs
and MockCubits
which make it easy to test UI. We can mock the states of the various cubits and ensure that the UI reacts correctly.
There are 5 cases we’re going to test:
1. Render an initial empty page when SongState is initial
2. Render a circular progress indicator when SongState is loading
3. Render a failed result page when SongState is failure
4. Render an empty page with no result when SongState is success
with an empty songs
5. Render a SongListView when SongState is success
with valid songs
search_bar.dart
SearchBar
has a TextEditingController
attached on the TextField
, the fetchSongs
method will be invoked when editing is completed, or when the search button is being pressed.
search_bar_test.dart
SearchBar
depends on SongCubit
, we will mock the SongCubit
and simulate the user interaction activities to verify if the fetchSongs
is called correctly.
Test cases:
- Invoke
fechSongs
method once after tap the search button with valid text - Invoke
fechSongs
method once after editing is completed with valid text
Bonus: Test coverage visualization
You can gather test coverage to verify that how many lines of code you’ve covered. There are few scripts(.sh) in the scripts
folder, just run below command and you will see the LCOV coverage report in a few seconds.
sh scripts/test.sh
Well, that’s it. 🎉 There are few more part and test cases not mentioned here such as AudioPlayerCubit
, you can find it in the finished project.