[Flutter] 단위 테스트
Flutter 프로젝트를 시작하기 전에 테스트에 대한 개념을 정리해보기로 했다. 오늘은 단위 테스트(Unit test)에 대해 알아보려고 한다.
테스트의 종류
테스트는 보통 정적 테스트와 동적 테스트로 나뉜다.
정적 테스트
프로그램을 실행하지 않고 테스트하는 기법이다. 실행 없이 개발 산출물(코드나 문서 등)을 분석하기만 한다. 코드 리뷰나 별도의 정적 테스트 도구를 통해 진행한다.
- Flutter에서는
flutter analyze명령어를 통해 문법오류, 스타일 위반, 사용되지 않는 코드 등을 찾아낼 수 있다.
동적 테스트
프로그램을 직접 실행해보면서 테스트하는 기법이다.
Flutter에서 지원하는 동적 테스트로는 단위 테스트, 위젯 테스트, 통합 테스트 가 있다.
단위 테스트
메소드나 클래스 각각이 제대로 동작하는지 확인한다.위젯 테스트
앱을 실행하지 않고 각 위젯의 동작을 확인한다.통합 테스트
실제 디바이스/에뮬레이터에서 직접 앱을 실행하며 시나리오 단위로 앱의 동작을 확인한다(이때 integration_test 패키지를 이용해 자동으로 앱을 띄우고 기능을 검증할 수 있다).
단위 테스트
방법
Flutter의 단위 테스트는 flutter test 명령어로 실행할 수 있다. 이 명령어는 test/ 디렉토리(프로젝트 생성 시에 자동으로 생성된다) 아래의 _test.dart 로 끝나는 모든 파일들을 찾아서 실행한다.
1
2
3
4
5
6
7
8
9
class Counter {
int _count = 0;
int get count => _count;
void increment() {
_count++;
}
}
위 코드는 간단한 Counter 예제다. 이 클래스에 대한 단위 테스트를 만들어보자.
test/ 디렉토리 안에 counter_test.dart 파일을 생성하고, 테스트 코드를 다음과 같은 형식으로 작성한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// test/counter_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_practice/counter.dart';
void main() {
group(
'Counter increment tests',
() {
test(
'test 1',
() {
final counter = Counter();
counter.increment();
expect(counter.count, 1);
},
);
test(
'test 2',
() {
final counter = Counter();
counter.increment();
counter.increment();
expect(counter.count, 2);
},
);
},
);
}
Counter 객체를 생성하고, increment()를 호출한 뒤 값이 의도한 대로 변경되었는지를 expect()로 확인하는 구조다. 하나의 테스트 파일에는 보통 테스트를 실행하는 main() 함수 하나를 두고, 그 안에서 관련 테스트들을 group()으로 묶어 관리한다.
테스트 파일에서 사용하는 test(), group(), expect(), setUp()과 같은 함수들은 모두 flutter_test 패키지에 포함되어 있다. 이 패키지는 Flutter 프로젝트 생성 시 기본으로 pubspec.yaml에 포함되어 있기 때문에, 별도로 설치할 필요는 없다.
이제 터미널에서 명령어를 실행해보자.
1
flutter test
1
00:04 +3: All tests passed!
만약 실패한 테스트가 있다면, 어떤 테스트가 실패했는지, 기대한 값과 실제 결과가 무엇인지 상세하게 출력된다.
단위 테스트: Mock 객체를 이용
간단한 클래스는 위처럼 바로 테스트가 가능하지만, 아래와 같이 외부 API 통신을 포함하는 메소드라면 테스트에 제약이 생긴다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// api_service.dart
import 'package:dio/dio.dart';
import 'package:flutter_practice/models/todo.dart';
class ApiService {
final Dio _dio;
ApiService(this._dio);
Future<List<Todo>> fetchTodos() async {
try {
final response = await _dio.get(
'https://jsonplaceholder.typicode.com/todos',
);
if (response.statusCode == 200) {
return (response.data as List)
.map((todoJson) => Todo.fromJson(todoJson))
.toList();
} else {
throw Exception('fetchTodos() 실패');
}
} catch (e) {
throw Exception('fetchTodos() 실패: $e');
}
}
}
이 메소드를 테스트하려고 하면 실제 네트워크 요청이 발생한다. 만약 네트워크 환경이 제한되어 있거나, 서버에 문제가 있는 상황이라면 API 테스트는 실패할 것이다.
❓ Dio vs http 간단 비교
http 패키지는 http.get(Uri.parse(“…”))처럼 Uri로 명시적으로 변환해야 한다. 반면, dio 패키지는 dio.get(“…“)처럼 문자열을 직접 넘겨도 자동으로 Uri 변환을 해주고, jsonEncode, jsonDecode도 자동으로 처리되어 편리하다.
이때 모의 객체인 Mock 객체를 통해 테스트가 가능하다. Mock 객체를 사용하면 실제 네트워크나 외부 시스템에 의존하지 않고도 내부 로직을 독립적으로 검증할 수 있다.
Mock 객체를 만들기 위해서는 mockito 패키지, 그리고 코드 자동 생성을 위한 build_runner 패키지가 필요하다.
1
flutter pub add dev:mockito dev:build_runner
1
2
3
dev_dependencies:
mockito: ^5.4.6
build_runner: ^2.4.15
test/ 디렉토리 안에 api_service_test.dart 파일을 생성하고, 다음과 같이 @GenerateMocks 어노테이션을 사용한다.
1
2
3
4
5
6
// test/api_service_test.dart
import 'package:dio/dio.dart';
@GenerateMocks([Dio]) // 대괄호 안에 원하는 모의객체의 이름 넣기
void main() {}
이제 build_runner를 실행해주면, build runner가 어노테이션을 감지하여 자동으로 api_service_test.mocks.dart 파일을 같은 경로에 생성한다.
1
flutter pub run build_runner build
1
2
3
4
5
6
7
8
9
10
11
12
// 생성된 api_service_test.mocks.dart
...
class MockDio extends _i1.Mock implements _i7.Dio {
MockDio() {
_i1.throwOnMissingStub(this);
}
@override
...
}
이제 테스트용 객체인 MockDio를 사용할 수 있다.
❓ 테스트용 클래스 이름
테스트용 클래스의 이름은 직접 지정하지 않는 한 Mockito가 자동으로 지어주는데,
원본 클래스명: Xyz → 모의 객체 이름: MockXyz
이렇게 앞에 Mock-이 붙는 게 기본 규칙이다. 그래서 예시처럼 Dio를 넣는다면 MockDio가 된다.
이름을 직접 지정하고 싶으면 어노테이션 내부에 customMocks 파라미터를 추가로 넣으면 된다.
이제 아까 작성했던 api_service_test.dart에 테스트 코드를 추가해준다. api_service_test.mocks.dart 파일을 추가로 import해준 뒤, MockDio를 사용해 ApiService 내부에 필요한 Dio 객체를 외부에서 주입함으로써, 실제 네트워크 요청 없이 테스트가 가능하도록 만든다. 그리고 원하는 테스트 내용에 따라 main함수 안의 test() 부분을 작성한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import 'package:flutter_practice/api_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:dio/dio.dart';
import 'package:mockito/mockito.dart';
import 'api_service_test.mocks.dart';
@GenerateMocks([Dio]) // 대괄호 안에 원하는 모의객체의 이름 넣기
void main() {
late MockDio _dio;
late ApiService apiService;
// 테스트 이전에 실행되는 함수
setUp(() {
_dio = MockDio(); // 의존성 주입⭐️
apiService = ApiService(_dio); // 의존성 주입⭐️
});
// 테스트 함수
test('fetchTodos() test', () async {
// 실제 통신한 게 아니라 Mock 객체를 사용했기 때문에, response를 직접 정의해줘야 한다
when(_dio.get(any)).thenAnswer(
(_) async => Response(
data: [
{'id': 1, 'title': 'Test Todo 1', 'completed': false},
{'id': 2, 'title': 'Test Todo 2', 'completed': true},
],
statusCode: 200,
requestOptions: RequestOptions(path: ''),
),
);
final todos = await apiService.fetchTodos();
expect(todos.length, 2);
expect(todos[0].id, 1);
expect(todos[0].title, 'Test Todo 1');
expect(todos[0].completed, false);
expect(todos[1].id, 2);
expect(todos[1].title, 'Test Todo 2');
expect(todos[1].completed, true);
});
}
⭐️ 의존성 주입(DI)의 중요성
이번에 구현한 Counter와 ApiService 클래스는 모두 외부에서 인스턴스를 주입받는 구조로 설계되었다. 이처럼 클래스 내부에서 직접 객체를 생성하지 않고, 필요한 객체를 외부에서 주입받는 구조를 의존성 주입(Dependency Injection, DI)이라고 한다.
DI의 가장 큰 장점 중 하나는 테스트의 용이성이다. 앞서 확인했듯이, 외부 네트워크 요청이 필요한 클래스가 실제 통신을 하지 않고도 Mock 객체를 통한 테스트가 가능해진다. 따라서 안정적인 테스트 환경의 구축이 가능해진다.
이외에도 의존성 주입의 장점이 몇 가지 더 있다.
- 유지보수성 및 확장성 증가
객체 간의 강한 결합(Tightly Coupled)을 막아준다.- 강한 결합: 클래스 간의 의존성이 높아서, 한 클래스가 변경되면 다른 클래스도 변경해야 하는 상황이 발생할 수 있음
재사용성 향상
객체를 외부에서 주입받기 때문에, 동일한 인스턴스를 여러 곳에서 재사용하기가 쉬워진다.- 가독성 향상
UI와 비즈니스 로직이 분리되어 코드의 가독성이 높아지고 일관된 아키텍처를 유지할 수 있다.
Flutter에서는 Provider, RiverPod, GetX 등의 상태관리 패키지를 통해 DI를 자연스럽게 적용할 수 있다.
+) 보충: 강한 결합과 비교
아직 개념이 헷갈려서, 강한 결합과 비교하며 다시 정리해보려고 한다.
1
2
3
class ApiService {
final Dio _dio = Dio(); // 강한 결합
}
이렇게 ApiService 클래스 내부에서 Dio 객체를 직접 생성하면, 테스트할 때 Dio를 MockDio로 바꾸는 것이 불가능하다. 이게 바로 유지보수가 어렵고 유연하지 않은 강한 결합 구조다.
반면, 아래처럼 바깥에서 Dio 객체를 주입하는 방식은 다르다.
1
2
final _dio = MockDio();
final apiService = ApiService(_dio); // 의존성 주입
이 구조에서는 ApiService가 어떤 Dio를 쓸지를 외부에서 결정한다. 그래서 테스트에선 MockDio, 실제 환경에선 Dio를 쓸 수 있어 훨씬 유연하다.
의존성은, 어떤 객체가 동작하기 위해 다른 객체를 필요로 하는 상태를 말한다. 그리고 주입은 외부에서 밀어넣는 것을 말한다. 즉 의존성 주입은, 어떤 객체가 필요로 하는 다른 객체를 직접 만들지 않고 외부에서 밀어넣는다는 의미가 된다.
이 글에서는 ApiService가 HTTP 요청을 위해 Dio에 의존하고 있다. 그런데 클래스 내에서 new Dio()로 직접 만들지 않고 외부에서 주입받았기 때문에, MockDio 같은 가짜 객체도 넣을 수 있었고, 덕분에 독립적인 테스트가 가능해졌다.
마무리
이번 단위테스트 구현을 통해서 DI의 필요성과 효과를 직접 체감해볼 수 있었다. 이번 팀 프로젝트에서도 서버 등 외부 환경에 맞춰야 하는 상황이 많을 텐데, 이런 패턴을 적용해두면 테스트나 유지보수가 훨씬 수월해질 것 같다.
참고 링크
Flutter 테스트 유형 및 방법
🧀 [Flutter] API 테스트를 위한 mockito 사용해 보기
