Notes by Lesnitsky Subscribe

#bloc ยท 2020-05-13 04:37 PM Twitter Logo

Bloc: Red, Green, Refactor

This article shows how to apply test-driven development practice together with BLoC. I'll test and implement AuthBloc as an example

Initial setup

Let's add bloc, bloc_test, and mockito to the dependencies

dependencies:
bloc: ^4.0.0
dev_dependencies:
bloc_test: ^5.1.0 mockito: ^4.1.1

define a skeleton of our AuthBloc

class AuthState {}

class AuthEvent {}

class AuthBloc extends Bloc<AuthEvent, AuthState> {
@override
// TODO: implement initialState
AuthState get initialState => throw UnimplementedError();

@override
Stream<AuthState> mapEventToState(AuthEvent event) {
// TODO: implement mapEventToState
throw UnimplementedError();
}
}

and make some preparation in tests

import 'package:bloc_auth_tdd/auth_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';

void main() {
AuthBloc bloc;

// setUp is called before each unit test
setUp(() {
bloc = AuthBloc();
});
}

The first test

The easiest thing to test is initialState, so let's write a tiny unit test for it

group('AuthBloc', () {
test('has unresolved initial state', () {
expect(bloc.initialState, isA<UnresolvedState>());
});
});

UnresolvedState isn't defined yet, let's fix it

class UnresolvedState extends AuthState {}

If we'll run our first test, it will fail, since we didn't implement initialState getter, but that's the point of TDD. You need to see the test red first.

The implementation is fairly straightforward:

@override
AuthState get initialState => UnresolvedState();

And now the test is green โœ…๐ŸŽ‰

Setting up entities

To move forward we need to define more entities, like User

class User {
String id;
String name;

User(this.id, this.name);
}

authentication service

abstract class AuthService {
User readAuthFromStorage();
Future<User> signIn(Credentials credentials);
Future<void> signOut();
}

and credentials object

class Credentials {
String username;
String password;

Credentials(this.username, this.password);
}

We'll need to inject AuthService into AuthBloc

class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthService authService;

AuthBloc(this.authService); ...

mock it using mockito

class MockAuthService extends Mock implements AuthService {}

and fix the test

AuthBloc bloc;
AuthService authService;

// setUp is called before each unit test
setUp(() {
authService = MockAuthService();
bloc = AuthBloc(authService);
});

Auth Verification

The result of successful authentication is AuthenticatedState of AuthBloc

class AuthenticatedState extends AuthState {
User user;

AuthenticatedState(this.user);
}

Let's add a verification function which will be used later in several tests:

final mockUser = User('42', 'testuser');
Future<void> verifyAuthenticatedUser(AuthBloc bloc) async {
final state = bloc.state;

if (state is AuthenticatedState) {
expect(state.user, equals(mockUser));
}
}

Authentication events

There are two ways to get into the authenticated state:

The user was previously authenticated and auth info was persisted on the device

class RestoreAuthEvent extends AuthEvent {}

or user signed in with correct credentials

class SignInEvent extends AuthEvent {
Credentials credentials;
SignInEvent(this.credentials);
}

final mockCorrectCredentials = Credentials('username', 'password');

If the user wasn't previously authenticated or used the wrong credentials, we'll get into UnauthenticatedState

class UnauthenticatedState extends AuthState {}

Now let's cover these cases with tests

RestoreAuthEvent:

group('if user was previously authenticated', () {
blocTest<AuthBloc, AuthEvent, AuthState>(
'emits LoadingState, then AuthenticatedState when RestoreAuthEvent was added',
build: () async => bloc,
act: (bloc) async {
when(authService.readAuthFromStorage())
.thenAnswer((_) async => mockUser);
bloc.add(RestoreAuthEvent());
},
verify: verifyAuthenticatedUser,
expect: [isA<LoadingState>(), isA<AuthenticatedState>()],
);
});

SignInEvent:

blocTest<AuthBloc, AuthEvent, AuthState>(
'emits LoadingState, then AuthenticatedState when SignInEvent was added with correct credentials',
build: () async => bloc,
act: (bloc) async {
when(authService.signIn(mockCorrectCredentials))
.thenAnswer((_) async => mockUser);

bloc.add(SignInEvent(mockCorrectCredentials));
},
verify: verifyAuthenticatedUser,
expect: [isA<LoadingState>(), isA<AuthenticatedState>()],
);

Tests are red, let's implement an actual functionality

RestoreAuthEvent:

