昨天最後有介紹到Mockito這個套件的用途和用法,由於今天要測試的Bloc內的商業邏輯都有使用的網路的服務(Firebase、TMDb API...),所以需要使用Mockito來為我們模擬這些服務加速運行測試的速度。
再次提醒記得要在pubspec.yaml引用test和Mockito套件

※ Authentication和Login Bloc程式碼參考於Felix Angelov的Medium文章
在test/bloc資料夾下新增「authentication_bloc_test.dart」
引用所需的檔案和套件,並建立一個mock UserRepository 的實體來模擬原本的UserRepository。
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:fluttube/firebase/user_repository.dart';
import 'package:fluttube/authentication_bloc/bloc.dart';
class MockUserRepository extends Mock implements UserRepository {}
還記得昨天提過的setUp()嗎?  setUp內的程式碼會在每一個測試開始前執行,在這裡設定每次測試開始前初始化MockUserRepository和AuthenticationBloc。
void main() {
  AuthenticationBloc authenticationBloc;
  MockUserRepository userRepository;
  setUp(() {
    userRepository = MockUserRepository();
    authenticationBloc = AuthenticationBloc(userRepository: userRepository);
  });
  
  ...
}
第一個要測試的是Bloc的初始值是否正確以及關閉(dispose)Bloc後還有沒有產生State。
test('initial state is correct', () {
    expect(authenticationBloc.initialState, Uninitialized());
  });
test('dispose does not emit new states', () {
    expectLater(
      authenticationBloc.state,
      emitsInOrder([]),
    );
    authenticationBloc.dispose();
  });
emitsInOrder([])會回傳StreamMatcher,用來和authenticationBloc.state比較。
測試當Event是「AppStarted」的情況,分成兩種
group('AppStarted', () {
    test('emits [uninitialized, unauthenticated] for invalid token', () {
      final expectedResponse = [
        Uninitialized(),
        Unauthenticated()
      ];
      when(userRepository.isSignedIn()).thenAnswer((_) => Future.value(false));
      expectLater(
        authenticationBloc.state,
        emitsInOrder(expectedResponse),
      );
      authenticationBloc.dispatch(AppStarted());
    });
    test('emits [uninitialized, authenticated] for valid token', () {
      final expectedResponse = [
        Uninitialized(),
        Authenticated("tester")
      ];
      when(userRepository.isSignedIn()).thenAnswer((_) => Future.value(true));
      when(userRepository.getUser()).thenAnswer((_) => Future.value('tester'));
      expectLater(
        authenticationBloc.state,
        emitsInOrder(expectedResponse),
      );
      authenticationBloc.dispatch(AppStarted());
    });
  });
測試觸發Login和Logout Event,State是否有正確轉換。
group('LoggedIn', () {
    test(
        'emits [uninitialized, authenticated] when token is persisted',
            () {
          final expectedResponse = [
            Uninitialized(),
            Authenticated("tester"),
          ];
          when(userRepository.getUser()).thenAnswer((_) => Future.value("tester"));
          expectLater(
            authenticationBloc.state,
            emitsInOrder(expectedResponse),
          );
          authenticationBloc.dispatch(LoggedIn());
        });
  });
  group('LoggedOut', () {
    test(
        'emits [uninitialized, unauthenticated] when token is deleted',
            () {
          final expectedResponse = [
            Uninitialized(),
            Unauthenticated(),
          ];
          expectLater(
            authenticationBloc.state,
            emitsInOrder(expectedResponse),
          );
          authenticationBloc.dispatch(LoggedOut());
        });
  });
