Test driven development with flutter, repository & BLoC

In this article we’re going to develop a fairly simple, but fairly typical feature using the flutter bloc library — and we’re going to do it writing the tests first.

Our simple feature is called ‘levels’ and it is intended to store a user’s current level, work out the amount of experience needed before the next level. The data is going to be stored and retrieved from a store.

Pubspec.yml

Let’s get the pubspec out the way with first:

name: levels
description: Adding levels
version: 0.0.1
author:
homepage:
environment:
sdk: ">=2.1.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^3.2.0
equatable: ^1.1.1
dev_dependencies:
flutter_test:
sdk: flutter
mockito: ^4.0.0
bloc_test: ^3.0.0

We have added the following libraries, the first is the bloc library — that we are going to use for our Bloc implementation, and the bloc testing library — to test our blocs, also mockito — which we can use for mocking. We’ve also added equatable, which is an important library for testing as it allows us to compare two values in unit tests for equality.

What we’ll write

In this article, we’re going to implement the ‘models’ — i.e the level / experience to go, the ‘logic’ — the bit that takes total experience and returns the level / and experience, the ‘repository’ — where the total experience will be stored and the ‘bloc’ which we can use to interface between the repository and the UI.

Test driven development

So here’s our requirement(s)

A user will need 100 XP for level 1, an additional 200 XP (A total of 300XP) for level 2, an additional 300 XP for level (A total of 600 XP) — and so on.

First, we’ll write out some test cases, using the format Given, When, Then.

Given-When-Then (GWT) is a semi-structured way to write down test cases.

Given we have 0 XP, then we call ‘calculate’ the level and remaining XP we get in response will be level 0 and 100 XP

Given we have 90 XP, then we call calculate, the level and remaining are 0 and 10 XP

Given we have 100 XP, then we call calculate, the level and remaining are 1 and 200 XP

Given we have 101 XP, then we call calculate, the level and remaining are 199 XP

Given we have 250 XP, then we call calculate, the level and remaining are 1 and 50 XP

Given we have 300 XP, then we call calculate, the level and remaining are 2 and 300 XP

First, let’s define the models

In order for us to write a test for the input and output, we will need to define the input and output. We’ll first create two models:

class Experience extends Equatable {
final double number;
Experience(this.number);}

and

class Level  extends Equatable{
final int level;
final double experienceToNextLevel;

Level(this.level, this.experienceToNextLevel);

@override
List<Object> get props => [this.level, this.experienceToNextLevel];
}

A note on the @override here, it is the equatable library that we imported earlier — essentially what happens when we use == between two objects of level is that it will check the override of each and compare the values of the return props, e.g. if the level and experienceToLevel are equal then the objects are equal.

Logic

This is usually the easiest thing to unit test, often examples have ‘sum’ as an example, ours has a bit more to it because we are not using a primitive type for our input / output, but let’s first take one of our requirements listed above and see what that would look like as a unit test.

Given we have 0 XP, then we call ‘calculate’ the level and remaining XP we get in response will be level 0 and 100 XP

I’m going to create our test class, and assume we’re calling the file holding this class user_level_calculator.dart so I’ll create a file in test called user_level_calculator_test.dart (note: the name is important to end with _test to be picked up by flutter test)

Here’s a snapshot of what my packages look like:

Now there’s two ways of approaching this next part, either by stubbing the class, or by writing the test and using android’s refactor tools. Personally, I prefer creating the class and stubbing it:

class UserLevelCalculator {
Level currentLevelForExperience(Experience experience) => null;
}

Now, let’s start writing our test. Our first test is based on our first requirements:

Given we have 0 XP, then we call ‘calculate’ the level and remaining XP we get in response will be level 0 and 100 XP

This looks something like this

void main() {
final calculator = UserLevelCalculator();

test(
"Given we have 0 XP, then we call 'calculate' the level and remaining XP we get in response will be level 0 and 100 XP", () {

final zeroExp = Experience(0);

expect(calculator.currentLevelForExperience(zeroExp), Level(0, 100));
});
}

The test itself is fairly self explanatory, and matches our requirement.

An important part of test driven development is to run the test so that it fails

Do not skip this step, it takes no time at all and can pick up irritating issues before you’re expecting them to pass for real.

What you do next is more a of a personal preference thing, you can either write the code to make that test pass, or continue writing the rest of your tests before writing any code. I tend to prefer the second option, but sometimes I’ll make just the first test pass in a simple way, then write the rest of the tests before writing anymore code.

For example:

class UserLevelCalculator {
Level currentLevelForExperience(Experience experience) => 100;
}

If we keep running through all the tests, we’ll end up with something like this