@override
Stream<AuthState> mapEventToState(AuthEvent event) async* {
switch (event.runtimeType) {
case RestoreAuthEvent:
yield LoadingState();
yield await restoreAuth();
break;
}
}
Future<AuthState> restoreAuth() async {
final user = await authService.readAuthFromStorage();
if (user == null) {
return UnauthenticatedState();
} else {
return AuthenticatedState(user);
}
}

SignInEvent:

... Stream<AuthState> mapEventToState(AuthEvent event) async* { switch (event.runtimeType) { ... case SignInEvent:
yield LoadingState();
yield await signIn((event as SignInEvent).credentials);
break; ...
Future<AuthState> signIn(Credentials credentials) async {
final user = await authService.signIn(credentials);
if (user == null) {
return UnauthenticatedState();
} else {
return AuthenticatedState(user);
}
}

SignOut

AuthBloc should also be capable of SignOut, let's add the corresponding event:

class SignOutEvent extends AuthEvent {}

Test the bloc:

blocTest<AuthBloc, AuthEvent, AuthState>(
'calls signOut of AuthService and emits UnauthenticatedState when SignOutEvent was added',
build: () async => bloc,
act: (bloc) async {
bloc.add(SignOutEvent());
},
verify: (bloc) async {
final state = bloc.state;
if (state is UnauthenticatedState) {
expect(verify(authService.signOut()).callCount, 1);
}
},
expect: [isA<UnauthenticatedState>()],
);

and implement it:

@override
Stream<AuthState> mapEventToState(AuthEvent event) async* {
switch (event.runtimeType) { ...
case SignOutEvent:
await authService.signOut();
yield UnauthenticatedState();
break;
}
}

Collecting test coverage

Now we have all tests written, functionality implemented, let's collect some statistics:

flutter test --coverage
genhtml -o coverage coverage/lcov.info
open coverage/index.html

Wait, what? Test coverage is 92.3%... How is this possible, tests were written first. Let's check what lines are not covered:

coverage

Ok, so turns out we've implemented restoreAuth and signIn with UnauthenticatedState in mind, but forgot to cover these branches of code with tests. Let's fix it!

Covering uncovered

RestoreAuthEvent:

group("if user wasn't previously authenticated", () {
blocTest<AuthBloc, AuthEvent, AuthState>(
'emits LoadingState, then UnauthenticatedState when RestoreAuthEvent was added',
build: () async => bloc,
act: (bloc) async {
when(authService.readAuthFromStorage()).thenAnswer((_) async => null);
bloc.add(RestoreAuthEvent());
},
expect: [isA<LoadingState>(), isA<UnauthenticatedState>()],
);
});

SignInEvent with wrong credentials:

blocTest<AuthBloc, AuthEvent, AuthState>(
'emits LoadingState, then UnauthenticatedState when SignInEvent was added with wrong credentials',
build: () async => bloc,
act: (bloc) async {
when(authService.signIn(any)).thenAnswer((invocation) async => null);

when(authService.signIn(mockCorrectCredentials))
.thenAnswer((invocation) async => mockUser);

bloc.add(SignInEvent(Credentials('1234', '5678')));
},
expect: [isA<LoadingState>(), isA<UnauthenticatedState>()],
);

Refactor

Both restoreAuth and signIn have the same piece of code:

if (user == null) {
return UnauthenticatedState();
} else {
return AuthenticatedState(user);
}

which could be moved to a separate helper function

_resolveStateFromUser(User user) {
if (user == null) {
return UnauthenticatedState();
} else {
return AuthenticatedState(user);
}
}

Run tests again

This was a simple refactor, but we have a guarantee nothing was accidentally broken โ€“ we have tests! That's the most important part: once you covered your bloc with tests, feel free to refactor it as long as you want, you'll have those tests watching you ๐Ÿ‘€

Update from BLoC creator Felix Angelov

Blocs should be closed after each test, so let's add this code to tests setup

tearDown(() {
bloc.close();
});

One more way to verify valid states is to use matcher package

dev_dependencies:
matcher: ^0.12.6

and replace previously implemented verifyAuthenticatedUser with something like this:

Matcher isAuthenticatedState(User user) {
return const TypeMatcher<AuthenticatedState>()
.having((state) => state.user, 'user', user);
}

and use it in blocTest

blocTest<AuthBloc, AuthEvent, AuthState>( ...
expect: [isA<LoadingState>(), isAuthenticatedState(mockUser)],
);

Source code:

https://github.com/lesnitsky/bloc_auth_tdd