Flutter Music Search app with Unit and Widget Tests

Danny Chuang
Flutter Taipei
Published in
10 min readMar 21, 2022

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

Music Search APP 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 :)

Domain-Driven Design Architecture
  1. 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.
  2. 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.
  3. 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 Failures. 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 the return keyword for "correct" data and the throw keyword when something goes wrong, we're going to have Failure unions. This will also ensure that we'll know about a method's possible failures without checking the documentation.
  4. 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 Exceptions from data sources as their input, and returns either failure or success entity Either<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 finished project folders

The following diagram shows how the DDD architecture is applied in this application, and the relationship between each layer

App architecture

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.

How song states 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:

  1. Subscribe to the stream of states exposed fromAudioPlayer() , forward it and emit the changes to the corresponding AudioPlayerState
  2. Subscribe to the stream of SongState , and load the fetched songs into player when the SongState turned out success status with valid List<Song>
  3. Exposes methods from AudioPlayer() , these methods will be triggered by AudioPlayerButtons widget in presentation layer, and the AudioPlayerState 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 .

test file structure under ./test folder

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

  1. Constructor does not require an httpClient
  2. Make a correct http request
  3. Throws a SongRequestException on non-200 response
  4. Returns an empty List<SongDTO> if the results in response is empty
  5. 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.

  1. Returns a SongFailure on fetchSongs failed
  2. 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 loads
  • loading during the API call
  • success if the API call is successful
  • failure 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 the when 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:

  1. Invoke fechSongs method once after tap the search button with valid text
  2. 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.

--

--