Flutter 튜토리얼 44편: 유닛 테스트

요약#

핵심 요지#

  • 유닛 테스트는 단일 함수, 메서드, 클래스의 동작을 검증합니다.
  • test() 함수로 개별 테스트를 작성하고, group()으로 관련 테스트를 묶습니다.
  • expect() 함수와 Matcher를 사용하여 기대값과 실제값을 비교합니다.
  • Mockito 패키지로 외부 의존성을 모킹하여 격리된 테스트를 작성합니다.

문서가 설명하는 범위#

Flutter 공식 문서의 Testing overview, Introduction to unit testing, Mock dependencies using Mockito를 기반으로 유닛 테스트 작성 방법을 설명합니다.

참고 자료#


Flutter 테스트 개요#

Why - 왜 자동화 테스트가 필요한가요?#

수동으로 앱의 모든 기능을 테스트하는 것은 시간이 많이 걸리고 실수하기 쉽습니다. 자동화 테스트는 코드 변경 시 기존 기능이 깨지지 않았는지 빠르게 확인할 수 있게 해줍니다. 테스트가 없으면 새 기능을 추가할 때마다 불안감이 커집니다.

What - Flutter의 테스트 종류#

Flutter는 세 가지 자동화 테스트를 지원합니다.

테스트 종류대상특징
유닛 테스트함수, 메서드, 클래스빠른 실행, 낮은 신뢰도
위젯 테스트단일 위젯중간 속도, 중간 신뢰도
통합 테스트앱 전체느린 실행, 높은 신뢰도
graph TB subgraph "테스트 피라미드" I[통합 테스트] W[위젯 테스트] U[유닛 테스트] end I --> |적은 수| W W --> |많은 수| U style U fill:#e8f5e9 style W fill:#fff3e0 style I fill:#fce4ec

테스트 피라미드1에 따르면, 유닛 테스트가 가장 많고, 통합 테스트가 가장 적어야 합니다.

How - 테스트 간의 트레이드오프#

요소유닛위젯통합
신뢰도낮음중간높음
유지보수 비용낮음중간높음
의존성적음중간많음
실행 속도빠름빠름느림

Watch out - 주의사항#

  • 유닛 테스트만으로는 UI 버그를 잡을 수 없습니다.
  • 통합 테스트만 있으면 실행 시간이 너무 길어집니다.
  • 세 종류의 테스트를 적절히 조합하세요.

유닛 테스트 기초#

Why - 왜 유닛 테스트를 작성하나요?#

유닛 테스트2는 단일 함수, 메서드, 클래스가 올바르게 동작하는지 검증합니다. 외부 의존성을 모킹하여 테스트 대상만 격리해서 테스트하므로 빠르게 실행됩니다.

What - 유닛 테스트의 특징#

  • 디스크에 읽고 쓰지 않습니다.
  • 화면에 렌더링하지 않습니다.
  • 테스트 프로세스 외부에서 사용자 액션을 받지 않습니다.
  • 외부 의존성은 모킹합니다.

How - 첫 번째 유닛 테스트 작성하기#

1단계: 테스트 의존성 추가

Terminal window
flutter pub add dev:test

2단계: 테스트할 클래스 작성

lib/counter.dart
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단계: 테스트 작성

test/counter_test.dart
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단계: 테스트 실행

Terminal window
# 특정 테스트 파일 실행
flutter test test/counter_test.dart
# 모든 테스트 실행
flutter test

Watch 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#

