iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 19
1

今天就來為FlutTube專案內的Bloc(Authentication Bloc、Login Bloc、Register Bloc)寫測試吧。

Mockito

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

再次提醒記得要在pubspec.yaml引用test和Mockito套件


※ Authentication和Login Bloc程式碼參考於Felix Angelov的Medium文章


Authentication Bloc Test

在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」的情況,分成兩種

  1. 使用者先前有登入過
  2. 使用者沒有登入過
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());
        });
  });

Login Bloc Test

在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'));
    });
  });

Register Bloc Test

在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


上一篇
【Flutter基礎概念與實作】 Day18–Flutter測試框架以及Mockito Package使用範例介紹
下一篇
【Flutter基礎概念與實作】 Day20–測試Movie API和Movie BLoC
系列文
Flutter---Google推出的跨平台框架,Android、iOS一起搞定30

尚未有邦友留言

立即登入留言