LogoSkills

Unit Test Patterns

Standard patterns for writing BLoC and UseCase unit tests.

Standard patterns for writing BLoC and UseCase unit tests.

Test Strategy Overview#

Changes After DI Decoupling#

Since Optional Constructor Injection pattern was applied in Epic #3564, inject mocks directly via constructor instead of registering in GetIt.

// ✅ NEW: Direct constructor injection (after DI decoupling)
bloc = MyBloc(getDataUseCase: mockGetData);

// ❌ OLD: GetIt registration approach (legacy, prohibited)
registerTestLazySingleton<IRepository>(mockRepository);
bloc = MyBloc(); // 내부에서 getIt으로 가져옴

Mock Libraries#

LibraryPurposeWhen to Use
mocktailStandardWhen writing new tests
mockito + @GenerateNiceMocks Legacy When maintaining existing tests

New tests use mocktail (no code generation needed, concise).


UseCase Test Patterns#

Basic Structure#

import 'package:core/core.dart';
import 'package:dependencies/dependencies.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

// Mock 클래스 정의
class MockIBookRepository extends Mock implements IBookRepository {}

void main() {
  late MockIBookRepository mockRepository;
  late GetBookUseCase useCase;

  setUp(() {
    mockRepository = MockIBookRepository();
    // ✅ Register mock in GetIt (UseCase internally gets repo via getIt)
    getIt.registerFactory<IBookRepository>(() => mockRepository);
    useCase = const GetBookUseCase();
  });

  tearDown(() => getIt.reset());

  group('GetBookUseCase', () {
    const tBookId = 1;
    final tBook = Book(id: tBookId, title: '테스트 도서');

    test('should_return_book_when_repository_succeeds', () async {
      // Arrange
      when(() => mockRepository.getBook(tBookId))
          .thenAnswer((_) async => Right(tBook));

      // Act
      final result = await useCase(tBookId);

      // Assert
      expect(result.isRight(), isTrue);
      result.fold(
        (failure) => fail('Expected Right but got Left: $failure'),
        (book) => expect(book.id, equals(tBookId)),
      );
      verify(() => mockRepository.getBook(tBookId)).called(1);
    });

    test('should_return_failure_when_repository_fails', () async {
      // Arrange
      final tFailure = NetworkFailure('연결 실패');
      when(() => mockRepository.getBook(tBookId))
          .thenAnswer((_) async => Left(tFailure));

      // Act
      final result = await useCase(tBookId);

      // Assert
      expect(result.isLeft(), isTrue);
      verifyNever(() => mockRepository.updateBook(any()));
    });
  });
}

Why GetIt is Used in UseCase Tests#

UseCase maintains const constructor and calls getIt() in repo getter:

class GetBookUseCase extends UseCase<Book, int, IBookRepository> {
  const GetBookUseCase();

  @override
  IBookRepository get repo => getIt(); // getIt에서 가져옴
}

Therefore, in UseCase tests, you must register mocks in GetIt:

setUp(() {
  mockRepository = MockIBookRepository();
  getIt.registerFactory<IBookRepository>(() => mockRepository);
  useCase = const GetBookUseCase();
});

tearDown(() => getIt.reset());

AuthRequiredUseCase Test#

UseCases requiring authentication must consider GlobalAuthMixin's auth check:

class MockIAuthRepository extends Mock implements IAuthRepository {}

void main() {
  late MockIBookRepository mockRepository;
  late MockIAuthRepository mockAuthRepository;
  late PurchaseBookUseCase useCase;

  setUp(() {
    mockRepository = MockIBookRepository();
    mockAuthRepository = MockIAuthRepository();
    getIt
      ..registerFactory<IBookRepository>(() => mockRepository)
      ..registerFactory<IAuthRepository>(() => mockAuthRepository);
    useCase = const PurchaseBookUseCase();
  });

  tearDown(() => getIt.reset());

  test('should_return_auth_failure_when_not_authenticated', () async {
    // Arrange: Set up auth failure
    when(() => mockAuthRepository.isAuthenticated())
        .thenAnswer((_) async => false);

    // Act
    final result = await useCase(PurchaseParams(bookId: 1));

    // Assert
    expect(result.isLeft(), isTrue);
  });
}

