단위 테스트
소프트웨어 개발에서 테스트는 코드의 정확성을 검증하고 결함을 조기에 발견하는 데 핵심적인 역할을 합니다. 이 장에서는 Flutter 애플리케이션의 단위 테스트에 대해 다루겠습니다. 단위 테스트는 코드의 가장 작은 단위(일반적으로 함수나 메서드)가 예상대로 작동하는지 확인하는 테스트입니다.
단위 테스트의 중요성
Section titled “단위 테스트의 중요성”단위 테스트는 다음과 같은 여러 이유로 중요합니다:
- 버그 조기 발견: 코드 변경이 기존 기능을 손상시키지 않는지 확인할 수 있습니다.
- 리팩토링 신뢰성: 코드를 변경하더라도 동작이 여전히 올바른지 확인할 수 있습니다.
- 문서화: 테스트는 코드가 어떻게 동작해야 하는지 보여주는 생생한 문서 역할을 합니다.
- 설계 개선: 테스트를 작성하면 종종 더 나은 코드 설계로 이어집니다.
- 개발 속도 향상: 장기적으로 디버깅 시간이 줄어들어 개발 속도가 빨라집니다.
Flutter에서 단위 테스트 설정하기
Section titled “Flutter에서 단위 테스트 설정하기”1. 의존성 추가
Section titled “1. 의존성 추가”Flutter 프로젝트에서 단위 테스트를 시작하려면 pubspec.yaml
파일에 필요한 패키지를 추가해야 합니다:
dev_dependencies: flutter_test: sdk: flutter test: ^1.24.1
flutter_test
는 Flutter SDK의 일부로, Flutter 위젯을 테스트하는 데 필요한 도구를 제공합니다. test
패키지는 일반 Dart 코드를 테스트하는 데 사용됩니다.
2. 테스트 파일 구성
Section titled “2. 테스트 파일 구성”테스트 파일은 일반적으로 프로젝트의 test
디렉토리에 위치합니다. 테스트 파일 이름은 관례적으로 {파일명}_test.dart
형식을 따릅니다:
my_app/ ├── lib/ │ ├── models/ │ │ └── user.dart │ └── utils/ │ └── validator.dart └── test/ ├── models/ │ └── user_test.dart └── utils/ └── validator_test.dart
기본 단위 테스트 작성하기
Section titled “기본 단위 테스트 작성하기”1. 간단한 유틸리티 함수 테스트
Section titled “1. 간단한 유틸리티 함수 테스트”먼저 테스트할 간단한 유틸리티 함수를 살펴보겠습니다:
class Calculator { int add(int a, int b) => a + b; int subtract(int a, int b) => a - b; int multiply(int a, int b) => a * b; double divide(int a, int b) { if (b == 0) { throw ArgumentError('Cannot divide by zero'); } return a / b; }}
이제 이 Calculator
클래스의 단위 테스트를 작성해 보겠습니다:
import 'package:flutter_test/flutter_test.dart';import 'package:my_app/utils/calculator.dart';
void main() { late Calculator calculator;
setUp(() { calculator = Calculator(); });
group('Calculator', () { test('add returns the sum of two numbers', () { // Arrange & Act final result = calculator.add(2, 3);
// Assert expect(result, 5); });
test('subtract returns the difference of two numbers', () { expect(calculator.subtract(5, 2), 3); });
test('multiply returns the product of two numbers', () { expect(calculator.multiply(3, 4), 12); });
test('divide returns the quotient of two numbers', () { expect(calculator.divide(10, 2), 5.0); });
test('divide throws ArgumentError when dividing by zero', () { expect( () => calculator.divide(10, 0), throwsA(isA<ArgumentError>()), ); }); });}
2. 테스트 실행하기
Section titled “2. 테스트 실행하기”테스트를 실행하는 방법은 여러 가지가 있습니다:
명령줄에서 실행:
flutter test
특정 테스트 파일만 실행:
flutter test test/utils/calculator_test.dart
IDE에서 실행:
대부분의 IDE(예: VS Code, Android Studio)는 테스트 파일 옆에 실행 버튼을 제공하여 쉽게 테스트를 실행할 수 있습니다.
모델 클래스 테스트
Section titled “모델 클래스 테스트”모델 클래스의 테스트는 특히 JSON 변환과 관련된 코드를 검증하는 데 유용합니다:
class User { final int id; final String name; final String email;
User({ required this.id, required this.name, required this.email, });
factory User.fromJson(Map<String, dynamic> json) { return User( id: json['id'] as int, name: json['name'] as String, email: json['email'] as String, ); }
Map<String, dynamic> toJson() { return { 'id': id, 'name': name, 'email': email, }; }
@override bool operator ==(Object other) { if (identical(this, other)) return true; return other is User && other.id == id && other.name == name && other.email == email; }
@override int get hashCode => id.hashCode ^ name.hashCode ^ email.hashCode;}
이 User
클래스에 대한 테스트:
import 'package:flutter_test/flutter_test.dart';import 'package:my_app/models/user.dart';
void main() { group('User', () { test('fromJson creates a User instance correctly', () { // Arrange final json = { 'id': 1, 'name': '홍길동', 'email': 'hong@example.com', };
// Act final user = User.fromJson(json);
// Assert expect(user.id, 1); expect(user.name, '홍길동'); expect(user.email, 'hong@example.com'); });
test('toJson returns correct map', () { // Arrange final user = User( id: 1, name: '홍길동', email: 'hong@example.com', );
// Act final json = user.toJson();
// Assert expect(json, { 'id': 1, 'name': '홍길동', 'email': 'hong@example.com', }); });
test('equality works correctly', () { // Arrange final user1 = User(id: 1, name: '홍길동', email: 'hong@example.com'); final user2 = User(id: 1, name: '홍길동', email: 'hong@example.com'); final user3 = User(id: 2, name: '김철수', email: 'kim@example.com');
// Assert expect(user1, equals(user2)); expect(user1, isNot(equals(user3))); });
test('fromJson throws when fields are missing', () { // Arrange final incompleteJson = { 'id': 1, 'name': '홍길동', // email이 누락됨 };
// Act & Assert expect( () => User.fromJson(incompleteJson), throwsA(isA<TypeError>()), ); }); });}
비동기 코드 테스트
Section titled “비동기 코드 테스트”Flutter 앱에서는 네트워크 요청, 파일 입출력 등 비동기 코드가 흔합니다. 이러한 코드를 테스트하는 방법을 살펴보겠습니다:
import 'dart:convert';import 'package:http/http.dart' as http;import '../models/user.dart';
class UserService { final String baseUrl; final http.Client client;
UserService({ required this.baseUrl, required this.client, });
Future<User> fetchUser(int id) async { final response = await client.get(Uri.parse('$baseUrl/users/$id'));
if (response.statusCode == 200) { return User.fromJson(json.decode(response.body)); } else { throw Exception('Failed to load user'); } }
Future<List<User>> fetchUsers() async { final response = await client.get(Uri.parse('$baseUrl/users'));
if (response.statusCode == 200) { final List<dynamic> userJsonList = json.decode(response.body); return userJsonList.map((json) => User.fromJson(json)).toList(); } else { throw Exception('Failed to load users'); } }}
이 UserService
클래스의 단위 테스트:
import 'dart:convert';import 'package:flutter_test/flutter_test.dart';import 'package:http/http.dart' as http;import 'package:mockito/annotations.dart';import 'package:mockito/mockito.dart';import 'package:my_app/models/user.dart';import 'package:my_app/services/user_service.dart';
import 'user_service_test.mocks.dart';
// Mockito를 사용하여 http.Client 클래스를 모방하는 Mock 클래스 생성@GenerateMocks([http.Client])void main() { late MockClient mockClient; late UserService userService;
setUp(() { mockClient = MockClient(); userService = UserService( baseUrl: 'https://api.example.com', client: mockClient, ); });
group('UserService', () { test('fetchUser returns a User if the http call completes successfully', () async { // Arrange final userData = { 'id': 1, 'name': '홍길동', 'email': 'hong@example.com', };
// mock 클라이언트가 GET 요청을 받으면 가짜 응답을 반환하도록 설정 when(mockClient.get(Uri.parse('https://api.example.com/users/1'))) .thenAnswer((_) async => http.Response(json.encode(userData), 200));
// Act final user = await userService.fetchUser(1);
// Assert expect(user.id, 1); expect(user.name, '홍길동'); expect(user.email, 'hong@example.com'); });
test('fetchUser throws an exception if the http call fails', () async { // Arrange when(mockClient.get(Uri.parse('https://api.example.com/users/1'))) .thenAnswer((_) async => http.Response('Not Found', 404));
// Act & Assert expect(userService.fetchUser(1), throwsException); });
test('fetchUsers returns a list of Users if the http call completes successfully', () async { // Arrange final usersData = [ { 'id': 1, 'name': '홍길동', 'email': 'hong@example.com', }, { 'id': 2, 'name': '김철수', 'email': 'kim@example.com', }, ];
when(mockClient.get(Uri.parse('https://api.example.com/users'))) .thenAnswer((_) async => http.Response(json.encode(usersData), 200));
// Act final users = await userService.fetchUsers();
// Assert expect(users.length, 2); expect(users[0].id, 1); expect(users[0].name, '홍길동'); expect(users[1].id, 2); expect(users[1].name, '김철수'); }); });}
이 테스트를 실행하기 전에 Mockito 패키지를 설치하고 코드를 생성해야 합니다:
dev_dependencies: flutter_test: sdk: flutter mockito: ^5.4.2 build_runner: ^2.4.6
그리고 다음 명령어를 실행하여 Mock 클래스를 생성합니다:
flutter pub run build_runner build
비즈니스 로직 레이어(Provider, Riverpod, Bloc 등) 테스트
Section titled “비즈니스 로직 레이어(Provider, Riverpod, Bloc 등) 테스트”상태 관리 라이브러리를 사용하는 비즈니스 로직 레이어 테스트를 살펴보겠습니다. 여기서는 Riverpod를 예시로 들겠습니다:
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'counter_provider.g.dart';
@riverpodclass Counter extends _$Counter { @override int build() => 0;
void increment() { state = state + 1; }
void decrement() { if (state > 0) { state = state - 1; } }
void reset() { state = 0; }}
이 Riverpod 프로바이더의 단위 테스트:
import 'package:flutter_riverpod/flutter_riverpod.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/providers/counter_provider.dart';
void main() { late ProviderContainer container;
setUp(() { container = ProviderContainer(); });
tearDown(() { container.dispose(); });
group('CounterProvider', () { test('initial value is 0', () { final value = container.read(counterProvider); expect(value, 0); });
test('increment increases state by 1', () { final notifier = container.read(counterProvider.notifier);
// 초기값 확인 expect(container.read(counterProvider), 0);
// 증가 실행 notifier.increment();
// 변경된 값 확인 expect(container.read(counterProvider), 1); });
test('decrement decreases state by 1', () { final notifier = container.read(counterProvider.notifier);
// 초기값을 1로 변경 notifier.increment(); expect(container.read(counterProvider), 1);
// 감소 실행 notifier.decrement();
// 변경된 값 확인 expect(container.read(counterProvider), 0); });
test('decrement does not go below 0', () { final notifier = container.read(counterProvider.notifier);
// 초기값 확인 expect(container.read(counterProvider), 0);
// 감소 실행 notifier.decrement();
// 여전히 0이어야 함 expect(container.read(counterProvider), 0); });
test('reset sets state back to 0', () { final notifier = container.read(counterProvider.notifier);
// 증가 실행 notifier.increment(); notifier.increment(); expect(container.read(counterProvider), 2);
// 리셋 실행 notifier.reset();
// 0으로 리셋됨 expect(container.read(counterProvider), 0); });
test('confirms state changes correctly with multiple operations', () { final notifier = container.read(counterProvider.notifier);
notifier.increment(); // 1 notifier.increment(); // 2 notifier.decrement(); // 1 notifier.increment(); // 2 notifier.increment(); // 3
expect(container.read(counterProvider), 3); }); });}
Freezed 또는 json_serializable 모델 테스트
Section titled “Freezed 또는 json_serializable 모델 테스트”Freezed나 json_serializable을 사용하는 모델의 테스트 예시입니다:
import 'package:freezed_annotation/freezed_annotation.dart';import 'package:json_annotation/json_annotation.dart';
part 'product.freezed.dart';part 'product.g.dart';
@freezedclass Product with _$Product { const factory Product({ required int id, required String name, required double price, @Default(0) int stock, String? description, @Default([]) List<String> categories, }) = _Product;
factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);}
이 Freezed 모델의 테스트:
import 'package:flutter_test/flutter_test.dart';import 'package:my_app/models/product.dart';
void main() { group('Product', () { test('fromJson creates a valid Product instance', () { // Arrange final json = { 'id': 1, 'name': '노트북', 'price': 1200000.0, 'stock': 10, 'description': '고성능 노트북', 'categories': ['전자제품', '컴퓨터'], };
// Act final product = Product.fromJson(json);
// Assert expect(product.id, 1); expect(product.name, '노트북'); expect(product.price, 1200000.0); expect(product.stock, 10); expect(product.description, '고성능 노트북'); expect(product.categories, ['전자제품', '컴퓨터']); });
test('fromJson creates a valid Product with default values', () { // Arrange final json = { 'id': 1, 'name': '노트북', 'price': 1200000.0, };
// Act final product = Product.fromJson(json);
// Assert expect(product.id, 1); expect(product.name, '노트북'); expect(product.price, 1200000.0); expect(product.stock, 0); // 기본값 expect(product.description, null); // 기본값(null) expect(product.categories, isEmpty); // 기본값(빈 배열) });
test('toJson returns valid JSON', () { // Arrange final product = Product( id: 1, name: '노트북', price: 1200000.0, stock: 10, description: '고성능 노트북', categories: ['전자제품', '컴퓨터'], );
// Act final json = product.toJson();
// Assert expect(json['id'], 1); expect(json['name'], '노트북'); expect(json['price'], 1200000.0); expect(json['stock'], 10); expect(json['description'], '고성능 노트북'); expect(json['categories'], ['전자제품', '컴퓨터']); });
test('copyWith works correctly', () { // Arrange final product = Product( id: 1, name: '노트북', price: 1200000.0, );
// Act final updatedProduct = product.copyWith( name: '고성능 노트북', price: 1300000.0, stock: 5, );
// Assert expect(updatedProduct.id, 1); // 변경되지 않음 expect(updatedProduct.name, '고성능 노트북'); // 변경됨 expect(updatedProduct.price, 1300000.0); // 변경됨 expect(updatedProduct.stock, 5); // 변경됨 });
test('두 제품이 같은 값을 가질 때 동등하게 취급된다', () { // Arrange final product1 = Product( id: 1, name: '노트북', price: 1200000.0, );
final product2 = Product( id: 1, name: '노트북', price: 1200000.0, );
// Assert expect(product1, equals(product2)); }); });}
복잡한 비즈니스 로직 테스트
Section titled “복잡한 비즈니스 로직 테스트”복잡한 비즈니스 로직이 포함된 클래스의 테스트 예시입니다:
import '../models/product.dart';
class CartItem { final Product product; final int quantity;
CartItem({required this.product, required this.quantity});
double get totalPrice => product.price * quantity;
CartItem copyWith({int? quantity}) { return CartItem( product: product, quantity: quantity ?? this.quantity, ); }}
class CartService { final Map<int, CartItem> _items = {};
List<CartItem> get items => _items.values.toList();
int get itemCount => _items.values.fold(0, (sum, item) => sum + item.quantity);
double get totalAmount => _items.values.fold( 0, (sum, item) => sum + item.totalPrice, );
void addProduct(Product product, {int quantity = 1}) { if (product.stock < quantity) { throw Exception('재고가 부족합니다'); }
if (_items.containsKey(product.id)) { final existingItem = _items[product.id]!; final newQuantity = existingItem.quantity + quantity;
if (product.stock < newQuantity) { throw Exception('재고가 부족합니다'); }
_items[product.id] = existingItem.copyWith(quantity: newQuantity); } else { _items[product.id] = CartItem(product: product, quantity: quantity); } }
void updateQuantity(int productId, int quantity) { if (!_items.containsKey(productId)) { throw Exception('장바구니에 해당 상품이 없습니다'); }
final item = _items[productId]!;
if (quantity <= 0) { _items.remove(productId); return; }
if (item.product.stock < quantity) { throw Exception('재고가 부족합니다'); }
_items[productId] = item.copyWith(quantity: quantity); }
void removeProduct(int productId) { _items.remove(productId); }
void clear() { _items.clear(); }
bool hasProduct(int productId) { return _items.containsKey(productId); }}
이 CartService
클래스의 단위 테스트:
import 'package:flutter_test/flutter_test.dart';import 'package:my_app/models/product.dart';import 'package:my_app/services/cart_service.dart';
void main() { late CartService cartService; late Product laptop; late Product phone;
setUp(() { cartService = CartService();
laptop = Product( id: 1, name: '노트북', price: 1200000.0, stock: 10, );
phone = Product( id: 2, name: '스마트폰', price: 800000.0, stock: 5, ); });
group('CartService', () { test('초기 장바구니는 비어있다', () { expect(cartService.items, isEmpty); expect(cartService.itemCount, 0); expect(cartService.totalAmount, 0); });
test('상품을 장바구니에 추가할 수 있다', () { // Act cartService.addProduct(laptop);
// Assert expect(cartService.items.length, 1); expect(cartService.items[0].product, laptop); expect(cartService.items[0].quantity, 1); expect(cartService.itemCount, 1); expect(cartService.totalAmount, 1200000.0); });
test('같은 상품을 추가하면 수량이 증가한다', () { // Arrange cartService.addProduct(laptop);
// Act cartService.addProduct(laptop);
// Assert expect(cartService.items.length, 1); expect(cartService.items[0].quantity, 2); expect(cartService.itemCount, 2); expect(cartService.totalAmount, 2400000.0); });
test('재고보다 많은 수량을 추가하려고 하면 예외가 발생한다', () { // Act & Assert expect( () => cartService.addProduct(laptop, quantity: 11), throwsException, ); });
test('장바구니에 있는 상품의 수량을 업데이트할 수 있다', () { // Arrange cartService.addProduct(laptop);
// Act cartService.updateQuantity(laptop.id, 3);
// Assert expect(cartService.items[0].quantity, 3); expect(cartService.itemCount, 3); expect(cartService.totalAmount, 3600000.0); });
test('수량을 0 이하로 설정하면 상품이 장바구니에서 제거된다', () { // Arrange cartService.addProduct(laptop);
// Act cartService.updateQuantity(laptop.id, 0);
// Assert expect(cartService.items, isEmpty); });
test('존재하지 않는 상품의 수량을 업데이트하려고 하면 예외가 발생한다', () { // Act & Assert expect( () => cartService.updateQuantity(999, 1), throwsException, ); });
test('상품을 장바구니에서 제거할 수 있다', () { // Arrange cartService.addProduct(laptop); cartService.addProduct(phone); expect(cartService.items.length, 2);
// Act cartService.removeProduct(laptop.id);
// Assert expect(cartService.items.length, 1); expect(cartService.items[0].product, phone); });
test('장바구니를 비울 수 있다', () { // Arrange cartService.addProduct(laptop); cartService.addProduct(phone); expect(cartService.items.length, 2);
// Act cartService.clear();
// Assert expect(cartService.items, isEmpty); expect(cartService.itemCount, 0); expect(cartService.totalAmount, 0); });
test('hasProduct는 상품의 존재 여부를 올바르게 확인한다', () { // Arrange cartService.addProduct(laptop);
// Assert expect(cartService.hasProduct(laptop.id), true); expect(cartService.hasProduct(phone.id), false); });
test('여러 상품을 추가하면 totalAmount가 올바르게 계산된다', () { // Act cartService.addProduct(laptop); // 1,200,000 cartService.addProduct(phone, quantity: 2); // 800,000 * 2 = 1,600,000
// Assert expect(cartService.itemCount, 3); expect(cartService.totalAmount, 2800000.0); }); });}
테스트 커버리지 측정하기
Section titled “테스트 커버리지 측정하기”테스트 커버리지는 코드가 테스트에 의해 얼마나 실행되었는지를 나타내는 지표입니다. Flutter에서는 다음과 같이 커버리지를 측정할 수 있습니다:
flutter test --coverage
이 명령어는 테스트를 실행하고 coverage/lcov.info
파일을 생성합니다. 이 파일을 사람이 읽기 쉬운 HTML 리포트로 변환하려면 lcov
도구를 사용합니다:
genhtml coverage/lcov.info -o coverage/html
그런 다음 coverage/html/index.html
파일을 브라우저에서 열어 커버리지 리포트를 확인할 수 있습니다.
단위 테스트 모범 사례
Section titled “단위 테스트 모범 사례”1. AAA 패턴 사용하기
Section titled “1. AAA 패턴 사용하기”AAA(Arrange, Act, Assert) 패턴은 테스트를 구조화하는 명확한 방법을 제공합니다:
- Arrange: 테스트에 필요한 데이터와 객체를 설정합니다.
- Act: 테스트하려는 동작을 실행합니다.
- Assert: 예상 결과를 실제 결과와 비교합니다.
2. 테스트 이름을 명확하게 지정하기
Section titled “2. 테스트 이름을 명확하게 지정하기”// 좋지 않은 예test('add', () { expect(calculator.add(2, 3), 5);});
// 좋은 예test('add returns the sum of two numbers', () { expect(calculator.add(2, 3), 5);});
3. 테스트 그룹화하기
Section titled “3. 테스트 그룹화하기”관련 테스트를 group
함수를 사용하여 그룹화하면 테스트 출력을 더 잘 구조화할 수 있습니다:
group('Calculator', () { group('add', () { test('returns the sum of two positive numbers', () { // ... });
test('returns the correct sum when one number is negative', () { // ... }); });
group('divide', () { test('returns the quotient of two numbers', () { // ... });
test('throws ArgumentError when dividing by zero', () { // ... }); });});
4. 중복 제거하기
Section titled “4. 중복 제거하기”setUp
과 tearDown
함수를 사용하여 테스트 간에 공통 코드를 추출하면 테스트의 가독성과 유지보수성이 향상됩니다.
5. 의미 있는 어설션 사용하기
Section titled “5. 의미 있는 어설션 사용하기”테스트가 실패했을 때 무엇이 잘못되었는지 명확하게 나타내는 어설션을 사용하세요:
// 좋지 않은 예expect(user.toJson(), json); // 오류 메시지가 모호할 수 있음
// 좋은 예: 개별 필드 테스트expect(user.toJson()['id'], json['id']);expect(user.toJson()['name'], json['name']);expect(user.toJson()['email'], json['email']);
6. 테스트 분리 유지하기
Section titled “6. 테스트 분리 유지하기”각 테스트는 독립적이어야 하며 다른 테스트나 외부 상태에 의존해서는 안 됩니다. 테스트의 순서가 결과에 영향을 미치지 않아야 합니다.
7. 경계 조건 테스트하기
Section titled “7. 경계 조건 테스트하기”함수나 메서드의 동작을 검증할 때는 일반적인 케이스뿐만 아니라 경계 조건도 테스트하세요. 예를 들어:
- 빈 리스트나 맵
- null 값 (nullable 필드인 경우)
- 음수, 0, 매우 큰 숫자
- 매우 긴 문자열 또는 빈 문자열
8. 실패 케이스 테스트하기
Section titled “8. 실패 케이스 테스트하기”함수나 메서드가 오류를 올바르게 처리하는지 확인하기 위해 실패 케이스도 테스트하세요:
test('fetchUser throws exception for non-existent user', () { // Act & Assert expect(userService.fetchUser(-1), throwsException);});
단위 테스트는 코드의 신뢰성을 높이는 데 매우 중요합니다. Flutter에서 단위 테스트를 작성하면 앱의 품질이 향상되고, 버그를 조기에 발견할 수 있으며, 코드의 유지보수성이 개선됩니다. 이 장에서는 Dart 함수, 모델 클래스, 비동기 코드, 비즈니스 로직 등 다양한 코드 유형에 대한 단위 테스트 작성 방법을 살펴보았습니다.
가장 좋은 방법은 처음부터 테스트를 작성하는 것이지만, 기존 프로젝트에서도 점진적으로 테스트를 추가하여 이점을 얻을 수 있습니다. 테스트 주도 개발(TDD) 접근 방식을 사용하면 더 견고하고 유지보수하기 쉬운 코드베이스를 구축하는 데 도움이 됩니다.
다음 장에서는 위젯 테스트에 대해 자세히 알아보겠습니다. 위젯 테스트는 단위 테스트보다 한 단계 더 나아가 Flutter 위젯의 동작과 상호작용을 테스트합니다.