// 기본 Matcher
expect(value, equals(expected)); // 동등성 비교
expect(value, isNull); // null 확인
expect(value, isNotNull); // null이 아님 확인
expect(value, isTrue); // true 확인
expect(value, isFalse); // false 확인
// 숫자 Matcher
expect(value, greaterThan(10)); // 10보다 큼
expect(value, lessThan(10)); // 10보다 작음
expect(value, inInclusiveRange(1, 10)); // 1~10 사이
// 컬렉션 Matcher
expect(list, isEmpty); // 빈 컬렉션
expect(list, isNotEmpty); // 비어있지 않음
expect(list, hasLength(3)); // 길이가 3
expect(list, contains(item)); // 항목 포함
expect(list, containsAll([1, 2])); // 모든 항목 포함
// 문자열 Matcher
expect(str, startsWith('Hello')); // 시작 문자열
expect(str, endsWith('World')); // 끝 문자열
expect(str, contains('llo')); // 부분 문자열 포함
expect(str, matches(RegExp(r'\d+'))); // 정규식 매칭
// 타입 Matcher
expect(obj, isA<String>()); // 타입 확인
expect(obj, isA<List<int>>()); // 제네릭 타입 확인
// 예외 Matcher
expect(() => 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는 모의 객체를 자동 생성하고, 동작을 정의하고, 호출을 검증하는 패키지입니다.

graph LR T[테스트] --> VM[ViewModel] VM --> MR[Mock Repository] MR -.->|실제 호출 없음| API[API] style MR fill:#e8f5e9 style API fill:#fce4ec

How - Mockito 설정하기#

1단계: 의존성 추가

Terminal window
flutter pub add http
flutter pub add dev:mockito
flutter pub add dev:build_runner

2단계: 테스트할 함수 작성

lib/album.dart
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 생성 어노테이션 추가

test/fetch_album_test.dart
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 클래스 생성

Terminal window
dart run build_runner build

이 명령으로 test/fetch_album_test.mocks.dart 파일이 생성됩니다.

5단계: 테스트 작성

test/fetch_album_test.dart
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 Matcher
when(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 - 비동기 테스트 작성하기#

lib/user_repository.dart
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});
}
test/user_repository_test.dart
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를 사용하세요.

테스트 실행하기#

터미널에서 실행#

Terminal window
# 모든 테스트 실행
flutter test
# 특정 파일 실행
flutter test test/counter_test.dart
# 특정 그룹 실행
flutter test --plain-name "Counter 테스트"
# 특정 테스트만 실행
flutter test --plain-name "값이 증가한다"
# 커버리지 리포트 생성
flutter test --coverage
# 상세 출력
flutter test --reporter expanded

IDE에서 실행#

VS Code

  • 테스트 파일에서 Run 버튼 클릭
  • 또는 Cmd+Shift+PDart: Run All Tests

IntelliJ/Android Studio

  • 테스트 파일 우클릭 → Run 'tests in...'
  • 또는 테스트 함수 옆 실행 아이콘 클릭

Watch out - 주의사항#

  • CI/CD 환경에서는 flutter test를 자동으로 실행하세요.
  • 커버리지 리포트로 테스트되지 않은 코드를 확인하세요.
  • 실패한 테스트는 바로 수정하여 테스트 신뢰성을 유지하세요.

마무리#

유닛 테스트의 핵심을 정리하면 다음과 같습니다.

  1. 테스트 구조: test()로 개별 테스트, group()으로 그룹화, setUp()으로 공통 설정
  2. expect와 Matcher: 다양한 Matcher로 유연한 검증 표현
  3. Mockito: 외부 의존성을 모킹하여 격리된 테스트 작성
  4. 비동기 테스트: async/awaitexpectLater로 비동기 코드 테스트
  5. 테스트 실행: 터미널이나 IDE에서 쉽게 실행

다음 튜토리얼에서는 Flutter 위젯 테스트를 자세히 살펴보겠습니다.


Footnotes#

  1. 테스트 피라미드: Mike Cohn이 제안한 테스트 전략으로, 유닛 테스트를 많이, 통합 테스트를 적게 작성하여 빠른 피드백과 높은 신뢰도를 동시에 얻는 방법입니다.

  2. 유닛 테스트(Unit Test): 소프트웨어의 가장 작은 단위(함수, 메서드, 클래스)를 격리하여 테스트하는 방법입니다. 외부 의존성을 모킹하여 테스트 대상만 검증합니다.

  3. Matcher: 테스트에서 기대값과 실제값을 비교하는 객체입니다. equals, contains, isA 등 다양한 조건을 표현할 수 있습니다.

  4. 모킹(Mocking): 테스트에서 실제 객체 대신 가짜 객체(Mock)를 사용하는 기법입니다. 외부 의존성을 제거하여 테스트를 빠르고 안정적으로 만듭니다.

  5. Mockito: Java에서 시작된 모킹 프레임워크의 Dart 버전입니다. 모의 객체를 자동 생성하고, 동작을 정의하고, 호출을 검증하는 기능을 제공합니다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 44편: 유닛 테스트
https://moodturnpost.net/posts/flutter/flutter-testing-unit/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차