registerFallbackValue Pattern#

Custom types require registerFallbackValue when using any():

void main() {
  setUpAll(() {
    // Register fallback for custom parameter types
    registerFallbackValue(const CreateBookParams(title: '', authorId: 0));
  });

  // ...
  test('should create book', () async {
    when(() => mockRepository.createBook(any()))
        .thenAnswer((_) async => Right(tBook));
    // ...
  });
}

BLoC Test Patterns#

Basic Pattern BLoC (< 8 UseCases)#

import 'package:bloc_test/bloc_test.dart';
import 'package:core/core.dart';
import 'package:dependencies/dependencies.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

// Mock UseCase 정의
class MockGetBookUseCase extends Mock implements GetBookUseCase {}
class MockDeleteBookUseCase extends Mock implements DeleteBookUseCase {}

void main() {
  late MockGetBookUseCase mockGetBook;
  late MockDeleteBookUseCase mockDeleteBook;
  late BookBloc bloc;

  setUp(() {
    mockGetBook = MockGetBookUseCase();
    mockDeleteBook = MockDeleteBookUseCase();
    // ✅ Direct mock injection via constructor (leveraging DI decoupling)
    bloc = BookBloc(
      getBookUseCase: mockGetBook,
      deleteBookUseCase: mockDeleteBook,
    );
  });

  tearDown(() => bloc.close());

  group('BookBloc', () {
    final tBook = Book(id: 1, title: '테스트 도서');

    group('LoadBook', () {
      blocTest<BookBloc, BookState>(
        'should_emit_loading_and_loaded_when_get_book_succeeds',
        build: () {
          when(() => mockGetBook(any()))
              .thenAnswer((_) async => Right(tBook));
          return bloc;
        },
        act: (bloc) => bloc.add(const BookEvent.load(id: 1)),
        expect: () => [
          isA<BookState>().having(
            (s) => s.status,
            'status',
            isA<BookStatusLoading>(),
          ),
          isA<BookState>()
              .having((s) => s.status, 'status', isA<BookStatusLoaded>())
              .having((s) => s.book, 'book', equals(tBook)),
        ],
      );

      blocTest<BookBloc, BookState>(
        'should_emit_loading_and_error_when_get_book_fails',
        build: () {
          when(() => mockGetBook(any()))
              .thenAnswer((_) async => Left(NetworkFailure('에러')));
          return bloc;
        },
        act: (bloc) => bloc.add(const BookEvent.load(id: 1)),
        expect: () => [
          isA<BookState>().having(
            (s) => s.status,
            'status',
            isA<BookStatusLoading>(),
          ),
          isA<BookState>().having(
            (s) => s.status,
            'status',
            isA<BookStatusError>(),
          ),
        ],
      );
    });
  });
}

Bundle Pattern BLoC (≥ 8 UseCases)#

class MockGetBookUseCase extends Mock implements GetBookUseCase {}
class MockSearchBooksUseCase extends Mock implements SearchBooksUseCase {}
// ... 8개 이상의 mock

void main() {
  late MockGetBookUseCase mockGetBook;
  late MockSearchBooksUseCase mockSearchBooks;
  // ... remaining mock declarations
  late StoreBloc bloc;

  setUp(() {
    mockGetBook = MockGetBookUseCase();
    mockSearchBooks = MockSearchBooksUseCase();
    // ...

    // ✅ Inject mocks into Bundle
    bloc = StoreBloc(
      useCases: StoreUseCases(
        getBook: mockGetBook,
        searchBooks: mockSearchBooks,
        // ... remaining UseCase mocks
      ),
    );
  });

  tearDown(() => bloc.close());

  // blocTest 작성 ...
}

Stream UseCase Test (SWR Pattern)#