test("Given we have 90 XP, then we call calculate, the level and remaining are 0 and 10 XP", () {
final exp = Experience(90);
expect(calculator.currentLevelForExperience(exp), Level(0, 10));
});
test("Given we have 100 XP, then we call calculate, the level and remaining are 1 and 200 XP", () {
final exp = Experience(100);
expect(calculator.currentLevelForExperience(exp), Level(1, 200));
});
test("Given we have 101 XP, then we call calculate, the level and remaining are 199 XP", () {
final exp = Experience(101);
expect(calculator.currentLevelForExperience(exp), Level(1, 199));
});
test("Given we have 250 XP, then we call calculate, the level and remaining are 2 and 50 XP", () {
final exp = Experience(250);
expect(calculator.currentLevelForExperience(exp), Level(1, 50));
});
test("Given we have 300 XP, then we call calculate, the level and remaining are 2 and 300 XP", () {
final exp = Experience(300);
expect(calculator.currentLevelForExperience(exp), Level(2, 300));
});

And running these tests will fail. So let’s make that not true; there are two ways of doing this, and I alternate between the two: you can either make each test pass one at a time, or you can write some code… see what passes and what fails, then write some more. This is what it looks like at the end of the process

class UserLevelCalculator {
Level currentLevelForExperience(Experience experience) {
int currentLevel = 1;
int experienceToLevel = currentLevel * 100;

while (experience.number >= experienceToLevel) {
currentLevel++;
experienceToLevel = experienceToLevel + (currentLevel * 100);
}

return Level(currentLevel - 1, experienceToLevel - experience.number);
}
}

Repository

We’re going to store a user’s current experience in a repository. There’s a few different places I could store this in flutter — for example, I could store it locally as a file, in SQL, in firebase or even as shared preferences. Rather than having specific implementations for this in each of my repositories, I often have a BaseRepository

abstract class BaseRepository {
Future<dynamic> retrieve(String key);

Future<void> store(String key, dynamic value);
}

And make use of this base repository in my implementation e.g.

ExpRepository {
final BaseRepository baseRepository;
ExpRepository(this.baseRepository);
}

There’s not a whole lot of logic I’m going to add into my base repository, other than two functions:

Future<Experience> getExperience();
Future<void> logExperience(Experience experience);

Here are my requirements for the repository

If no data is stored in the repository, return an Experience with zero in it.

If data is stored in the repository, return that data.

If I store data in the repository, I should then be able to retrieve that data.

Here is where I want to introduce a concept called ‘fakes’.

Fakes

Fakes are ‘test doubles’ which are a real implementation, but not production code. In reality, my data will be stored on disk — in my fake, for this repository, I am going to pass in a ‘fake’ BaseRepository which just holds values in a map

class BaseRepositoryMemory implements BaseRepository {

Map<String, dynamic> _map = Map();

@override
Future<dynamic> retrieve(String key) {
return Future.value(_map[key]);
}

Future<void> store(String key, dynamic value) {
_map[key] = value;
return Future.value();
}
}

So, my tests can now be written because I have the input an outputs I expect.

Let’s start stubbing:

void main() {
BaseRepositoryMemory repository;

setUp(() {
repository = BaseRepositoryMemory();
});
test(
"If no data is stored in the repository, return an Experience with zero in it.",
() {});
test("If data is stored in the repository, return that data.", () {});
test(
"If I store data in the repository, I should then be able to retrieve that data.",
() {});
}

As you can see we’re constructing our BaseRepository, which we can then use to construct our class under test.

For example:

test(
"If no data is stored in the repository, return an Experience with zero in it.",
() async {
final expRepo = ExpRepository(repository);
expect(await expRepo.getExperience(), Experience(0));
});

And another

final expectedExperience = Experience(1000);
repository.store("experience", expectedExperience);
final expRepo = ExpRepository(repository);
expect(await expRepo.getExperience(), expectedExperience);

And finally

final expectedExperience = Experience(1000);
final expRepo = ExpRepository(repository);
await expRepo.logExperience(expectedExperience);
expect(await expRepo.getExperience(), expectedExperience);

And the code that we write will look like this:

class ExpRepository {
final BaseRepository baseRepository;

ExpRepository(this.baseRepository);

Future<Experience> getExperience() async {
final data = await baseRepository.retrieve("experience");
return Future.value(data ?? Experience(0));
}

Future<void> logExperience(Experience experience) async {
await baseRepository.store("experience", experience);
}
}

This is a fairly simplistic example, obviously some repositories do a lot more than is worth testing — perhaps pulling from one source and putting into another, perhaps if it involves network connection then surfacing an error etc but this simple example should be enough to demonstrate the basics of test driven development of a repository.

