Mock과 Stub이 필요한 이유
실제 앱에서는 순수한 로직만 있는 코드는 드뭅니다. 대부분의 코드는 외부 시스템과 상호작용합니다. API 서버와 통신하고, 데이터베이스에 저장하고, 파일 시스템을 사용합니다.
이런 외부 의존성이 있는 코드를 테스트할 때 문제가 발생합니다:
• 느림: 네트워크 요청이나 파일 I/O는 시간이 걸립니다
• 불안정: 네트워크가 끊기거나 서버가 다운될 수 있습니다
• 복잡함: 테스트 데이터를 준비하고 정리하는 것이 어렵습니다
• 비용: API 호출에 비용이 발생할 수 있습니다
이 문제를 해결하는 방법이 바로 Test Double입니다. Test Double은 테스트에서 실제 객체를 대신하는 가짜 객체를 말합니다. 영화 촬영에서 위험한 장면을 대신 연기하는 스턴트 더블(Stunt Double)에서 유래한 용어입니다.
이런 외부 의존성이 있는 코드를 테스트할 때 문제가 발생합니다:
• 느림: 네트워크 요청이나 파일 I/O는 시간이 걸립니다
• 불안정: 네트워크가 끊기거나 서버가 다운될 수 있습니다
• 복잡함: 테스트 데이터를 준비하고 정리하는 것이 어렵습니다
• 비용: API 호출에 비용이 발생할 수 있습니다
이 문제를 해결하는 방법이 바로 Test Double입니다. Test Double은 테스트에서 실제 객체를 대신하는 가짜 객체를 말합니다. 영화 촬영에서 위험한 장면을 대신 연기하는 스턴트 더블(Stunt Double)에서 유래한 용어입니다.
Test Double의 종류
Test Double에는 여러 종류가 있습니다. 각각 다른 목적과 특징을 가지고 있습니다.
1. Dummy
가장 단순한 형태입니다. 파라미터를 채우기 위해서만 사용되고 실제로는 사용되지 않습니다.
2. Stub
미리 정해진 값을 반환합니다. 테스트에 필요한 최소한의 구현만 제공합니다.
3. Mock
호출을 기록하고 검증합니다. 어떤 메서드가 몇 번, 어떤 파라미터로 호출되었는지 확인할 수 있습니다.
4. Fake
실제와 유사하게 동작하는 간단한 구현체입니다. 예를 들어, 실제 데이터베이스 대신 메모리에 데이터를 저장하는 가짜 데이터베이스입니다.
5. Spy
실제 객체를 감싸서 호출을 기록합니다. 실제 동작도 수행하면서 검증도 가능합니다.
실제로는 Mock과 Stub을 가장 많이 사용하며, 때로는 두 개념이 혼용되기도 합니다. Flutter에서는 주로 Mockito 라이브러리를 사용합니다.
실제로는 Mock과 Stub을 가장 많이 사용하며, 때로는 두 개념이 혼용되기도 합니다. Flutter에서는 주로 Mockito 라이브러리를 사용합니다.
Mockito 시작하기
설치
yaml
# pubspec.yaml
dev_dependencies:
mockito: ^5.4.0
build_runner: ^2.4.0
기본 사용법
먼저 실제 서비스 코드를 만들어봅시다:
Dart
// lib/services/user_service.dart
class UserService {
final ApiClient apiClient;
final LocalStorage localStorage;
UserService({
required this.apiClient,
required this.localStorage,
});
Future<User?> getCurrentUser() async {
try {
// 먼저 로컬 캐시 확인
final cachedUser = await localStorage.getUser('current');
if (cachedUser != null) {
return cachedUser;
}
// 캐시가 없으면 API 호출
final userData = await apiClient.get('/user/me');
final user = User.fromJson(userData);
// 로컬에 저장
await localStorage.saveUser('current', user);
return user;
} catch (e) {
print('Failed to get user: $e');
return null;
}
}
Future<bool> updateProfile(String name, String email) async {
try {
// API 호출
await apiClient.put('/user/profile', {
'name': name,
'email': email,
});
// 로컬 캐시 업데이트
final currentUser = await localStorage.getUser('current');
if (currentUser != null) {
final updatedUser = currentUser.copyWith(
name: name,
email: email,
);
await localStorage.saveUser('current', updatedUser);
}
return true;
} catch (e) {
print('Failed to update profile: $e');
return false;
}
}
Future<void> logout() async {
// 로컬 데이터 삭제
await localStorage.deleteUser('current');
// 서버에 로그아웃 알림
await apiClient.post('/auth/logout', {});
}
}
이제 이 서비스를 테스트해봅시다. ApiClient와 LocalStorage를 Mock으로 대체합니다:
Dart
// test/services/user_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
// Mock 클래스 생성을 위한 어노테이션
@GenerateMocks([ApiClient, LocalStorage])
void main() {
// build_runner를 실행하면 MockApiClient와 MockLocalStorage가 자동 생성됩니다
// flutter pub run build_runner build
}
build_runner를 실행하면 user_service_test.mocks.dart 파일이 생성됩니다. 이제 테스트를 작성할 수 있습니다:
Dart
// test/services/user_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
import 'user_service_test.mocks.dart';
@GenerateMocks([ApiClient, LocalStorage])
void main() {
group('UserService', () {
late UserService userService;
late MockApiClient mockApiClient;
late MockLocalStorage mockLocalStorage;
setUp(() {
mockApiClient = MockApiClient();
mockLocalStorage = MockLocalStorage();
userService = UserService(
apiClient: mockApiClient,
localStorage: mockLocalStorage,
);
});
group('getCurrentUser', () {
test('캐시된 사용자가 있으면 캐시를 반환한다', () async {
// Arrange: Mock 동작 정의
final cachedUser = User(
id: '123',
name: 'John Doe',
email: 'john@example.com',
);
when(mockLocalStorage.getUser('current'))
.thenAnswer((_) async => cachedUser);
// Act: 실제 메서드 호출
final result = await userService.getCurrentUser();
// Assert: 결과 검증
expect(result, equals(cachedUser));
// API는 호출되지 않아야 함
verifyNever(mockApiClient.get(any));
// LocalStorage는 한 번만 호출되어야 함
verify(mockLocalStorage.getUser('current')).called(1);
});
test('캐시가 없으면 API를 호출한다', () async {
// Arrange
final apiResponse = {
'id': '456',
'name': 'Jane Smith',
'email': 'jane@example.com',
};
when(mockLocalStorage.getUser('current'))
.thenAnswer((_) async => null);
when(mockApiClient.get('/user/me'))
.thenAnswer((_) async => apiResponse);
when(mockLocalStorage.saveUser(any, any))
.thenAnswer((_) async => {});
// Act
final result = await userService.getCurrentUser();
// Assert
expect(result?.name, equals('Jane Smith'));
// 호출 순서 검증
verifyInOrder([
mockLocalStorage.getUser('current'),
mockApiClient.get('/user/me'),
mockLocalStorage.saveUser('current', any),
]);
});
test('API 호출이 실패하면 null을 반환한다', () async {
// Arrange
when(mockLocalStorage.getUser('current'))
.thenAnswer((_) async => null);
when(mockApiClient.get('/user/me'))
.thenThrow(NetworkException('Connection failed'));
// Act
final result = await userService.getCurrentUser();
// Assert
expect(result, isNull);
// 저장은 시도하지 않아야 함
verifyNever(mockLocalStorage.saveUser(any, any));
});
});
group('updateProfile', () {
test('프로필 업데이트가 성공한다', () async {
// Arrange
final currentUser = User(
id: '123',
name: 'Old Name',
email: 'old@example.com',
);
when(mockApiClient.put('/user/profile', any))
.thenAnswer((_) async => {});
when(mockLocalStorage.getUser('current'))
.thenAnswer((_) async => currentUser);
when(mockLocalStorage.saveUser(any, any))
.thenAnswer((_) async => {});
// Act
final result = await userService.updateProfile(
'New Name',
'new@example.com',
);
// Assert
expect(result, isTrue);
// API 호출 파라미터 검증
verify(mockApiClient.put('/user/profile', {
'name': 'New Name',
'email': 'new@example.com',
})).called(1);
// 로컬 저장소 업데이트 검증
final captured = verify(
mockLocalStorage.saveUser('current', captureAny)
).captured;
final savedUser = captured.first as User;
expect(savedUser.name, equals('New Name'));
expect(savedUser.email, equals('new@example.com'));
});
test('API 호출이 실패하면 false를 반환한다', () async {
// Arrange
when(mockApiClient.put(any, any))
.thenThrow(ApiException('Server error'));
// Act
final result = await userService.updateProfile(
'New Name',
'new@example.com',
);
// Assert
expect(result, isFalse);
// 로컬 저장소는 업데이트하지 않아야 함
verifyNever(mockLocalStorage.saveUser(any, any));
});
});
group('logout', () {
test('로컬 데이터를 삭제하고 서버에 알린다', () async {
// Arrange
when(mockLocalStorage.deleteUser('current'))
.thenAnswer((_) async => {});
when(mockApiClient.post('/auth/logout', any))
.thenAnswer((_) async => {});
// Act
await userService.logout();
// Assert
verify(mockLocalStorage.deleteUser('current')).called(1);
verify(mockApiClient.post('/auth/logout', {})).called(1);
});
});
});
}
Mockito의 주요 기능
1. when() - Stub 동작 정의
when()을 사용해서 Mock 객체의 동작을 정의합니다:
Dart
// 값 반환
when(mock.someMethod()).thenReturn(value);
// Future 반환
when(mock.asyncMethod()).thenAnswer((_) async => value);
// Stream 반환
when(mock.streamMethod()).thenAnswer((_) => Stream.value(value));
// 예외 발생
when(mock.dangerousMethod()).thenThrow(Exception('Error'));
// 여러 번 호출 시 다른 값 반환
when(mock.method())
.thenReturn('first')
.thenReturn('second')
.thenReturn('third');
2. verify() - 호출 검증
verify()로 메서드 호출을 검증합니다:
Dart
// 호출 여부 확인
verify(mock.method());
// 호출 횟수 확인
verify(mock.method()).called(3);
verify(mock.method()).called(greaterThan(2));
verify(mock.method()).called(lessThanOrEqualTo(5));
// 한 번도 호출되지 않았는지 확인
verifyNever(mock.method());
// 호출 순서 확인
verifyInOrder([
mock.firstMethod(),
mock.secondMethod(),
mock.thirdMethod(),
]);
// 더 이상 호출이 없는지 확인
verifyNoMoreInteractions(mock);
3. Argument Matchers
파라미터를 유연하게 매칭할 수 있습니다:
Dart
// 어떤 값이든 허용
when(mock.method(any)).thenReturn(value);
// null이 아닌 값
when(mock.method(argThat(isNotNull))).thenReturn(value);
// 특정 타입
when(mock.method(argThat(isA<String>()))).thenReturn(value);
// 조건 매칭
when(mock.method(argThat(greaterThan(10)))).thenReturn(value);
// 커스텀 매칭
when(mock.method(argThat(
predicate<String>((s) => s.startsWith('test'))
))).thenReturn(value);
// 여러 파라미터
when(mock.method(
argThat(isA<String>()),
argThat(greaterThan(0)),
captureAny,
)).thenReturn(value);
4. captureAny - 파라미터 캡처
호출된 파라미터 값을 캡처할 수 있습니다:
Dart
// 파라미터 캡처
when(mock.save(captureAny)).thenAnswer((_) async => true);
// 호출 후 캡처된 값 확인
await service.doSomething();
final captured = verify(mock.save(captureAny)).captured;
expect(captured.first, equals('expected value'));
// 여러 파라미터 캡처
verify(mock.method(captureAny, captureAny)).captured;
// captured[0]: 첫 번째 파라미터
// captured[1]: 두 번째 파라미터
실전 예제: Repository 테스트
실제 Repository를 테스트하는 예제를 봅시다:
Dart
// lib/repositories/product_repository.dart
class ProductRepository {
final ApiClient apiClient;
final CacheManager cacheManager;
final NetworkInfo networkInfo;
ProductRepository({
required this.apiClient,
required this.cacheManager,
required this.networkInfo,
});
Future<List<Product>> getProducts({
String? category,
int page = 1,
int limit = 20,
}) async {
// 캐시 키 생성
final cacheKey = 'products_${category ?? 'all'}_${page}_$limit';
// 오프라인이면 캐시만 사용
if (!await networkInfo.isConnected) {
final cached = await cacheManager.get<List<Product>>(cacheKey);
if (cached != null) {
return cached;
}
throw NetworkException('No internet connection');
}
// 캐시 확인 (5분 유효)
final cached = await cacheManager.get<List<Product>>(
cacheKey,
maxAge: Duration(minutes: 5),
);
if (cached != null) {
return cached;
}
// API 호출
final response = await apiClient.get('/products', queryParameters: {
if (category != null) 'category': category,
'page': page,
'limit': limit,
});
final products = (response['data'] as List)
.map((json) => Product.fromJson(json))
.toList();
// 캐시 저장
await cacheManager.set(cacheKey, products);
return products;
}
Future<Product> createProduct(Product product) async {
if (!await networkInfo.isConnected) {
throw NetworkException('No internet connection');
}
final response = await apiClient.post(
'/products',
data: product.toJson(),
);
// 캐시 무효화 (새 상품이 추가되었으므로)
await cacheManager.clearPattern('products_*');
return Product.fromJson(response);
}
}
Repository 테스트:
Dart
// test/repositories/product_repository_test.dart
@GenerateMocks([ApiClient, CacheManager, NetworkInfo])
void main() {
group('ProductRepository', () {
late ProductRepository repository;
late MockApiClient mockApiClient;
late MockCacheManager mockCacheManager;
late MockNetworkInfo mockNetworkInfo;
setUp(() {
mockApiClient = MockApiClient();
mockCacheManager = MockCacheManager();
mockNetworkInfo = MockNetworkInfo();
repository = ProductRepository(
apiClient: mockApiClient,
cacheManager: mockCacheManager,
networkInfo: mockNetworkInfo,
);
});
group('getProducts', () {
final testProducts = [
Product(id: '1', name: 'Product 1', price: 100),
Product(id: '2', name: 'Product 2', price: 200),
];
test('오프라인일 때 캐시를 반환한다', () async {
// Arrange
when(mockNetworkInfo.isConnected)
.thenAnswer((_) async => false);
when(mockCacheManager.get<List<Product>>('products_all_1_20'))
.thenAnswer((_) async => testProducts);
// Act
final result = await repository.getProducts();
// Assert
expect(result, equals(testProducts));
verifyNever(mockApiClient.get(any, queryParameters: anyNamed('queryParameters')));
});
test('오프라인이고 캐시가 없으면 예외를 발생시킨다', () async {
// Arrange
when(mockNetworkInfo.isConnected)
.thenAnswer((_) async => false);
when(mockCacheManager.get<List<Product>>(any))
.thenAnswer((_) async => null);
// Act & Assert
expect(
() => repository.getProducts(),
throwsA(isA<NetworkException>()),
);
});
test('온라인일 때 유효한 캐시가 있으면 캐시를 반환한다', () async {
// Arrange
when(mockNetworkInfo.isConnected)
.thenAnswer((_) async => true);
when(mockCacheManager.get<List<Product>>(
'products_all_1_20',
maxAge: Duration(minutes: 5),
)).thenAnswer((_) async => testProducts);
// Act
final result = await repository.getProducts();
// Assert
expect(result, equals(testProducts));
verifyNever(mockApiClient.get(any, queryParameters: anyNamed('queryParameters')));
});
test('캐시가 없으면 API를 호출하고 결과를 캐시한다', () async {
// Arrange
when(mockNetworkInfo.isConnected)
.thenAnswer((_) async => true);
when(mockCacheManager.get<List<Product>>(any, maxAge: anyNamed('maxAge')))
.thenAnswer((_) async => null);
when(mockApiClient.get('/products', queryParameters: anyNamed('queryParameters')))
.thenAnswer((_) async => {
'data': [
{'id': '1', 'name': 'Product 1', 'price': 100},
{'id': '2', 'name': 'Product 2', 'price': 200},
],
});
when(mockCacheManager.set(any, any))
.thenAnswer((_) async => true);
// Act
final result = await repository.getProducts();
// Assert
expect(result.length, equals(2));
expect(result.first.name, equals('Product 1'));
// API 호출 파라미터 검증
verify(mockApiClient.get(
'/products',
queryParameters: {'page': 1, 'limit': 20},
)).called(1);
// 캐시 저장 검증
verify(mockCacheManager.set('products_all_1_20', any)).called(1);
});
test('카테고리 필터가 적용된다', () async {
// Arrange
when(mockNetworkInfo.isConnected)
.thenAnswer((_) async => true);
when(mockCacheManager.get<List<Product>>(any, maxAge: anyNamed('maxAge')))
.thenAnswer((_) async => null);
when(mockApiClient.get(any, queryParameters: anyNamed('queryParameters')))
.thenAnswer((_) async => {'data': []});
when(mockCacheManager.set(any, any))
.thenAnswer((_) async => true);
// Act
await repository.getProducts(category: 'electronics');
// Assert
verify(mockApiClient.get(
'/products',
queryParameters: {
'category': 'electronics',
'page': 1,
'limit': 20,
},
)).called(1);
verify(mockCacheManager.set('products_electronics_1_20', any)).called(1);
});
});
group('createProduct', () {
final newProduct = Product(
id: '',
name: 'New Product',
price: 300,
);
test('오프라인일 때 예외를 발생시킨다', () async {
// Arrange
when(mockNetworkInfo.isConnected)
.thenAnswer((_) async => false);
// Act & Assert
expect(
() => repository.createProduct(newProduct),
throwsA(isA<NetworkException>()),
);
verifyNever(mockApiClient.post(any, data: anyNamed('data')));
});
test('상품을 생성하고 캐시를 무효화한다', () async {
// Arrange
when(mockNetworkInfo.isConnected)
.thenAnswer((_) async => true);
when(mockApiClient.post('/products', data: anyNamed('data')))
.thenAnswer((_) async => {
'id': '3',
'name': 'New Product',
'price': 300,
});
when(mockCacheManager.clearPattern('products_*'))
.thenAnswer((_) async => {});
// Act
final result = await repository.createProduct(newProduct);
// Assert
expect(result.id, equals('3'));
expect(result.name, equals('New Product'));
// API 호출 검증
verify(mockApiClient.post(
'/products',
data: newProduct.toJson(),
)).called(1);
// 캐시 무효화 검증
verify(mockCacheManager.clearPattern('products_*')).called(1);
});
});
});
}
베스트 프랙티스
1. 테스트 가독성 향상
Helper 메서드를 만들어서 반복을 줄이고 가독성을 높입니다:
Dart
class TestHelper {
static void stubNetworkConnected(MockNetworkInfo mock, bool connected) {
when(mock.isConnected).thenAnswer((_) async => connected);
}
static void stubCacheResponse<T>(MockCacheManager mock, String key, T? value) {
when(mock.get<T>(key, maxAge: anyNamed('maxAge')))
.thenAnswer((_) async => value);
}
static void stubApiResponse(MockApiClient mock, String path, dynamic response) {
when(mock.get(path, queryParameters: anyNamed('queryParameters')))
.thenAnswer((_) async => response);
}
}
// 사용
test('캐시된 데이터를 반환한다', () async {
TestHelper.stubNetworkConnected(mockNetworkInfo, true);
TestHelper.stubCacheResponse(mockCacheManager, 'key', testData);
final result = await repository.getData();
expect(result, equals(testData));
});
2. Given-When-Then 패턴
테스트를 구조화해서 읽기 쉽게 만듭니다:
Dart
test('오프라인일 때 캐시를 반환한다', () async {
// Given: 준비
when(mockNetworkInfo.isConnected).thenAnswer((_) async => false);
when(mockCache.get('key')).thenAnswer((_) async => cachedData);
// When: 실행
final result = await repository.getData();
// Then: 검증
expect(result, equals(cachedData));
verifyNever(mockApi.get(any));
});
3. 적절한 Mock 사용
Mock을 과도하게 사용하면 테스트가 복잡해집니다. 필요한 부분만 Mock하세요:
Dart
// ❌ 과도한 Mocking
when(mock.method1()).thenReturn(value1);
when(mock.method2()).thenReturn(value2);
when(mock.method3()).thenReturn(value3);
// ... 10개 더
// ✅ 필요한 부분만 Mock
when(mock.criticalMethod()).thenReturn(expectedValue);
정리
Mock과 Stub을 사용하면 외부 의존성이 있는 코드도 안전하게 테스트할 수 있습니다.
핵심 포인트:
• 격리: 테스트 대상만 실제로 실행하고 나머지는 Mock
• 제어: Mock의 동작을 완전히 제어 가능
• 검증: 메서드 호출과 파라미터를 검증
• 속도: 실제 I/O 없이 빠르게 실행
Mock 사용 시 주의사항:
• 과도한 Mock은 테스트를 복잡하게 만듭니다
• Mock의 동작이 실제와 너무 다르면 의미 없는 테스트가 됩니다
• 인터페이스가 변경되면 Mock도 업데이트해야 합니다
다음 편에서는 Widget Test를 작성하는 방법을 알아보겠습니다.
핵심 포인트:
• 격리: 테스트 대상만 실제로 실행하고 나머지는 Mock
• 제어: Mock의 동작을 완전히 제어 가능
• 검증: 메서드 호출과 파라미터를 검증
• 속도: 실제 I/O 없이 빠르게 실행
Mock 사용 시 주의사항:
• 과도한 Mock은 테스트를 복잡하게 만듭니다
• Mock의 동작이 실제와 너무 다르면 의미 없는 테스트가 됩니다
• 인터페이스가 변경되면 Mock도 업데이트해야 합니다
다음 편에서는 Widget Test를 작성하는 방법을 알아보겠습니다.