1. Unit Test
Unit Test는 단위 테스트라고도 하며, 단일 함수, 메서드 또는 클래스 등 작은 코드 단위를 독립적으로 테스트하는 것입니다.
테스트 코드에 관해 궁금하다면 여기를 클릭하세요.
2. Mock?
실제 앱에서는 API 통신, 네트워크 요청, 데이터베이스 접근 등 외부 리소스에 의존하는 코드가 많습니다.
이런 외부 의존성은 테스트를 어렵게 만드는데요.
이때 사용하는 것이 바로 mock(모의 객체)입니다.
mock은 실제 객체처럼 동작하지만 내부 구현은 없고, 테스트 목적으로 특정 동작만 흉내 내는 가짜 객체입니다.
실제 객체는 외부 시스템과 연결되지만 mock 객체는 외부 연결 없이 동작만 흉내냅니다.
즉, 원래는 서버에 네트워크 요청을 보내고, 데이터를 받아오는 상황이지만,
mock 객체는 항상 개발자가 설정한 고정된 값을 반환하도록하여 테스트를 진행합니다.
그래서 외부 의존성 없이 내부 로직만 독립적으로 검증할 수 있는 것입니다.
3. mockito vs mocktail
Flutter에서 Unit Test를 할 때 의존성을 mock 객체로 대체할 수 있는 대표적인 라이브러리는 mockito와 mocktail이 있습니다.
1) mockito
mockito는 Flutter에서 가장 오래되고 널리 사용되는 mocking 라이브러리입니다.
@GenerateMocks 어노테이션을 사용하여 해당 클래스의 mock 객체를 자동으로 생성하며,
생성된 mock 클래스는 .mocks.dart 파일로 별도로 관리합니다.
또한, 이 코드 생성을 위해서는 반드시 build_runner 명령어를 실행해야 합니다.
이는 Dart에서 사용하는 코드 생성도구로 어노테이션을 해석해서 코드 파일을 자동으로 생성합니다.
즉, mockito는 자동 생성 방식을 사용하기 때문에 타입 체크, 자동완성 등에서 강점을 가집니다.
하지만 설정이 번거롭고, mock 클래스를 매번 재생성해야 하는 단점이 있습니다.
2) mocktail
mocktail은 mockito에서 영감을 받은 라이브러리로 mockito의 복잡한 설정을 간소화한 경량 mocking 라이브러리입니다.
코드 생성이 전혀 필요 없으며, 테스트 대상 클래스를 상속받는 mock 클래스를 직접 만들어 사용하면 됩니다.
즉, 간단하고 빠르게 테스트를 할 수 있다는 장점이 있어 초보자나 간단한 테스트 환경에서 매우 유용합니다.
3) 비교
| 항목 | mockito | mocktail |
| 코드 생성 | 필요 (build_runner) | 불필요 |
| 설정 복잡도 | 복잡 (어노테이션, 별도 파일 import) | 매우 간단 |
| IDE 자동완성 | 뛰어남 (생성된 클래스 기반) | 제한적 (수동 생성) |
| 타입 안정성 | 높음 | 중간 |
| 생산성 | 설정 이후에는 우수 | 초반부터 빠름 |
| 커뮤니티 | 오래되고 안정적 | 최근 인기도 상승 중 |
| Spy 지원 | deprecated | 명시적 지원 없음 |
추가로 mockito에서 spy는 공식적으로 deprecated되었지만,
mocktail은 spy기능을 명시적으로 지원하지 않습니다.
spy를 사용해야 한다는건 테스트하려는 코드가 너무 복잡하다는 신호일 수 있기 때문에
일반적으로 spy가 필요한 구조라면 설계를 개선하는 것이 더 낫다고 보기 때문입니다.
spy는 이 메서드가 정말 호출이 되었는지, 어떤 값을 가지고 호출되었는지 테스트에서 확인하고 싶을 때 사용합니다.
지금부터 mocktail을 사용하여 Unit Test를 진행하겠습니다!
4. mockail로 Unit Test 작성하기
1) 패키지 설치
터미널에 http와 mocktail을 설치합니다.
flutter pub add http
flutter pub add mocktail
2) 코드 작성
main.dart에 아래 코드를 작성합니다.
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
/// 비동기로 서버에서 Album 데이터를 가져오는 함수
Future<Album> fetchAlbum(http.Client client) async {
// HTTP GET 요청 수행
final response = await client.get(
Uri.parse('https://jsonplaceholder.typicode.com/albums/1'),
);
// 서버가 200 OK 응답을 준 경우, JSON 파싱해서 Album 객체 반환
if (response.statusCode == 200) {
return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
} else {
// 그 외 상태코드일 경우 예외 발생
throw Exception('Failed to load album');
}
}
/// 서버 응답 JSON 데이터를 담기 위한 모델 클래스
class Album {
final int userId;
final int id;
final String title;
const Album({required this.userId, required this.id, required this.title});
// JSON 데이터를 Album 객체로 변환
factory Album.fromJson(Map<String, dynamic> json) {
return Album(
userId: json['userId'] as int,
id: json['id'] as int,
title: json['title'] as String,
);
}
}
void main() => runApp(const MyApp());
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
// 서버에서 받아올 Album 데이터 (비동기)
late final Future<Album> futureAlbum;
@override
void initState() {
super.initState();
// 앱 시작 시 fetchAlbum 호출하여 Future 저장
futureAlbum = fetchAlbum(http.Client());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Fetch Data Example',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: Scaffold(
appBar: AppBar(title: const Text('Fetch Data Example')),
body: Center(
// FutureBuilder를 사용해 비동기 상태에 따라 UI를 구성
child: FutureBuilder<Album>(
future: futureAlbum,
builder: (context, snapshot) {
// 데이터가 정상적으로 도착한 경우
if (snapshot.hasData) {
return Text(snapshot.data!.title);
}
// 에러가 발생한 경우
else if (snapshot.hasError) {
return Text('${snapshot.error}');
}
// 기본적으로 로딩 중에는 로딩 스피너 표시
return const CircularProgressIndicator();
},
),
),
),
);
}
}
위 코드는 공식문서에 있는 코드를 활용하였습니다.
3) 테스트 코드 작성
먼저 실제 네트워크 요청을 하지 않고도 테스트할 수 있도록 http.Client를 상속받아 가짜 클라이언트를 생성합니다.
// 1. Mock 클래스 정의: http.Client를 상속받은 가짜(Mock) 클라이언트
class MockClient extends Mock implements http.Client {}
그 후, 테스트를 그룹으로 묶어 fetchAlbum 함수 테스트를 진행합니다.
그룹으로 정리하면 관련 함수 테스트를 쉽게 확인할 수 있다는 장점이 있습니다.
void main() {
group('fetchAlbum', () {
test('returns an Album if the http call completes successfully', () async {
// 2. MockClient 인스턴스 생성
final client = MockClient();
// 3. HTTP GET 요청에 대한 응답을 미리 정의 (성공 응답)
when(
() => client.get(
Uri.parse('https://jsonplaceholder.typicode.com/albums/1'),
),
).thenAnswer(
(_) async =>
http.Response('{"userId": 1, "id": 2, "title": "mock"}', 200),
);
// 4. fetchAlbum 호출 시 Album 인스턴스를 반환하는지 검증
expect(await fetchAlbum(client), isA<Album>());
});
// 실패 케이스 테스트
test('throws an exception if the http call completes with an error', () {
// 2. MockClient 인스턴스 생성
final client = MockClient();
// 3. HTTP GET 요청에 대한 응답을 미리 정의 (에러 응답)
when(
() => client.get(
Uri.parse('https://jsonplaceholder.typicode.com/albums/1'),
),
).thenAnswer((_) async => http.Response('Not Found', 404));
// 4. fetchAlbum 호출 시 예외를 던지는지 검증
expect(fetchAlbum(client), throwsException);
});
});
}
5. 참고자료
https://pub.dev/packages/mockito
https://pub.dev/packages/mocktail
https://stackoverflow.com/questions/70226080/mockito-vs-mocktail-in-flutter
https://docs.flutter.dev/cookbook/testing/unit/mocking
'Flutter' 카테고리의 다른 글
| [Flutter] TMDB로 영화관 앱 만들기 (0) | 2025.05.15 |
|---|---|
| [Flutter] 기능 우선 분리(Feature first Structure)로 영화관 앱 구조 설계하기 (0) | 2025.05.15 |
| [Flutter] 앱 테스트 유형 (feat. Integration, Unit, Widget) (0) | 2025.05.07 |
| [Flutter] Riverpod으로 상태관리하고, MVVM패턴 적용해서 Counter앱 만들기 (0) | 2025.05.02 |
| [Flutter] API 키 숨기기 (feat. dotenv) (0) | 2025.04.22 |