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#
| Library | Purpose | When to Use |
|---|---|---|
mocktail | Standard | When 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#
| Target | File Name Pattern | Example |
|---|---|---|
| BLoC | {bloc_name}_test.dart | book_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.registerFactoryorregisterTestLazySingleton - Call
getIt.reset()intearDown - Register
registerFallbackValuefor 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()intearDown - Verify state transitions with
blocTest - Cover both success/failure cases
- Verify state fields with
isA<T>().having()pattern
Related Documents#
- bdd-test-patterns.md - BDD test patterns
- swr-pattern.md - SWR Pattern (Stream UseCase Test)
- CLAUDE.md - Complete project guide