BLoC

This is the meat of the matter, we’re going to look at testing BLoCs using TDD. One of the things I like best about the BLoC architecture is the way we able to use it for test driven development, everything we need is integral to the pattern itself. A bloc consists of ‘Events’ which are inputs, and ‘State’ which are outputs. In addition, the bloc test library makes it especially easy for us to test blocs.

As with other tests, we’ll start with our requirements — which are now driven by what we want to display on the screen.

Given a user who has never completed a task, we want to see zero experience on the screen.

We want to be able for a user to complete a task and that experience is stored, and a new level is calculated.

We want if a user has completed a task, upon re-entering the screen, to see their level and experience calculated.

Our events are therefore going to be

  • AddExperience
  • Refresh

And our State is going to be

  • HasData
  • InitialState (because we’ll need one anyway)

Again, this is a really simple example but is useful to explain our point. For generating blocs I like to this extension:

This will gives us the following event already generated

abstract class LevelEvent extends Equatable {
const LevelEvent();
}

And if we build on that for our two events

class AddExperience extends LevelEvent {
final Experience experience;

AddExperience(this.experience);

@override
List<Object> get props => [this.experience];
}

class RefreshEvent extends LevelEvent {
@override
List<Object> get props => [];
}

And our expected states, one already generated

abstract class LevelState extends Equatable {
const LevelState();
}

class InitialLevelState extends LevelState {
@override
List<Object> get props => [];
}

Plus our has data state

class HasDataState extends LevelState {
final Level level;

HasDataState(this.level);

@override
List<Object> get props => [this.level];
}

So our tests are going to look a bit different as we’re going to use blocTest, so for our stubs

blocTest(
"Given a user who has never completed a task, we want to see zero experience on the screen.",
build: () => LevelBloc(), expect: [InitialLevelState()])
blocTest(
"We want to be able for a user to complete a task and that experience is stored, and a new level is calculated. ",
build: () => LevelBloc(), expect: [InitialLevelState()])
blocTest(
"We want if a user has completed a task, upon re-entering the screen, to see their level and experience calculated.",
build: () => LevelBloc(), expect: [InitialLevelState()])

Our bloc is already generated with some stub code, so let’s just modify that to add the experience repository into it.

class LevelBloc extends Bloc<LevelEvent, LevelState> {

final ExpRepository repository;

LevelBloc(this.repository);

@override
LevelState get initialState => InitialLevelState();

@override
Stream<LevelState> mapEventToState(
LevelEvent event,
) async* {
// TODO: Add Logic
}
}

We even get a helpful TODO to remind us to add logic.

Mocks

In our previous examples, we used a fake, which is a type of double. A mock is another type of double which is used for ‘mocking’ behaviour — it differs from fakes in that it is not an implementation of an interface or class, but the calls are mocked and are able to be verified. The topic of mockito or mocking is a bit beyond this article, but suffice to say we are going to use a mock here — as it allows us to properly test our BLoC in isolation.

We’ll implement our mockk in a file called mocks.dart like this:

class MockExpRepository extends Mock implements ExpRepository {
}

We now have fine grained control over what is returned, so let’s start with some setup code and a few functions to generate the correct state for each test

The setup:

MockExpRepository expRepository;

setUp(() {
expRepository = MockExpRepository();
});

Then, mostly to keep things neat in our test code we’ll create a function to setup state

LevelBloc buildBlockWithNoUserExperience(MockExpRepository expRepository) {
when(expRepository.getExperience()).thenAnswer((_) => Future.value(Experience(0)));
return LevelBloc(expRepository);
}

So we want to verify that when we have no experience, we call refresh then we get our response from the BLoC which will be a hasData state with specific outputs. Our test will look like this

blocTest(
"Given a user who has never completed a task, we want to see zero experience on the screen.",
build: () => buildBlockWithNoUserExperience(expRepository),
act: (bloc) => bloc.add(RefreshEvent()),
expect: [isA<InitialLevelState>(), HasDataState(Level(0, 100))],
);

With this example, let’s first make this test pass before moving on to the next test. Diving in to our BLoC it becomes apparent that we want to make use of our Calculator from earlier. We have to options here, we can pass the calculator in and mock the response to get predictable outputs from it, or, we can pass in the concrete implementation for our test.

The purest way of isolating this test from other classes is to pass a mock, and so we’ll do that here. First, let’s update our BLoC code:

class LevelBloc extends Bloc<LevelEvent, LevelState> {

final ExpRepository repository;
final UserLevelCalculator calculator;

LevelBloc(this.repository, this.calculator);

@override
LevelState get initialState => InitialLevelState();

@override
Stream<LevelState> mapEventToState(
LevelEvent event,
) async* {
// TODO: Add Logic
}
}

