mirror of
https://github.com/ChangJoo-Park/learn-flutter.git
synced 2025-06-10 14:41:37 +00:00
1034 lines
27 KiB
Markdown
1034 lines
27 KiB
Markdown
# 단위 테스트
|
|
|
|
소프트웨어 개발에서 테스트는 코드의 정확성을 검증하고 결함을 조기에 발견하는 데 핵심적인 역할을 합니다. 이 장에서는 Flutter 애플리케이션의 단위 테스트에 대해 다루겠습니다. 단위 테스트는 코드의 가장 작은 단위(일반적으로 함수나 메서드)가 예상대로 작동하는지 확인하는 테스트입니다.
|
|
|
|
## 단위 테스트의 중요성
|
|
|
|
단위 테스트는 다음과 같은 여러 이유로 중요합니다:
|
|
|
|
1. **버그 조기 발견**: 코드 변경이 기존 기능을 손상시키지 않는지 확인할 수 있습니다.
|
|
2. **리팩토링 신뢰성**: 코드를 변경하더라도 동작이 여전히 올바른지 확인할 수 있습니다.
|
|
3. **문서화**: 테스트는 코드가 어떻게 동작해야 하는지 보여주는 생생한 문서 역할을 합니다.
|
|
4. **설계 개선**: 테스트를 작성하면 종종 더 나은 코드 설계로 이어집니다.
|
|
5. **개발 속도 향상**: 장기적으로 디버깅 시간이 줄어들어 개발 속도가 빨라집니다.
|
|
|
|
## Flutter에서 단위 테스트 설정하기
|
|
|
|
### 1. 의존성 추가
|
|
|
|
Flutter 프로젝트에서 단위 테스트를 시작하려면 `pubspec.yaml` 파일에 필요한 패키지를 추가해야 합니다:
|
|
|
|
```yaml
|
|
dev_dependencies:
|
|
flutter_test:
|
|
sdk: flutter
|
|
test: ^1.24.1
|
|
```
|
|
|
|
`flutter_test`는 Flutter SDK의 일부로, Flutter 위젯을 테스트하는 데 필요한 도구를 제공합니다. `test` 패키지는 일반 Dart 코드를 테스트하는 데 사용됩니다.
|
|
|
|
### 2. 테스트 파일 구성
|
|
|
|
테스트 파일은 일반적으로 프로젝트의 `test` 디렉토리에 위치합니다. 테스트 파일 이름은 관례적으로 `{파일명}_test.dart` 형식을 따릅니다:
|
|
|
|
```
|
|
my_app/
|
|
├── lib/
|
|
│ ├── models/
|
|
│ │ └── user.dart
|
|
│ └── utils/
|
|
│ └── validator.dart
|
|
└── test/
|
|
├── models/
|
|
│ └── user_test.dart
|
|
└── utils/
|
|
└── validator_test.dart
|
|
```
|
|
|
|
## 기본 단위 테스트 작성하기
|
|
|
|
### 1. 간단한 유틸리티 함수 테스트
|
|
|
|
먼저 테스트할 간단한 유틸리티 함수를 살펴보겠습니다:
|
|
|
|
```dart
|
|
// lib/utils/calculator.dart
|
|
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` 클래스의 단위 테스트를 작성해 보겠습니다:
|
|
|
|
```dart
|
|
// test/utils/calculator_test.dart
|
|
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. 테스트 실행하기
|
|
|
|
테스트를 실행하는 방법은 여러 가지가 있습니다:
|
|
|
|
**명령줄에서 실행:**
|
|
|
|
```bash
|
|
flutter test
|
|
```
|
|
|
|
특정 테스트 파일만 실행:
|
|
|
|
```bash
|
|
flutter test test/utils/calculator_test.dart
|
|
```
|
|
|
|
**IDE에서 실행:**
|
|
|
|
대부분의 IDE(예: VS Code, Android Studio)는 테스트 파일 옆에 실행 버튼을 제공하여 쉽게 테스트를 실행할 수 있습니다.
|
|
|
|
## 모델 클래스 테스트
|
|
|
|
모델 클래스의 테스트는 특히 JSON 변환과 관련된 코드를 검증하는 데 유용합니다:
|
|
|
|
```dart
|
|
// lib/models/user.dart
|
|
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` 클래스에 대한 테스트:
|
|
|
|
```dart
|
|
// test/models/user_test.dart
|
|
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>()),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
```
|
|
|
|
## 비동기 코드 테스트
|
|
|
|
Flutter 앱에서는 네트워크 요청, 파일 입출력 등 비동기 코드가 흔합니다. 이러한 코드를 테스트하는 방법을 살펴보겠습니다:
|
|
|
|
```dart
|
|
// lib/services/user_service.dart
|
|
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` 클래스의 단위 테스트:
|
|
|
|
```dart
|
|
// test/services/user_service_test.dart
|
|
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 패키지를 설치하고 코드를 생성해야 합니다:
|
|
|
|
```yaml
|
|
dev_dependencies:
|
|
flutter_test:
|
|
sdk: flutter
|
|
mockito: ^5.4.2
|
|
build_runner: ^2.4.6
|
|
```
|
|
|
|
그리고 다음 명령어를 실행하여 Mock 클래스를 생성합니다:
|
|
|
|
```bash
|
|
flutter pub run build_runner build
|
|
```
|
|
|
|
## 비즈니스 로직 레이어(Provider, Riverpod, Bloc 등) 테스트
|
|
|
|
상태 관리 라이브러리를 사용하는 비즈니스 로직 레이어 테스트를 살펴보겠습니다. 여기서는 Riverpod를 예시로 들겠습니다:
|
|
|
|
```dart
|
|
// lib/providers/counter_provider.dart
|
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
|
|
part 'counter_provider.g.dart';
|
|
|
|
@riverpod
|
|
class 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 프로바이더의 단위 테스트:
|
|
|
|
```dart
|
|
// test/providers/counter_provider_test.dart
|
|
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 모델 테스트
|
|
|
|
Freezed나 json_serializable을 사용하는 모델의 테스트 예시입니다:
|
|
|
|
```dart
|
|
// lib/models/product.dart
|
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
|
import 'package:json_annotation/json_annotation.dart';
|
|
|
|
part 'product.freezed.dart';
|
|
part 'product.g.dart';
|
|
|
|
@freezed
|
|
class 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 모델의 테스트:
|
|
|
|
```dart
|
|
// test/models/product_test.dart
|
|
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));
|
|
});
|
|
});
|
|
}
|
|
```
|
|
|
|
## 복잡한 비즈니스 로직 테스트
|
|
|
|
복잡한 비즈니스 로직이 포함된 클래스의 테스트 예시입니다:
|
|
|
|
```dart
|
|
// lib/services/cart_service.dart
|
|
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` 클래스의 단위 테스트:
|
|
|
|
```dart
|
|
// test/services/cart_service_test.dart
|
|
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);
|
|
});
|
|
});
|
|
}
|
|
```
|
|
|
|
## 테스트 커버리지 측정하기
|
|
|
|
테스트 커버리지는 코드가 테스트에 의해 얼마나 실행되었는지를 나타내는 지표입니다. Flutter에서는 다음과 같이 커버리지를 측정할 수 있습니다:
|
|
|
|
```bash
|
|
flutter test --coverage
|
|
```
|
|
|
|
이 명령어는 테스트를 실행하고 `coverage/lcov.info` 파일을 생성합니다. 이 파일을 사람이 읽기 쉬운 HTML 리포트로 변환하려면 `lcov` 도구를 사용합니다:
|
|
|
|
```bash
|
|
genhtml coverage/lcov.info -o coverage/html
|
|
```
|
|
|
|
그런 다음 `coverage/html/index.html` 파일을 브라우저에서 열어 커버리지 리포트를 확인할 수 있습니다.
|
|
|
|
## 단위 테스트 모범 사례
|
|
|
|
### 1. AAA 패턴 사용하기
|
|
|
|
AAA(Arrange, Act, Assert) 패턴은 테스트를 구조화하는 명확한 방법을 제공합니다:
|
|
|
|
- **Arrange**: 테스트에 필요한 데이터와 객체를 설정합니다.
|
|
- **Act**: 테스트하려는 동작을 실행합니다.
|
|
- **Assert**: 예상 결과를 실제 결과와 비교합니다.
|
|
|
|
### 2. 테스트 이름을 명확하게 지정하기
|
|
|
|
```dart
|
|
// 좋지 않은 예
|
|
test('add', () {
|
|
expect(calculator.add(2, 3), 5);
|
|
});
|
|
|
|
// 좋은 예
|
|
test('add returns the sum of two numbers', () {
|
|
expect(calculator.add(2, 3), 5);
|
|
});
|
|
```
|
|
|
|
### 3. 테스트 그룹화하기
|
|
|
|
관련 테스트를 `group` 함수를 사용하여 그룹화하면 테스트 출력을 더 잘 구조화할 수 있습니다:
|
|
|
|
```dart
|
|
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. 중복 제거하기
|
|
|
|
`setUp`과 `tearDown` 함수를 사용하여 테스트 간에 공통 코드를 추출하면 테스트의 가독성과 유지보수성이 향상됩니다.
|
|
|
|
### 5. 의미 있는 어설션 사용하기
|
|
|
|
테스트가 실패했을 때 무엇이 잘못되었는지 명확하게 나타내는 어설션을 사용하세요:
|
|
|
|
```dart
|
|
// 좋지 않은 예
|
|
expect(user.toJson(), json); // 오류 메시지가 모호할 수 있음
|
|
|
|
// 좋은 예: 개별 필드 테스트
|
|
expect(user.toJson()['id'], json['id']);
|
|
expect(user.toJson()['name'], json['name']);
|
|
expect(user.toJson()['email'], json['email']);
|
|
```
|
|
|
|
### 6. 테스트 분리 유지하기
|
|
|
|
각 테스트는 독립적이어야 하며 다른 테스트나 외부 상태에 의존해서는 안 됩니다. 테스트의 순서가 결과에 영향을 미치지 않아야 합니다.
|
|
|
|
### 7. 경계 조건 테스트하기
|
|
|
|
함수나 메서드의 동작을 검증할 때는 일반적인 케이스뿐만 아니라 경계 조건도 테스트하세요. 예를 들어:
|
|
|
|
- 빈 리스트나 맵
|
|
- null 값 (nullable 필드인 경우)
|
|
- 음수, 0, 매우 큰 숫자
|
|
- 매우 긴 문자열 또는 빈 문자열
|
|
|
|
### 8. 실패 케이스 테스트하기
|
|
|
|
함수나 메서드가 오류를 올바르게 처리하는지 확인하기 위해 실패 케이스도 테스트하세요:
|
|
|
|
```dart
|
|
test('fetchUser throws exception for non-existent user', () {
|
|
// Act & Assert
|
|
expect(userService.fetchUser(-1), throwsException);
|
|
});
|
|
```
|
|
|
|
## 결론
|
|
|
|
단위 테스트는 코드의 신뢰성을 높이는 데 매우 중요합니다. Flutter에서 단위 테스트를 작성하면 앱의 품질이 향상되고, 버그를 조기에 발견할 수 있으며, 코드의 유지보수성이 개선됩니다. 이 장에서는 Dart 함수, 모델 클래스, 비동기 코드, 비즈니스 로직 등 다양한 코드 유형에 대한 단위 테스트 작성 방법을 살펴보았습니다.
|
|
|
|
가장 좋은 방법은 처음부터 테스트를 작성하는 것이지만, 기존 프로젝트에서도 점진적으로 테스트를 추가하여 이점을 얻을 수 있습니다. 테스트 주도 개발(TDD) 접근 방식을 사용하면 더 견고하고 유지보수하기 쉬운 코드베이스를 구축하는 데 도움이 됩니다.
|
|
|
|
다음 장에서는 위젯 테스트에 대해 자세히 알아보겠습니다. 위젯 테스트는 단위 테스트보다 한 단계 더 나아가 Flutter 위젯의 동작과 상호작용을 테스트합니다.
|