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