Now, let’s mock add a mock for the calculator.

class MockUserLevelCalculator extends Mock implements UserLevelCalculator {

}

And then let’s pass in that mock to our class under test

void main() {
MockExpRepository expRepository;
MockUserLevelCalculator calculator;

setUp(() {
expRepository = MockExpRepository();
calculator = MockUserLevelCalculator();
});

blocTest(
"Given a user who has never completed a task, we want to see zero experience on the screen.",
build: () => buildBlockWithNoUserExperience(expRepository, calculator),
act: (bloc) => bloc.add(RefreshEvent()),
expect: [isA<InitialLevelState>(), HasDataState(Level(0, 100))],
);
}

LevelBloc buildBlockWithNoUserExperience(
MockExpRepository expRepository, MockUserLevelCalculator calculator) {
when(expRepository.getExperience())
.thenAnswer((_) => Future.value(Experience(0)));

when(calculator.currentLevelForExperience(Experience(0))).thenReturn(Level(0, 100));

return LevelBloc(expRepository, calculator);
}

Back to that first test, let’s make it pass:

@override
Stream<LevelState> mapEventToState(
LevelEvent event,
) async* {
if (event is RefreshEvent) {
yield HasDataState(calculator.currentLevelForExperience(await this.repository.getExperience()));
}
}

Now moving on to the next test we have a few more things going on in our builder

LevelBloc buildBlocWhereAUserWillCompleteATask(
MockExpRepository expRepository, MockUserLevelCalculator calculator) {

// Our expected pattern here is the first time we call
// we'll have 100 experience, then we'll add 200 experience
// then the next call we'll return 200 experience
final experienceList = List()
..add(Experience(250))
..add(Experience(50));

when(expRepository.getExperience())
.thenAnswer((_) => Future.value(experienceList.removeLast()));

when(calculator.currentLevelForExperience(Experience(250)))
.thenReturn(Level(2, 50));

return LevelBloc(expRepository, calculator);
}

And our actual test

blocTest(
"We want to be able for a user to complete a task and that experience is stored, and a new level is calculated. ",
build: () => buildBlocWhereAUserWillCompleteATask(expRepository, calculator),
act: (bloc) => bloc.add(AddExperience(Experience(200))),
verify: () async {
verify(expRepository.logExperience(Experience(250))).called(1);
},
expect: [InitialLevelState(), HasDataState(Level(2, 50))]);

There’s an extra step in thie verify: What we’re doing is ensuring that we are (as we say in our requirement) calling ‘log experience’.

And to implement this test:

if (event is AddExperience) {
final newExperience = Experience((await repository.getExperience()).number + event.experience.number);
repository.logExperience(newExperience);
yield HasDataState(calculator.currentLevelForExperience(newExperience));
}

We have a final test, nothing extra in this but for completeness:

LevelBloc buildBlockWithSomeExperience(
MockExpRepository expRepository, MockUserLevelCalculator calculator) {
when(expRepository.getExperience())
.thenAnswer((_) => Future.value(Experience(100)));

when(calculator.currentLevelForExperience(Experience(100)))
.thenReturn(Level(1, 0));

return LevelBloc(expRepository, calculator);
}

And the test

blocTest(
"We want if a user has completed a task, upon re-entering the screen, to see their level and experience calculated.",
build: () => buildBlockWithSomeExperience(expRepository, calculator),
act: (bloc) => bloc.add(RefreshEvent()),
expect: [InitialLevelState(), HasDataState(Level(1, 0))]);

This test actually passes with no further changes…

Summary

In this article we have covered

  • Fakes
  • Mocks
  • Testing logic
  • Testing repositories
  • Testing Blocs
  • Verifying mocks were called
  • Using mocks to return basic data based on input and output

Hope you enjoyed it — I always appreciate a clap or a follow.

--

--

--

Love podcasts or audiobooks? Learn on the go with our new app.

Recommended from Medium

TryHackMe — Brainstorm

String methods in Ruby

IBM Business Automation Workflow Adding Automatic Data Refresh to A View

Digital Glossary — the building block of Agile Transformation

ETH is the future — MVU Cloud Mining Review

HOT’s Tasking Manager 4: How we built it

Amazing Side Hustles For Programmers

Asynchronous Interfaces

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Daniel Llewellyn

Daniel Llewellyn

More from Medium

Non-stop debugging (1) — Flutter Web CORS Error

Build and Deploy Flutter Application using CI/CD Azure Pipelines DevOps

How to Install Flutter Software Development Kit in Linux Systems?

Enhance your Flutter development experience with Mason