Flutter에서 수행할 수 있는 테스트 유형은 무엇인가요?
질문
Flutter에서 지원하는 다양한 테스트 유형과 각 유형의 특징 및 사용 사례에 대해 설명해주세요.
답변
Flutter는 애플리케이션의 견고성과 신뢰성을 보장하기 위해 다양한 수준의 테스트를 지원합니다. Flutter 테스트는 크게 단위 테스트, 위젯 테스트, 통합 테스트의 세 가지 주요 유형으로 나눌 수 있으며, 각 유형은 테스트 피라미드의 다른 계층을 나타냅니다.
1. 단위 테스트 (Unit Tests)
단위 테스트는 애플리케이션의 가장 작은 단위인 개별 함수, 메서드 또는 클래스의 기능을 검증합니다. 외부 의존성 없이 격리된 환경에서 실행되며, Flutter UI 컴포넌트와는 관련이 없습니다.
특징:
- 가장 빠르게 실행됨
- 외부 종속성은 모의(mock) 객체로 대체
- Flutter 환경이 필요 없음 (dart 테스트 프레임워크만 사용)
- 비즈니스 로직, 유틸리티 함수, 서비스 레이어 테스트에 이상적
설정:
# pubspec.yaml
dev_dependencies:
test: ^1.21.0
mockito: ^5.3.2
build_runner: ^2.3.3 # mockito 코드 생성용
예시:
// calculator.dart
class Calculator {
int add(int a, int b) => a + b;
int subtract(int a, int b) => a - b;
Future<int> multiplyAsync(int a, int b) async {
await Future.delayed(Duration(milliseconds: 100)); // 비동기 작업 시뮬레이션
return a * b;
}
}
// calculator_test.dart
import 'package:test/test.dart';
import 'package:my_app/calculator.dart';
void main() {
late Calculator calculator;
setUp(() {
calculator = Calculator();
});
group('Calculator', () {
test('add 메서드는 두 정수의 합을 반환해야 함', () {
expect(calculator.add(2, 3), equals(5));
expect(calculator.add(-1, 1), equals(0));
expect(calculator.add(0, 0), equals(0));
});
test('subtract 메서드는 두 정수의 차를 반환해야 함', () {
expect(calculator.subtract(5, 2), equals(3));
expect(calculator.subtract(2, 5), equals(-3));
expect(calculator.subtract(0, 0), equals(0));
});
test('multiplyAsync 메서드는 두 정수의 곱을 비동기적으로 반환해야 함', () async {
expect(await calculator.multiplyAsync(3, 4), equals(12));
expect(await calculator.multiplyAsync(-2, 3), equals(-6));
expect(await calculator.multiplyAsync(0, 5), equals(0));
});
});
}
모의 객체(Mocking) 예시:
// user_repository.dart
class UserRepository {
Future<User> fetchUser(int id) async {
// API 호출 로직
}
}
// user_service.dart
class UserService {
final UserRepository repository;
UserService(this.repository);
Future<String> getUserName(int id) async {
final user = await repository.fetchUser(id);
return user.name;
}
}
// user_service_test.dart
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
import 'package:my_app/user_service.dart';
import 'package:my_app/user_repository.dart';
import 'package:my_app/models/user.dart';
import 'user_service_test.mocks.dart'; // 자동 생성될 파일
@GenerateMocks([UserRepository])
void main() {
late MockUserRepository mockRepository;
late UserService userService;
setUp(() {
mockRepository = MockUserRepository();
userService = UserService(mockRepository);
});
test('getUserName은 사용자 이름을 반환해야 함', () async {
// Mock 동작 설정
when(mockRepository.fetchUser(1)).thenAnswer((_) async =>
User(id: 1, name: '홍길동', email: 'hong@example.com'));
// 테스트할 메서드 호출
final name = await userService.getUserName(1);
// 검증
expect(name, equals('홍길동'));
verify(mockRepository.fetchUser(1)).called(1);
});
}
코드 생성 명령:
flutter pub run build_runner build
2. 위젯 테스트 (Widget Tests)
위젯 테스트는 개별 위젯의 UI 동작을 검증합니다. 실제 기기 없이 메모리에서 위젯을 렌더링하고 상호 작용할 수 있어 통합 테스트보다 빠르지만 실제 환경과 완전히 동일하지는 않습니다.
특징:
- 단위 테스트보다 느리지만 통합 테스트보다 빠름
- 위젯의 랜더링, 이벤트 처리, 상태 관리 등을 테스트
flutter_test
패키지 사용- Flutter 위젯 트리를 메모리에 생성하고 테스트
설정:
# pubspec.yaml
dev_dependencies:
flutter_test:
sdk: flutter
예시:
// counter_widget.dart
class CounterWidget extends StatefulWidget {
@override
_CounterWidgetState createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'$_counter',
style: TextStyle(fontSize: 24),
key: Key('counter_value'),
),
ElevatedButton(
onPressed: _incrementCounter,
child: Text('증가'),
key: Key('increment_button'),
),
],
);
}
}
// counter_widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/counter_widget.dart';
void main() {
testWidgets('Counter 위젯이 클릭시 값을 증가시켜야 함', (WidgetTester tester) async {
// 위젯 빌드
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: CounterWidget(),
),
),
);
// 초기 상태 확인
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// 버튼 탭
await tester.tap(find.byKey(Key('increment_button')));
// 위젯 리빌드 및 애니메이션 완료 대기
await tester.pumpAndSettle();
// 증가된 값 확인
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}
폼 테스트 예시:
// login_form.dart
class LoginForm extends StatefulWidget {
final Function(String, String) onSubmit;
LoginForm({required this.onSubmit});
@override
_LoginFormState createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _emailController,
decoration: InputDecoration(labelText: '이메일'),
validator: (value) =>
value == null || value.isEmpty ? '이메일을 입력하세요' : null,
key: Key('email_field'),
),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(labelText: '비밀번호'),
obscureText: true,
validator: (value) =>
value == null || value.length < 6 ? '6자 이상 입력하세요' : null,
key: Key('password_field'),
),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
widget.onSubmit(
_emailController.text,
_passwordController.text,
);
}
},
child: Text('로그인'),
key: Key('login_button'),
),
],
),
);
}
}
// login_form_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/login_form.dart';
void main() {
testWidgets('로그인 폼 유효성 검사 테스트', (WidgetTester tester) async {
bool submitCalled = false;
String? submittedEmail;
String? submittedPassword;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: LoginForm(
onSubmit: (email, password) {
submitCalled = true;
submittedEmail = email;
submittedPassword = password;
},
),
),
),
);
// 빈 폼 제출 시도
await tester.tap(find.byKey(Key('login_button')));
await tester.pump();
// 유효성 검사 메시지 확인
expect(find.text('이메일을 입력하세요'), findsOneWidget);
expect(submitCalled, isFalse);
// 이메일 입력
await tester.enterText(find.byKey(Key('email_field')), 'test@example.com');
await tester.pump();
// 짧은 비밀번호 입력
await tester.enterText(find.byKey(Key('password_field')), '12345');
await tester.tap(find.byKey(Key('login_button')));
await tester.pump();
// 비밀번호 유효성 검사 메시지 확인
expect(find.text('6자 이상 입력하세요'), findsOneWidget);
expect(submitCalled, isFalse);
// 유효한 비밀번호 입력
await tester.enterText(find.byKey(Key('password_field')), '123456');
await tester.tap(find.byKey(Key('login_button')));
await tester.pump();
// 폼 제출 확인
expect(submitCalled, isTrue);
expect(submittedEmail, equals('test@example.com'));
expect(submittedPassword, equals('123456'));
});
}
3. 통합 테스트 (Integration Tests)
통합 테스트는 애플리케이션의 여러 부분이 함께 작동하는 방식을 실제 장치나 에뮬레이터에서 검증합니다. 앱 전체 또는 중요한 유저 플로우를 테스트하는 데 사용됩니다.
특징:
- 가장 느리게 실행됨
- 실제 장치나 에뮬레이터에서 테스트
- 앱의 실제 성능, 화면 전환, 애니메이션, 네트워크 요청 등을 테스트
- E2E(End-to-End) 테스트에 적합
설정:
# pubspec.yaml
dev_dependencies:
integration_test:
sdk: flutter
flutter_test:
sdk: flutter
예시:
// integration_test/app_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('전체 앱 테스트', () {
testWidgets('로그인부터 홈 화면까지 전체 플로우 테스트', (WidgetTester tester) async {
// 앱 시작
app.main();
await tester.pumpAndSettle();
// 로그인 화면에서 이메일과 비밀번호 입력
await tester.enterText(find.byKey(Key('email_field')), 'test@example.com');
await tester.enterText(find.byKey(Key('password_field')), 'password123');
// 로그인 버튼 클릭
await tester.tap(find.byKey(Key('login_button')));
await tester.pumpAndSettle(); // 화면 전환 및 애니메이션 완료 대기
// 홈 화면으로 이동했는지 확인
expect(find.text('홈 화면'), findsOneWidget);
// 홈 화면에서 할 일 목록 확인
expect(find.byType(ListView), findsOneWidget);
// 할 일 추가 버튼 클릭
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
// 할 일 추가 화면으로 이동했는지 확인
expect(find.text('할 일 추가'), findsOneWidget);
// 할 일 제목과 설명 입력
await tester.enterText(find.byKey(Key('title_field')), '테스트 할 일');
await tester.enterText(find.byKey(Key('description_field')), '통합 테스트를 통해 추가된 할 일');
// 저장 버튼 클릭
await tester.tap(find.byKey(Key('save_button')));
await tester.pumpAndSettle();
// 홈 화면으로 돌아왔는지 확인
expect(find.text('홈 화면'), findsOneWidget);
// 새로 추가된 할 일이 목록에 표시되는지 확인
expect(find.text('테스트 할 일'), findsOneWidget);
});
});
}
실행:
flutter test integration_test/app_test.dart
또는 특정 기기에서 실행:
flutter test integration_test/app_test.dart -d <device-id>
4. 골든 테스트 (Golden Tests)
골든 테스트는 위젯 테스트의 특별한 형태로, 위젯의 시각적 모양이 예상대로인지 확인합니다. 이 테스트는 UI가 변경되었을 때 이를 감지하는 데 유용합니다.
특징:
- 위젯의 스크린샷을 "골든" 파일과 비교
- 디자인 시스템의 일관성 유지에 도움
- 의도하지 않은 UI 변경을 감지
예시:
// button_widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/custom_button.dart';
void main() {
testWidgets('CustomButton 시각적 테스트', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: CustomButton(
text: '확인',
onPressed: () {},
),
),
),
),
);
await expectLater(
find.byType(CustomButton),
matchesGoldenFile('custom_button.png'),
);
});
testWidgets('CustomButton 비활성화 상태 시각적 테스트', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: CustomButton(
text: '확인',
onPressed: null, // 비활성화
),
),
),
),
);
await expectLater(
find.byType(CustomButton),
matchesGoldenFile('custom_button_disabled.png'),
);
});
}
골든 파일을 업데이트하려면:
flutter test --update-goldens test/button_widget_test.dart
5. 성능 테스트 (Performance Tests)
성능 테스트는 애플리케이션의 성능 메트릭을 측정하고 모니터링합니다. Flutter의 flutter_driver
패키지나 DevTools를 사용하여 성능 프로필링을 수행할 수 있습니다.
특징:
- 프레임 드롭, 메모리 사용량, CPU 사용량 등을 측정
- 성능 병목 현상 식별
- 크고 복잡한 위젯 트리의 렌더링 성능 측정
예시:
// test_driver/app.dart
import 'package:flutter/material.dart';
import 'package:flutter_driver/driver_extension.dart';
import 'package:my_app/main.dart' as app;
void main() {
// Flutter Driver 활성화
enableFlutterDriverExtension();
// 앱 실행
app.main();
}
// test_driver/app_test.dart
import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';
void main() {
group('스크롤 성능 테스트', () {
late FlutterDriver driver;
setUpAll(() async {
driver = await FlutterDriver.connect();
});
tearDownAll(() async {
driver.close();
});
test('긴 목록 스크롤 성능 측정', () async {
// 타임라인 기록 시작
final timeline = await driver.traceAction(() async {
// 목록 식별자
final listFinder = find.byType('ListView');
// 목록 끝까지 스크롤
await driver.scroll(
listFinder,
0,
-10000, // 충분히 긴 거리로 스크롤
Duration(seconds: 2),
);
// 다시 맨 위로 스크롤
await driver.scroll(
listFinder,
0,
10000,
Duration(seconds: 2),
);
});
// 성능 요약
final summary = timeline.summaryJson;
// 프레임 통계
expect(summary['missed_frame_count'], lessThan(5)); // 5개 미만의 프레임 드롭
expect(summary['average_frame_build_time_millis'], lessThan(16)); // 60fps 목표
// 출력 (선택 사항)
print('Average frame build time: ${summary['average_frame_build_time_millis']}ms');
print('Missed frames: ${summary['missed_frame_count']}');
print('Jank count: ${summary['jank_count']}');
});
});
}
실행:
flutter drive --target=test_driver/app.dart
6. 테스트 그룹 및 모범 사례
테스트 그룹화:
테스트를 논리적으로 그룹화하면 관련 테스트를 함께 구성하고 공통 설정을 공유할 수 있습니다:
void main() {
group('계산기 테스트', () {
late Calculator calculator;
setUp(() {
calculator = Calculator();
});
group('기본 연산', () {
test('덧셈', () {
expect(calculator.add(2, 3), equals(5));
});
test('뺄셈', () {
expect(calculator.subtract(5, 2), equals(3));
});
});
group('고급 연산', () {
test('거듭제곱', () {
expect(calculator.power(2, 3), equals(8));
});
test('제곱근', () {
expect(calculator.squareRoot(9), equals(3));
});
});
});
}
매개변수화된 테스트:
void main() {
group('사칙연산', () {
final testCases = [
{'a': 2, 'b': 3, 'expected': 5, 'op': 'add'},
{'a': 5, 'b': 2, 'expected': 3, 'op': 'subtract'},
{'a': 4, 'b': 2, 'expected': 8, 'op': 'multiply'},
{'a': 6, 'b': 2, 'expected': 3, 'op': 'divide'},
];
for (final tc in testCases) {
test('${tc['op']}: ${tc['a']} and ${tc['b']} should be ${tc['expected']}', () {
final calculator = Calculator();
int result;
switch (tc['op']) {
case 'add':
result = calculator.add(tc['a'] as int, tc['b'] as int);
break;
case 'subtract':
result = calculator.subtract(tc['a'] as int, tc['b'] as int);
break;
case 'multiply':
result = calculator.multiply(tc['a'] as int, tc['b'] as int);
break;
case 'divide':
result = calculator.divide(tc['a'] as int, tc['b'] as int);
break;
default:
throw Exception('알 수 없는 연산');
}
expect(result, equals(tc['expected']));
});
}
});
}
테스트 커버리지 측정:
테스트 커버리지는 코드의 어떤 부분이 테스트되고 있는지 측정합니다:
flutter test --coverage
이 명령은 coverage/lcov.info
파일을 생성합니다. lcov 도구를 사용하여 HTML 보고서로 변환할 수 있습니다.
결론
Flutter는 다양한 수준의 테스트를 지원하여 애플리케이션의 품질을 보장합니다:
- 단위 테스트: 개별 함수와 클래스의 기능 검증 (비즈니스 로직 테스트)
- 위젯 테스트: 개별 위젯의 UI 동작 검증 (UI 컴포넌트 테스트)
- 통합 테스트: 앱의 여러 부분이 함께 작동하는 방식 검증 (E2E 테스트)
- 골든 테스트: 위젯의 시각적 모양 검증 (UI 일관성 테스트)
- 성능 테스트: 앱의 성능 메트릭 측정 (성능 모니터링)
효과적인 테스트 전략에는 일반적으로 이러한 모든 유형의 테스트가 포함됩니다. 단위 테스트와 위젯 테스트는 빠르게 실행되므로 개발 중에 자주 실행할 수 있고, 통합 테스트는 출시 전에 전체 앱 기능을 확인하는 데 유용합니다. 적절한 테스트 구성은 개발 속도를 높이고 버그를 줄이며 리팩토링을 용이하게 하여 장기적으로 유지보수 가능한 애플리케이션을 구축하는 데 도움이 됩니다.