在test/bloc資料夾下新增「login_bloc_test.dart」
引用所需的檔案和套件,並建立一個mock UserRepository 的實體來模擬原本的UserRepository。
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:fluttube/firebase/user_repository.dart';
import 'package:fluttube/login/bloc/bloc.dart';
class MockUserRepository extends Mock implements UserRepository {}
使用setUp()在每次測試開始前初始化userRepository和loginBloc。
void main() {
  MockUserRepository userRepository;
  LoginBloc loginBloc;
  setUp(() {
    userRepository = MockUserRepository();
    loginBloc = LoginBloc(userRepository: userRepository);
  });
測試initState和dispose是否正常運作。
這裡需要使用到toString()把State內的值轉成字串來做比較,因為LoginState並沒有繼承Equatable協助比較兩個實體是否相同。所以即使比較的兩個實體內的屬性值都相同,仍然會被判定為「不等於」,導致測試不會過。
而AuthenticationState有繼承Equatable所以可以直接進行比較
你也可以選擇Override LoginState的hashCode,也能有同樣的效果
test('initial state is correct', () {
    expect(LoginState.empty().toString(), loginBloc.initialState.toString());
  });
  test('dispose does not emit new states', () {
    expectLater(
      loginBloc.state,
      emitsInOrder([]),
    );
    loginBloc.dispose();
  });
測試輸入正確和錯誤的帳號密碼,LoginState有無正確的轉換。
group('LoginButtonPressed', () {
    test('emits token on success', () {
      final expectedResponse = [
        LoginState.empty().toString(),
        LoginState.loading().toString(),
        LoginState.success().toString(),
      ];
      expectLater(
        loginBloc.state.map((state) => state.toString()),
        emitsInOrder(expectedResponse),
      );
      loginBloc.dispatch(LoginWithCredentialsPressed(
        email: 'tester123@gmail.com',
        password: 'password123',
      ));
    });
    test('throw error on fail', (){
      final expectedResponse = [
        LoginState.empty().toString(),
        LoginState.loading().toString(),
        LoginState.failure().toString(),
      ];
      when(userRepository.signInWithCredentials("wrong", "wrong")).thenThrow(Exception);
      expectLater(
        loginBloc.state.map((state) => state.toString()),
        emitsInOrder(expectedResponse),
      );
      loginBloc.dispatch(LoginWithCredentialsPressed(
        email: 'wrong',
        password: 'wrong',
      ));
    });
  });
測試Validator有沒有正確的檢查信箱和密碼格式。
需要加上      await Future.delayed(Duration(milliseconds: 400))是因為在實作LoginBloc時有使用到debounceTime讓Validator會等待使用者0.3秒的時間才進行驗證,所以這邊也必須等到debounceTime到了才再觸發Event。
group('test validation do the right judgement', (){
    test('test email validator', () async{
      final expectedResponse = [
        LoginState.empty().toString(),
        LoginState.empty().update(isEmailValid: false, isPasswordValid: true).toString(),
        LoginState.empty().update(isEmailValid: true, isPasswordValid: true).toString(),
      ];
      expectLater(
        loginBloc.state.map((state) => state.toString()),
        emitsInOrder(expectedResponse),
      );
      loginBloc.dispatch(EmailChanged(email: 'WRONG'));
      await Future.delayed(Duration(milliseconds: 400));
      loginBloc.dispatch(EmailChanged(email: 'GOOD@gmail.com'));
    });
    test('test password validator', () async{
      final expectedResponse = [
        LoginState.empty().toString(),
        LoginState.empty().update(isEmailValid: true, isPasswordValid: false).toString(),
        LoginState.empty().update(isEmailValid: true, isPasswordValid: true).toString(),
      ];
      expectLater(
        loginBloc.state.map((state) => state.toString()),
        emitsInOrder(expectedResponse),
      );
      loginBloc.dispatch(PasswordChanged(password: 'Bad'));
      await Future.delayed(Duration(milliseconds: 400));
      loginBloc.dispatch(PasswordChanged(password: 'goodPassword123'));
    });
  });
在test/bloc資料夾下新增「register_bloc_test.dart」
Register和Login的測試程式碼基本上相同,就不額外解釋囉。
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:fluttube/firebase/user_repository.dart';
import 'package:fluttube/register/bloc/bloc.dart';
class MockUserRepository extends Mock implements UserRepository {}
void main() {
  MockUserRepository userRepository;
  RegisterBloc registerBloc;
  setUp(() {
    userRepository = MockUserRepository();
    registerBloc = RegisterBloc(userRepository: userRepository);
  });
  test('initial state is correct', () {
    expect(RegisterState.empty().toString(), registerBloc.initialState.toString());
  });
  test('dispose does not emit new states', () {
    expectLater(
      registerBloc.state,
      emitsInOrder([]),
    );
    registerBloc.dispose();
  });
  group('LoginButtonPressed', () {
    test('emits token on success', () {
      final expectedResponse = [
        RegisterState.empty().toString(),
        RegisterState.loading().toString(),
        RegisterState.success().toString(),
      ];
      expectLater(
        registerBloc.state.map((state) => state.toString()),
        emitsInOrder(expectedResponse),
      );
      registerBloc.dispatch(Submitted(
        email: 'tester123@gmail.com',
        password: 'password123',
      ));
    });
    test('throw error on fail', (){
      final expectedResponse = [
        RegisterState.empty().toString(),
        RegisterState.loading().toString(),
        RegisterState.failure().toString(),
      ];
      when(userRepository.signUp(email: "wrong", password: "wrong")).thenThrow(Exception);
      expectLater(
        registerBloc.state.map((state) => state.toString()),
        emitsInOrder(expectedResponse),
      );
      registerBloc.dispatch(Submitted(
        email: 'wrong',
        password: 'wrong',
      ));
    });
  });
  group('test validation do the right judgement', (){
    test('test email validator', () async{
      final expectedResponse = [
        RegisterState.empty().toString(),
        RegisterState.empty().update(isEmailValid: false, isPasswordValid: true).toString(),
        RegisterState.empty().update(isEmailValid: true, isPasswordValid: true).toString(),
      ];
      expectLater(
        registerBloc.state.map((state) => state.toString()),
        emitsInOrder(expectedResponse),
      );
      registerBloc.dispatch(EmailChanged(email: 'WRONG'));
      await Future.delayed(Duration(milliseconds: 400));
      registerBloc.dispatch(EmailChanged(email: 'GOOD@gmail.com'));
    });
    test('test password validator', () async{
      final expectedResponse = [
        RegisterState.empty().toString(),
        RegisterState.empty().update(isEmailValid: true, isPasswordValid: false).toString(),
        RegisterState.empty().update(isEmailValid: true, isPasswordValid: true).toString(),
      ];
      expectLater(
        registerBloc.state.map((state) => state.toString()),
        emitsInOrder(expectedResponse),
      );
      registerBloc.dispatch(PasswordChanged(password: 'Bad'));
      await Future.delayed(Duration(milliseconds: 400));
      registerBloc.dispatch(PasswordChanged(password: 'goodPassword123'));
    });
  });
}
今天幫Authentication、Login、Register三個Bloc寫了測試,確保State都有在正確的時機和順序被轉換。
經過實際使用Mockito後應該能瞭解使用Mock服務的好處,除了能省去網路傳輸的不穩定以及測試時間外。假如是要測試像「SignUp」的功能,應該不可能每次都輸入不同的帳號密碼去實際註冊測試對吧,但是用模擬的方式就不受影響。
明天會完成Movie Bloc和Movie API的測試。
完整程式碼在這裡-> FlutTube Github