blocTest<BookBloc, BookState>(
  'should_emit_cached_then_fresh_data_when_swr_succeeds',
  build: () {
    when(() => mockGetBooksWithCache(any()))
        .thenAnswer((_) => Stream.fromIterable([
              Right(cachedBooks),
              Right(freshBooks),
            ]));
    return bloc;
  },
  act: (bloc) => bloc.add(const BookEvent.loadBooks()),
  expect: () => [
    // First emit: cached data
    isA<BookState>().having((s) => s.books, 'books', cachedBooks),
    // Second emit: server data
    isA<BookState>().having((s) => s.books, 'books', freshBooks),
  ],
);

Naming Conventions#

Test File Names#

TargetFile Name PatternExample
BLoC{bloc_name}_test.dartbook_bloc_test.dart
UseCase {usecase_name}_test.dart get_book_usecase_test.dart

Test Names#

// Pattern: should_{expected_result}_when_{condition}
'should_emit_loaded_when_get_book_succeeds'
'should_emit_error_when_repository_returns_failure'
'should_return_auth_failure_when_not_authenticated'
'should_not_emit_when_bloc_is_closed'

Group Structure#

group('MyBloc', () {
  group('LoadData', () {
    // Success case
    blocTest('should_emit_loaded_when_succeeds', ...);
    // Failure case
    blocTest('should_emit_error_when_fails', ...);
    // Edge case
    blocTest('should_not_reload_when_already_loading', ...);
  });

  group('DeleteData', () { ... });
});

Test Data#

Fixture Pattern#

// Define test data at file top (use t prefix)
final tBook = Book(id: 1, title: '테스트 도서', authorId: 1);
final tBooks = [tBook, Book(id: 2, title: '도서 2', authorId: 2)];
const tParams = GetBookParams(id: 1);
final tFailure = NetworkFailure('연결 실패');

// Or generate via helper function
Book createTestBook({int id = 1, String title = '테스트'}) {
  return Book(id: id, title: title, authorId: 1);
}

Separate Fixture Files (Large-Scale Tests)#

test/
├── src/
│   ├── fixture/
│   │   ├── book_fixtures.dart    # Book 관련 테스트 데이터
│   │   └── user_fixtures.dart    # User 관련 테스트 데이터
│   └── mock/
│       └── mock_repositories.dart # 공통 Mock 클래스

Precautions#

Regarding isClosed Check#

When a BLoC handler has an isClosed check after await, test bloc.close() timing:

blocTest<MyBloc, MyState>(
  'should_not_emit_when_closed_during_async_operation',
  build: () {
    when(() => mockGetData(any()))
        .thenAnswer((_) async {
          // Simulate delay
          await Future<void>.delayed(const Duration(milliseconds: 100));
          return Right(tData);
        });
    return bloc;
  },
  act: (bloc) async {
    bloc.add(const MyEvent.load());
    await Future<void>.delayed(const Duration(milliseconds: 50));
    await bloc.close(); // 비동기 작업 in progress 닫기
  },
  expect: () => [
    // Only loading should be emitted, not loaded
    isA<MyState>().having((s) => s.status, 'status', isA<Loading>()),
  ],
);

Using verify#

// ✅ Verify call count
verify(() => mockRepository.getBook(1)).called(1);

// ✅ Verify not called
verifyNever(() => mockRepository.deleteBook(any()));

// ✅ Verify call order (only when important)
verifyInOrder([
  () => mockRepository.validate(any()),
  () => mockRepository.save(any()),
]);

tearDown Required#

tearDown(() async {
  await bloc.close();    // BLoC 리소스 정리
  // When using GetIt in UseCase tests:
  await getIt.reset();   // GetIt 등록 초기화
});

Checklist#

When Writing UseCase Tests#

  • Define mock repository
  • Set up getIt.registerFactory or registerTestLazySingleton
  • Call getIt.reset() in tearDown
  • Register registerFallbackValue for custom type parameters
  • Test success cases
  • Test failure cases
  • Verify calls with verify

When Writing BLoC Tests#

  • Define mock UseCase (mock UseCase, not Repository)
  • Direct mock injection via constructor (leveraging DI decoupling)
  • Call bloc.close() in tearDown
  • Verify state transitions with blocTest
  • Cover both success/failure cases
  • Verify state fields with isA<T>().having() pattern