Flutter 튜토리얼 44편: 유닛 테스트
요약
핵심 요지
- 유닛 테스트는 단일 함수, 메서드, 클래스의 동작을 검증합니다.
test()함수로 개별 테스트를 작성하고,group()으로 관련 테스트를 묶습니다.expect()함수와 Matcher를 사용하여 기대값과 실제값을 비교합니다.- Mockito 패키지로 외부 의존성을 모킹하여 격리된 테스트를 작성합니다.
문서가 설명하는 범위
Flutter 공식 문서의 Testing overview, Introduction to unit testing, Mock dependencies using Mockito를 기반으로 유닛 테스트 작성 방법을 설명합니다.
참고 자료
Flutter 테스트 개요
Why - 왜 자동화 테스트가 필요한가요?
수동으로 앱의 모든 기능을 테스트하는 것은 시간이 많이 걸리고 실수하기 쉽습니다. 자동화 테스트는 코드 변경 시 기존 기능이 깨지지 않았는지 빠르게 확인할 수 있게 해줍니다. 테스트가 없으면 새 기능을 추가할 때마다 불안감이 커집니다.
What - Flutter의 테스트 종류
Flutter는 세 가지 자동화 테스트를 지원합니다.
| 테스트 종류 | 대상 | 특징 |
|---|---|---|
| 유닛 테스트 | 함수, 메서드, 클래스 | 빠른 실행, 낮은 신뢰도 |
| 위젯 테스트 | 단일 위젯 | 중간 속도, 중간 신뢰도 |
| 통합 테스트 | 앱 전체 | 느린 실행, 높은 신뢰도 |
테스트 피라미드1에 따르면, 유닛 테스트가 가장 많고, 통합 테스트가 가장 적어야 합니다.
How - 테스트 간의 트레이드오프
| 요소 | 유닛 | 위젯 | 통합 |
|---|---|---|---|
| 신뢰도 | 낮음 | 중간 | 높음 |
| 유지보수 비용 | 낮음 | 중간 | 높음 |
| 의존성 | 적음 | 중간 | 많음 |
| 실행 속도 | 빠름 | 빠름 | 느림 |
Watch out - 주의사항
- 유닛 테스트만으로는 UI 버그를 잡을 수 없습니다.
- 통합 테스트만 있으면 실행 시간이 너무 길어집니다.
- 세 종류의 테스트를 적절히 조합하세요.
유닛 테스트 기초
Why - 왜 유닛 테스트를 작성하나요?
유닛 테스트2는 단일 함수, 메서드, 클래스가 올바르게 동작하는지 검증합니다. 외부 의존성을 모킹하여 테스트 대상만 격리해서 테스트하므로 빠르게 실행됩니다.
What - 유닛 테스트의 특징
- 디스크에 읽고 쓰지 않습니다.
- 화면에 렌더링하지 않습니다.
- 테스트 프로세스 외부에서 사용자 액션을 받지 않습니다.
- 외부 의존성은 모킹합니다.
How - 첫 번째 유닛 테스트 작성하기
1단계: 테스트 의존성 추가
flutter pub add dev:test2단계: 테스트할 클래스 작성
class Counter { int value = 0;
void increment() => value++;
void decrement() => value--;
void reset() => value = 0;}3단계: 테스트 파일 생성
테스트 파일은 test/ 폴더에 위치하고, _test.dart로 끝나야 합니다.
my_app/ lib/ counter.dart test/ counter_test.dart // 테스트 파일4단계: 테스트 작성
import 'package:my_app/counter.dart';import 'package:test/test.dart';
void main() { test('Counter 값이 증가한다', () { // Arrange: 테스트 준비 final counter = Counter();
// Act: 동작 실행 counter.increment();
// Assert: 결과 검증 expect(counter.value, 1); });}5단계: 테스트 실행
# 특정 테스트 파일 실행flutter test test/counter_test.dart
# 모든 테스트 실행flutter testWatch out - 주의사항
- 테스트 파일은 반드시
_test.dart로 끝나야 합니다. test/폴더는 프로젝트 루트에 있어야 합니다.- 각 테스트는 독립적이어야 합니다. 다른 테스트의 결과에 의존하지 마세요.
테스트 구조화
Why - 왜 테스트를 구조화해야 하나요?
테스트가 많아지면 관리가 어려워집니다. 관련 테스트를 그룹으로 묶고, 공통 설정을 추출하면 테스트 코드가 깔끔해집니다.
What - 테스트 구조화 도구
| 함수 | 역할 | 설명 |
|---|---|---|
test() | 개별 테스트 | 하나의 테스트 케이스 정의 |
group() | 테스트 그룹 | 관련 테스트를 논리적으로 묶음 |
setUp() | 사전 설정 | 각 테스트 전에 실행 |
tearDown() | 사후 정리 | 각 테스트 후에 실행 |
setUpAll() | 전체 사전 설정 | 그룹의 모든 테스트 전에 한 번 실행 |
tearDownAll() | 전체 사후 정리 | 그룹의 모든 테스트 후에 한 번 실행 |
How - group과 setUp 사용하기
import 'package:my_app/counter.dart';import 'package:test/test.dart';
void main() { group('Counter 테스트', () { late Counter counter;
// 각 테스트 전에 새 Counter 생성 setUp(() { counter = Counter(); });
test('초기값은 0이다', () { expect(counter.value, 0); });
test('값이 증가한다', () { counter.increment(); expect(counter.value, 1); });
test('값이 감소한다', () { counter.decrement(); expect(counter.value, -1); });
test('값이 리셋된다', () { counter.increment(); counter.increment(); counter.reset(); expect(counter.value, 0); }); });
group('Counter 경계값 테스트', () { test('음수에서 증가하면 0이 될 수 있다', () { final counter = Counter()..decrement(); counter.increment(); expect(counter.value, 0); }); });}How - 중첩 그룹 사용하기
void main() { group('Calculator', () { group('덧셈', () { test('양수 + 양수', () { expect(Calculator.add(2, 3), 5); });
test('음수 + 양수', () { expect(Calculator.add(-2, 3), 1); }); });
group('뺄셈', () { test('양수 - 양수', () { expect(Calculator.subtract(5, 3), 2); }); }); });}Watch out - 주의사항
setUp()은 각 테스트마다 실행되므로 비용이 큰 작업은setUpAll()을 사용하세요.- 테스트 간 상태 공유를 피하세요. 각 테스트는 독립적이어야 합니다.
- 그룹 이름은 테스트 대상을, 테스트 이름은 예상 동작을 설명하세요.
expect와 Matcher
Why - 왜 Matcher가 필요한가요?
단순한 동등성 비교만으로는 모든 검증을 표현하기 어렵습니다. Matcher3는 다양한 조건을 표현할 수 있는 유연한 검증 도구입니다.
What - 자주 사용하는 Matcher
// 기본 Matcherexpect(value, equals(expected)); // 동등성 비교expect(value, isNull); // null 확인expect(value, isNotNull); // null이 아님 확인expect(value, isTrue); // true 확인expect(value, isFalse); // false 확인
// 숫자 Matcherexpect(value, greaterThan(10)); // 10보다 큼expect(value, lessThan(10)); // 10보다 작음expect(value, inInclusiveRange(1, 10)); // 1~10 사이
// 컬렉션 Matcherexpect(list, isEmpty); // 빈 컬렉션expect(list, isNotEmpty); // 비어있지 않음expect(list, hasLength(3)); // 길이가 3expect(list, contains(item)); // 항목 포함expect(list, containsAll([1, 2])); // 모든 항목 포함
// 문자열 Matcherexpect(str, startsWith('Hello')); // 시작 문자열expect(str, endsWith('World')); // 끝 문자열expect(str, contains('llo')); // 부분 문자열 포함expect(str, matches(RegExp(r'\d+'))); // 정규식 매칭
// 타입 Matcherexpect(obj, isA<String>()); // 타입 확인expect(obj, isA<List<int>>()); // 제네릭 타입 확인
// 예외 Matcherexpect(() => throwError(), throwsException);expect(() => throwError(), throwsA(isA<FormatException>()));How - 실제 테스트에서 Matcher 활용
import 'package:test/test.dart';
class UserService { List<String> users = [];
void addUser(String name) { if (name.isEmpty) { throw ArgumentError('이름은 비어있을 수 없습니다'); } users.add(name); }
String? findUser(String name) { return users.firstWhere( (u) => u == name, orElse: () => throw StateError('사용자를 찾을 수 없습니다'), ); }}
void main() { group('UserService', () { late UserService service;
setUp(() { service = UserService(); });
test('사용자 추가 후 목록에 포함된다', () { service.addUser('홍길동');
expect(service.users, isNotEmpty); expect(service.users, contains('홍길동')); expect(service.users, hasLength(1)); });
test('빈 이름으로 추가하면 예외가 발생한다', () { expect( () => service.addUser(''), throwsA(isA<ArgumentError>()), ); });
test('존재하지 않는 사용자 검색 시 예외가 발생한다', () { expect( () => service.findUser('없는사람'), throwsA(isA<StateError>()), ); }); });}Watch out - 주의사항
equals()는 생략 가능합니다:expect(value, 10)은expect(value, equals(10))과 같습니다.- 예외 테스트 시 함수를 직접 호출하지 말고 람다로 감싸세요.
- 커스텀 Matcher를 만들어 도메인 특화 검증을 표현할 수 있습니다.
Mockito로 의존성 모킹
Why - 왜 모킹이 필요한가요?
함수가 외부 서비스(API, 데이터베이스)에 의존하면 테스트가 어려워집니다. 네트워크 상태에 따라 테스트 결과가 달라지고(flaky test), 실행 속도도 느려집니다. 모킹4은 외부 의존성을 가짜 객체로 대체하여 이 문제를 해결합니다.
What - Mockito의 역할
Mockito5는 모의 객체를 자동 생성하고, 동작을 정의하고, 호출을 검증하는 패키지입니다.
How - Mockito 설정하기
1단계: 의존성 추가
flutter pub add httpflutter pub add dev:mockitoflutter pub add dev:build_runner2단계: 테스트할 함수 작성
import 'dart:convert';import 'package:http/http.dart' as http;
class Album { final int userId; final int id; final String title;
Album({required this.userId, required this.id, required this.title});
factory Album.fromJson(Map<String, dynamic> json) { return Album( userId: json['userId'] as int, id: json['id'] as int, title: json['title'] as String, ); }}
// 의존성을 주입받도록 수정Future<Album> fetchAlbum(http.Client client) async { final response = await client.get( Uri.parse('https://jsonplaceholder.typicode.com/albums/1'), );
if (response.statusCode == 200) { return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>); } else { throw Exception('앨범을 불러오는데 실패했습니다'); }}3단계: Mock 생성 어노테이션 추가
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/album.dart';
// Mock 클래스 생성을 위한 어노테이션@GenerateMocks([http.Client])void main() { // 테스트 작성}4단계: Mock 클래스 생성
dart run build_runner build이 명령으로 test/fetch_album_test.mocks.dart 파일이 생성됩니다.
5단계: 테스트 작성
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/album.dart';
// 생성된 Mock 파일 임포트import 'fetch_album_test.mocks.dart';
@GenerateMocks([http.Client])void main() { group('fetchAlbum', () { late MockClient mockClient;
setUp(() { mockClient = MockClient(); });
test('성공적으로 Album을 반환한다', () async { // Arrange: Mock 동작 정의 when(mockClient.get(any)).thenAnswer( (_) async => http.Response( '{"userId": 1, "id": 2, "title": "mock album"}', 200, ), );
// Act: 함수 실행 final album = await fetchAlbum(mockClient);
// Assert: 결과 검증 expect(album, isA<Album>()); expect(album.title, 'mock album'); });
test('HTTP 에러 시 예외를 던진다', () async { // Arrange: 에러 응답 설정 when(mockClient.get(any)).thenAnswer( (_) async => http.Response('Not Found', 404), );
// Assert: 예외 발생 확인 expect( () => fetchAlbum(mockClient), throwsException, ); });
test('네트워크 에러 시 예외를 던진다', () async { // Arrange: 네트워크 에러 설정 when(mockClient.get(any)).thenThrow( Exception('네트워크 연결 실패'), );
// Assert: 예외 발생 확인 expect( () => fetchAlbum(mockClient), throwsException, ); }); });}How - Mockito 핵심 함수
// when - 모의 동작 정의when(mock.someMethod(any)).thenReturn(value); // 동기 반환when(mock.someMethod(any)).thenAnswer((_) async => value); // 비동기 반환when(mock.someMethod(any)).thenThrow(Exception()); // 예외 발생
// verify - 호출 검증verify(mock.someMethod(any)).called(1); // 1번 호출됨verify(mock.someMethod('specific')).called(1); // 특정 인자로 호출됨verifyNever(mock.someMethod(any)); // 호출되지 않음
// Argument Matcherwhen(mock.method(any)); // 모든 인자when(mock.method(argThat(contains('test')))); // 조건 매칭when(mock.method(captureAny)); // 인자 캡처Watch out - 주의사항
- Mock을 사용하려면 의존성을 주입받도록 코드를 설계해야 합니다.
@GenerateMocks후 반드시build_runner build를 실행하세요.- Mock 파일(
.mocks.dart)은 버전 관리에 포함하지 않아도 됩니다.
비동기 테스트
Why - 왜 비동기 테스트가 필요한가요?
Flutter 앱의 대부분의 작업(API 호출, 파일 읽기, 데이터베이스 쿼리)은 비동기입니다. 비동기 코드를 올바르게 테스트하려면 테스트도 비동기로 작성해야 합니다.
What - 비동기 테스트의 특징
비동기 테스트는 async/await를 사용하여 작성합니다.
테스트 프레임워크가 Future가 완료될 때까지 자동으로 기다립니다.
How - 비동기 테스트 작성하기
class UserRepository { Future<User> fetchUser(String id) async { // 네트워크 요청 시뮬레이션 await Future.delayed(const Duration(milliseconds: 100)); return User(id: id, name: '사용자 $id'); }
Future<List<User>> fetchUsers() async { await Future.delayed(const Duration(milliseconds: 200)); return [ User(id: '1', name: '홍길동'), User(id: '2', name: '김철수'), ]; }}
class User { final String id; final String name; User({required this.id, required this.name});}import 'package:test/test.dart';import 'package:my_app/user_repository.dart';
void main() { group('UserRepository', () { late UserRepository repository;
setUp(() { repository = UserRepository(); });
// async 테스트 test('사용자를 가져온다', () async { final user = await repository.fetchUser('1');
expect(user.id, '1'); expect(user.name, '사용자 1'); });
test('여러 사용자를 가져온다', () async { final users = await repository.fetchUsers();
expect(users, hasLength(2)); expect(users.first.name, '홍길동'); });
// 타임아웃 설정 test( '긴 작업도 완료된다', () async { final users = await repository.fetchUsers(); expect(users, isNotEmpty); }, timeout: Timeout(Duration(seconds: 5)), ); });}How - Stream 테스트하기
// Stream을 반환하는 함수Stream<int> countStream(int max) async* { for (var i = 1; i <= max; i++) { await Future.delayed(const Duration(milliseconds: 10)); yield i; }}
// Stream 테스트void main() { test('countStream이 순서대로 숫자를 방출한다', () async { final stream = countStream(3);
// emitsInOrder: 순서대로 방출되는지 확인 await expectLater( stream, emitsInOrder([1, 2, 3]), ); });
test('Stream이 올바르게 완료된다', () async { final stream = countStream(2);
await expectLater( stream, emitsInOrder([1, 2, emitsDone]), ); });
test('Stream 에러를 테스트한다', () async { Stream<int> errorStream() async* { yield 1; throw Exception('에러 발생'); }
await expectLater( errorStream(), emitsInOrder([1, emitsError(isA<Exception>())]), ); });}Watch out - 주의사항
- 비동기 테스트는 반드시
async로 선언하고await로 기다리세요. - 긴 작업은
timeout옵션으로 제한 시간을 설정하세요. - Stream 테스트에는
expectLater를 사용하세요.
테스트 실행하기
터미널에서 실행
# 모든 테스트 실행flutter test
# 특정 파일 실행flutter test test/counter_test.dart
# 특정 그룹 실행flutter test --plain-name "Counter 테스트"
# 특정 테스트만 실행flutter test --plain-name "값이 증가한다"
# 커버리지 리포트 생성flutter test --coverage
# 상세 출력flutter test --reporter expandedIDE에서 실행
VS Code
- 테스트 파일에서
Run버튼 클릭 - 또는
Cmd+Shift+P→Dart: Run All Tests
IntelliJ/Android Studio
- 테스트 파일 우클릭 →
Run 'tests in...' - 또는 테스트 함수 옆 실행 아이콘 클릭
Watch out - 주의사항
- CI/CD 환경에서는
flutter test를 자동으로 실행하세요. - 커버리지 리포트로 테스트되지 않은 코드를 확인하세요.
- 실패한 테스트는 바로 수정하여 테스트 신뢰성을 유지하세요.
마무리
유닛 테스트의 핵심을 정리하면 다음과 같습니다.
- 테스트 구조:
test()로 개별 테스트,group()으로 그룹화,setUp()으로 공통 설정 - expect와 Matcher: 다양한 Matcher로 유연한 검증 표현
- Mockito: 외부 의존성을 모킹하여 격리된 테스트 작성
- 비동기 테스트:
async/await와expectLater로 비동기 코드 테스트 - 테스트 실행: 터미널이나 IDE에서 쉽게 실행
다음 튜토리얼에서는 Flutter 위젯 테스트를 자세히 살펴보겠습니다.
Footnotes
-
테스트 피라미드: Mike Cohn이 제안한 테스트 전략으로, 유닛 테스트를 많이, 통합 테스트를 적게 작성하여 빠른 피드백과 높은 신뢰도를 동시에 얻는 방법입니다. ↩
-
유닛 테스트(Unit Test): 소프트웨어의 가장 작은 단위(함수, 메서드, 클래스)를 격리하여 테스트하는 방법입니다. 외부 의존성을 모킹하여 테스트 대상만 검증합니다. ↩
-
Matcher: 테스트에서 기대값과 실제값을 비교하는 객체입니다.
equals,contains,isA등 다양한 조건을 표현할 수 있습니다. ↩ -
모킹(Mocking): 테스트에서 실제 객체 대신 가짜 객체(Mock)를 사용하는 기법입니다. 외부 의존성을 제거하여 테스트를 빠르고 안정적으로 만듭니다. ↩
-
Mockito: Java에서 시작된 모킹 프레임워크의 Dart 버전입니다. 모의 객체를 자동 생성하고, 동작을 정의하고, 호출을 검증하는 기능을 제공합니다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!