UnitTest 완벽 마스터
UnitTest의 핵심 개념과 실전 활용법
학습 항목
이미지 로딩 중...
Flutter 테스트 전략 완벽 가이드
Flutter 앱의 품질을 보장하는 테스트 전략을 초급자도 쉽게 이해할 수 있도록 설명합니다. 단위 테스트부터 통합 테스트까지, 실무에서 바로 적용할 수 있는 테스트 작성법과 노하우를 제공합니다.
목차
- 단위 테스트 기초 - 함수와 클래스 테스트하기
- 위젯 테스트 - UI 컴포넌트 검증하기
- 목(Mock) 객체 활용 - 의존성 격리하기
- 통합 테스트 - 전체 앱 플로우 검증하기
- 테스트 주도 개발(TDD) - 테스트 먼저 작성하기
- 테스트 커버리지 측정 - 테스트 충분도 확인하기
- 비동기 코드 테스트 - Future와 Stream 검증하기
- 상태 관리 테스트 - Provider와 Bloc 검증하기
- 에러 핸들링 테스트 - 예외 상황 검증하기
- 성능 테스트 - 렌더링과 애니메이션 최적화 검증하기
1. 단위 테스트 기초 - 함수와 클래스 테스트하기
시작하며
여러분이 Flutter 앱에서 복잡한 비즈니스 로직을 작성했는데, 나중에 코드를 수정하니 전혀 예상치 못한 곳에서 버그가 발생한 경험 있나요? 급하게 릴리스해야 하는데 어디서 문제가 생겼는지 찾느라 밤을 새우는 상황 말이죠.
이런 문제는 테스트 코드가 없을 때 자주 발생합니다. 코드의 작은 변경이 전체 앱에 어떤 영향을 미칠지 예측하기 어렵고, 매번 수동으로 앱 전체를 테스트하기엔 시간이 너무 오래 걸립니다.
바로 이럴 때 필요한 것이 단위 테스트입니다. 함수와 클래스의 동작을 자동으로 검증해서 코드 변경 시 즉시 문제를 발견할 수 있게 해줍니다.
개요
간단히 말해서, 단위 테스트는 코드의 가장 작은 단위(함수, 메서드, 클래스)가 의도한 대로 동작하는지 검증하는 자동화된 테스트입니다. 실무에서 계산 로직, 데이터 변환, 비즈니스 규칙 검증 등 UI와 분리된 순수한 로직을 작성할 때 단위 테스트는 필수입니다.
예를 들어, 사용자 입력 유효성 검사나 할인율 계산 같은 기능은 단위 테스트로 완벽하게 검증할 수 있습니다. 기존에는 코드를 수정할 때마다 앱을 실행하고 여러 화면을 클릭해가며 수동으로 테스트했다면, 이제는 단 몇 초 만에 수백 개의 테스트를 자동으로 실행할 수 있습니다.
단위 테스트의 핵심 특징은 빠른 실행 속도, 독립성, 그리고 명확한 실패 원인 파악입니다. 이러한 특징들이 개발 생산성을 크게 향상시키고 코드 리팩토링을 안전하게 만들어줍니다.
코드 예제
// test/calculator_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/calculator.dart';
void main() {
group('Calculator 클래스 테스트', () {
late Calculator calculator;
// 각 테스트 전에 새로운 Calculator 인스턴스 생성
setUp(() {
calculator = Calculator();
});
test('두 수를 더하면 정확한 결과를 반환한다', () {
// 준비(Arrange): 테스트 데이터 설정
final a = 5;
final b = 3;
// 실행(Act): 테스트할 기능 실행
final result = calculator.add(a, b);
// 검증(Assert): 결과 확인
expect(result, 8);
});
test('0으로 나누면 예외가 발생한다', () {
expect(() => calculator.divide(10, 0), throwsException);
});
});
}
설명
이것이 하는 일: 위 코드는 Calculator 클래스의 add와 divide 메서드가 올바르게 동작하는지 자동으로 검증합니다. 첫 번째로, setUp 함수에서 각 테스트 전에 새로운 Calculator 인스턴스를 생성합니다.
이렇게 하면 테스트 간에 상태가 공유되지 않아 각 테스트가 독립적으로 실행됩니다. 테스트의 독립성은 매우 중요한데, 한 테스트의 실패가 다른 테스트에 영향을 주지 않기 때문입니다.
그 다음으로, test 함수 내부에서 AAA(Arrange-Act-Assert) 패턴을 따릅니다. Arrange 단계에서 테스트에 필요한 데이터를 준비하고, Act 단계에서 실제 기능을 실행하며, Assert 단계에서 expect 함수로 결과를 검증합니다.
이 패턴은 테스트 코드를 일관되고 읽기 쉽게 만들어줍니다. 마지막으로, group 함수로 관련된 테스트들을 논리적으로 묶어서 관리합니다.
두 번째 테스트에서는 예외 발생을 검증하는 throwsException 매처를 사용하여 에러 처리가 올바른지 확인합니다. 여러분이 이 코드를 사용하면 코드 변경 시 기존 기능이 깨지지 않았는지 즉시 확인할 수 있고, 리팩토링을 자신 있게 진행할 수 있으며, 버그를 조기에 발견하여 수정 비용을 크게 줄일 수 있습니다.
실전 팁
💡 테스트 이름은 "무엇을 테스트하는지"와 "기대 결과"를 명확히 표현하세요. "test1", "test2" 같은 이름은 나중에 테스트 실패 시 원인을 파악하기 어렵습니다.
💡 하나의 test 함수에서는 하나의 동작만 검증하세요. 여러 개를 검증하면 어느 부분에서 실패했는지 파악하기 어렵고, 테스트의 의도도 불명확해집니다.
💡 setUp과 tearDown을 활용하여 테스트 전후 공통 작업을 처리하세요. 코드 중복을 줄이고 테스트 유지보수가 쉬워집니다.
💡 경계값(boundary values)을 꼭 테스트하세요. 0, 음수, 최댓값, 최솟값 같은 특수한 경우에 버그가 자주 숨어있습니다.
💡 테스트 커버리지를 확인하려면 flutter test --coverage 명령을 사용하세요. 하지만 100% 커버리지가 목표가 아니라 중요한 로직이 잘 테스트되었는지가 더 중요합니다.
2. 위젯 테스트 - UI 컴포넌트 검증하기
시작하며
여러분이 로그인 화면을 만들었는데, 사용자가 비밀번호를 입력하지 않고 로그인 버튼을 눌렀을 때 에러 메시지가 제대로 표시되는지 확인하려면 어떻게 하나요? 매번 앱을 실행하고 직접 버튼을 누르면서 테스트하기엔 너무 번거롭습니다.
이런 문제는 UI가 복잡해질수록 더 심각해집니다. 다크 모드에서도 잘 보이는지, 긴 텍스트가 들어가면 레이아웃이 깨지지 않는지, 버튼 클릭 시 올바른 콜백이 호출되는지 등 확인할 것이 너무 많습니다.
바로 이럴 때 필요한 것이 위젯 테스트입니다. UI 컴포넌트의 렌더링, 사용자 상호작용, 상태 변화를 자동으로 검증하여 UI 버그를 조기에 발견할 수 있게 해줍니다.
개요
간단히 말해서, 위젯 테스트는 Flutter 위젯이 화면에 올바르게 렌더링되고 사용자 상호작용에 적절히 반응하는지 검증하는 테스트입니다. 실무에서 버튼 클릭, 텍스트 입력, 스크롤 등 사용자 인터랙션이 있는 모든 위젯은 위젯 테스트로 검증해야 합니다.
예를 들어, 폼 유효성 검사, 조건부 렌더링, 애니메이션 상태 변화 같은 경우에 위젯 테스트가 매우 유용합니다. 기존에는 앱을 실행하고 에뮬레이터나 실제 기기에서 수동으로 UI를 확인했다면, 이제는 몇 초 안에 수십 개의 위젯을 자동으로 테스트할 수 있습니다.
실제 기기 없이도 가능합니다. 위젯 테스트의 핵심 특징은 빠른 실행 속도(단위 테스트보다는 느리지만 통합 테스트보다 빠름), 실제 위젯 렌더링, 그리고 사용자 인터랙션 시뮬레이션입니다.
이러한 특징들이 UI 버그를 조기에 발견하고 UI 리팩토링을 안전하게 만들어줍니다.
코드 예제
// test/widget/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 {
// 위젯 빌드
await tester.pumpWidget(
MaterialApp(home: LoginForm()),
);
// 이메일만 입력
await tester.enterText(
find.byKey(Key('email_field')),
'test@example.com',
);
// 로그인 버튼 탭
await tester.tap(find.byType(ElevatedButton));
await tester.pump(); // 위젯 리빌드
// 에러 메시지 확인
expect(find.text('비밀번호를 입력해주세요'), findsOneWidget);
});
testWidgets('로딩 중에는 버튼이 비활성화된다', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(home: LoginForm()));
final button = find.byType(ElevatedButton);
await tester.tap(button);
await tester.pump();
// 버튼이 비활성화되었는지 확인
final ElevatedButton buttonWidget = tester.widget(button);
expect(buttonWidget.enabled, false);
});
}
설명
이것이 하는 일: 위 코드는 LoginForm 위젯이 사용자 입력에 올바르게 반응하고 적절한 에러 메시지를 표시하는지 검증합니다. 첫 번째로, testWidgets 함수는 WidgetTester 객체를 제공하는데, 이것이 위젯 테스트의 핵심입니다.
tester.pumpWidget으로 테스트할 위젯을 빌드하고, MaterialApp으로 감싸서 실제 앱 환경과 유사하게 만듭니다. 이렇게 하면 테마, 네비게이션 등 Material 위젯에 필요한 컨텍스트가 제공됩니다.
그 다음으로, find.byKey나 find.byType으로 위젯을 찾고, tester.enterText나 tester.tap으로 사용자 입력을 시뮬레이션합니다. 중요한 것은 사용자 인터랙션 후에 tester.pump()를 호출해야 한다는 점입니다.
pump()는 위젯 트리를 리빌드하여 상태 변화를 반영합니다. 마지막으로, expect와 find를 조합하여 원하는 위젯이 화면에 나타나는지 검증합니다.
findsOneWidget은 정확히 하나의 위젯이 발견되어야 한다는 의미이고, findsNothing, findsNWidgets(n) 같은 매처도 사용할 수 있습니다. 여러분이 이 코드를 사용하면 UI 변경 시 기존 기능이 깨지지 않았는지 즉시 확인할 수 있고, 다양한 사용자 시나리오를 빠르게 테스트할 수 있으며, UI 리팩토링을 자신 있게 진행할 수 있습니다.
특히 에뮬레이터나 실제 기기 없이도 테스트 가능하다는 점이 큰 장점입니다.
실전 팁
💡 Key를 활용하여 위젯을 찾으면 테스트가 더 안정적입니다. find.text()는 텍스트가 변경되면 테스트가 깨지지만, find.byKey()는 구조가 유지되는 한 안전합니다.
💡 pump()와 pumpAndSettle()의 차이를 이해하세요. pump()는 한 프레임만 진행하지만, pumpAndSettle()은 모든 애니메이션이 완료될 때까지 기다립니다.
💡 비동기 작업 테스트 시 await tester.pumpAndSettle()을 사용하되, 무한 애니메이션이 있다면 pump(Duration(seconds: 1))처럼 특정 시간만큼만 진행하세요.
💡 커스텀 finder를 만들어 복잡한 위젯 검색을 단순화하세요. find.ancestor()나 find.descendant()로 위젯 트리 구조를 활용할 수 있습니다.
💡 Golden 테스트(스크린샷 비교)를 추가하면 UI 레이아웃이 예상대로 렌더링되는지 시각적으로 검증할 수 있습니다. await expectLater(find.byType(MyWidget), matchesGoldenFile('my_widget.png'));
3. 목(Mock) 객체 활용 - 의존성 격리하기
시작하며
여러분이 사용자 프로필 위젯을 테스트하려는데, 이 위젯이 API 서버에서 데이터를 가져온다면 어떻게 하나요? 테스트할 때마다 실제 서버에 요청을 보내면 테스트가 느려지고, 서버가 다운되면 테스트도 실패하며, 네트워크 상태에 따라 결과가 달라집니다.
이런 문제는 외부 의존성(데이터베이스, API, 파일 시스템 등)이 있는 코드를 테스트할 때 항상 발생합니다. 실제 의존성을 사용하면 테스트가 느리고 불안정하며 예측 불가능해집니다.
바로 이럴 때 필요한 것이 목 객체입니다. 실제 의존성을 가짜 객체로 대체하여 테스트를 빠르고 안정적으로 만들고, 다양한 시나리오(성공, 실패, 예외 등)를 쉽게 시뮬레이션할 수 있게 해줍니다.
개요
간단히 말해서, 목(Mock) 객체는 실제 객체를 흉내 내는 가짜 객체로, 테스트에서 외부 의존성을 제어 가능하고 예측 가능하게 만들어줍니다. 실무에서 API 호출, 데이터베이스 접근, 외부 서비스 연동 같은 코드를 테스트할 때 목 객체는 필수입니다.
예를 들어, 네트워크 에러 상황이나 특정 응답 값에 따른 UI 동작을 테스트하려면 목 객체로 이런 상황들을 쉽게 재현할 수 있습니다. 기존에는 테스트용 서버를 따로 구축하거나 실제 서버의 테스트 환경에 의존했다면, 이제는 코드 내에서 모든 시나리오를 즉시 재현할 수 있습니다.
서버 없이도, 네트워크 없이도 완벽한 테스트가 가능합니다. 목 객체의 핵심 특징은 행위 검증(어떤 메서드가 호출되었는지), 응답 제어(원하는 값을 반환하도록), 그리고 빠른 실행 속도입니다.
이러한 특징들이 복잡한 의존성이 있는 코드도 쉽게 테스트할 수 있게 만들어줍니다.
코드 예제
// test/user_repository_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
import 'package:my_app/api_client.dart';
import 'package:my_app/user_repository.dart';
// Mock 클래스 자동 생성을 위한 어노테이션
@GenerateMocks([ApiClient])
import 'user_repository_test.mocks.dart';
void main() {
group('UserRepository 테스트', () {
late MockApiClient mockApiClient;
late UserRepository userRepository;
setUp(() {
// Mock 객체 생성
mockApiClient = MockApiClient();
userRepository = UserRepository(mockApiClient);
});
test('사용자 정보를 성공적으로 가져온다', () async {
// Mock 객체의 반환값 설정
when(mockApiClient.getUser(any))
.thenAnswer((_) async => {'id': 1, 'name': 'John'});
// 실행
final user = await userRepository.fetchUser(1);
// 검증: 올바른 데이터 반환
expect(user.name, 'John');
// 검증: API 메서드가 올바른 인자로 호출되었는지
verify(mockApiClient.getUser(1)).called(1);
});
test('네트워크 에러 시 예외를 발생시킨다', () async {
// Mock 객체가 예외를 던지도록 설정
when(mockApiClient.getUser(any))
.thenThrow(NetworkException('Connection failed'));
// 예외 발생 검증
expect(
() => userRepository.fetchUser(1),
throwsA(isA<NetworkException>()),
);
});
});
}
설명
이것이 하는 일: 위 코드는 UserRepository가 ApiClient를 올바르게 사용하는지, 성공과 실패 상황을 모두 잘 처리하는지 검증합니다. 첫 번째로, @GenerateMocks 어노테이션으로 Mock 클래스를 자동 생성합니다.
flutter pub run build_runner build 명령을 실행하면 MockApiClient 클래스가 자동으로 만들어집니다. 이렇게 하면 수동으로 Mock 클래스를 작성할 필요가 없어 생산성이 크게 향상됩니다.
그 다음으로, when-thenAnswer 패턴으로 Mock 객체의 동작을 정의합니다. when(mockApiClient.getUser(any))는 "getUser 메서드가 어떤 인자로든 호출되면"이라는 의미이고, thenAnswer는 "이런 값을 반환해라"는 의미입니다.
async 함수는 thenAnswer를 사용하고, 동기 함수는 thenReturn을 사용합니다. 마지막으로, verify로 Mock 객체의 메서드가 실제로 호출되었는지 검증합니다.
verify(mockApiClient.getUser(1)).called(1)은 "getUser(1)이 정확히 1번 호출되었는지" 확인합니다. 이를 통해 우리 코드가 의존성을 올바르게 사용하는지 확인할 수 있습니다.
여러분이 이 코드를 사용하면 외부 서비스에 의존하지 않고도 모든 시나리오를 테스트할 수 있고, 네트워크 에러, 타임아웃, 특수한 응답 값 등 실제로 재현하기 어려운 상황을 쉽게 시뮬레이션할 수 있으며, 테스트 실행 속도가 획기적으로 빨라집니다. CI/CD 환경에서도 안정적으로 동작합니다.
실전 팁
💡 pubspec.yaml에 mockito와 build_runner를 dev_dependencies로 추가하세요. flutter pub run build_runner build --delete-conflicting-outputs로 Mock 클래스를 생성합니다.
💡 any 대신 구체적인 매처(argThat, captureAny)를 사용하면 더 정밀한 테스트가 가능합니다. 예: when(api.getUser(argThat(greaterThan(0))))
💡 verifyNever()로 특정 메서드가 호출되지 않았는지도 확인하세요. 불필요한 API 호출을 방지하는 로직을 테스트할 때 유용합니다.
💡 Mock 객체 대신 Fake 객체가 더 적합한 경우도 있습니다. Fake는 실제로 동작하는 간단한 구현체로, 인메모리 데이터베이스 같은 경우에 적합합니다.
💡 captureAny를 사용하면 메서드에 전달된 인자를 캡처하여 검증할 수 있습니다. 복잡한 객체가 올바르게 전달되었는지 확인할 때 유용합니다.
4. 통합 테스트 - 전체 앱 플로우 검증하기
시작하며
여러분이 만든 쇼핑몰 앱에서 "상품 검색 → 장바구니 추가 → 결제"라는 전체 플로우가 잘 동작하는지 확인하려면 어떻게 하나요? 단위 테스트와 위젯 테스트로는 각 부분이 따로따로는 잘 동작하는지 확인할 수 있지만, 전체가 함께 잘 동작하는지는 알 수 없습니다.
이런 문제는 복잡한 앱일수록 심각해집니다. 각 화면은 잘 동작하는데 화면 전환 시 데이터가 제대로 전달되지 않거나, 백엔드와 통신할 때 예상치 못한 문제가 발생할 수 있습니다.
바로 이럴 때 필요한 것이 통합 테스트입니다. 실제 기기나 에뮬레이터에서 전체 앱을 실행하고 사용자 시나리오를 자동으로 테스트하여 앱이 종단 간(end-to-end) 제대로 동작하는지 검증합니다.
개요
간단히 말해서, 통합 테스트는 앱의 여러 컴포넌트가 함께 동작하는 전체 시나리오를 실제 환경에서 검증하는 테스트입니다. 실무에서 로그인부터 주요 기능 사용까지의 핵심 사용자 여정(user journey)은 반드시 통합 테스트로 검증해야 합니다.
예를 들어, 회원가입 → 프로필 설정 → 첫 게시물 작성 같은 온보딩 플로우는 통합 테스트의 완벽한 대상입니다. 기존에는 QA 팀이 수동으로 앱을 테스트하거나 체크리스트를 따라가며 확인했다면, 이제는 이런 시나리오를 자동화하여 몇 분 안에 핵심 플로우를 모두 검증할 수 있습니다.
릴리스 전에 자동으로 실행되도록 설정할 수 있습니다. 통합 테스트의 핵심 특징은 실제 환경 테스트, 전체 시스템 검증, 그리고 사용자 관점의 테스트입니다.
이러한 특징들이 실제 사용자가 겪을 수 있는 문제를 조기에 발견하고 앱의 전반적인 품질을 보장합니다.
코드 예제
// 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();
// 1단계: 검색 화면으로 이동
final searchIcon = find.byIcon(Icons.search);
await tester.tap(searchIcon);
await tester.pumpAndSettle();
// 2단계: 상품 검색
final searchField = find.byType(TextField);
await tester.enterText(searchField, '노트북');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle(Duration(seconds: 2)); // API 응답 대기
// 3단계: 첫 번째 상품 선택
final firstProduct = find.byKey(Key('product_item_0'));
expect(firstProduct, findsOneWidget);
await tester.tap(firstProduct);
await tester.pumpAndSettle();
// 4단계: 장바구니에 추가
final addToCartButton = find.text('장바구니 추가');
await tester.tap(addToCartButton);
await tester.pumpAndSettle();
// 5단계: 성공 메시지 확인
expect(find.text('장바구니에 추가되었습니다'), findsOneWidget);
});
});
}
설명
이것이 하는 일: 위 코드는 사용자가 앱을 실행하고 상품을 검색한 뒤 장바구니에 추가하는 전체 과정을 실제 환경에서 자동으로 테스트합니다. 첫 번째로, IntegrationTestWidgetsFlutterBinding.ensureInitialized()로 통합 테스트 환경을 초기화합니다.
이것은 일반 위젯 테스트와의 주요 차이점으로, 실제 플랫폼(Android, iOS)과 통신할 수 있게 해줍니다. 실제 네트워크 요청, 플랫폼 채널, 네이티브 플러그인 등이 모두 동작합니다.
그 다음으로, app.main()으로 실제 앱을 시작합니다. 이것은 사용자가 앱을 실행하는 것과 완전히 동일합니다.
pumpAndSettle()로 모든 애니메이션과 비동기 작업이 완료될 때까지 기다립니다. 통합 테스트에서는 실제 API 호출이 발생할 수 있으므로 충분한 대기 시간을 줘야 합니다.
마지막으로, 사용자 시나리오를 단계별로 시뮬레이션합니다. 각 단계마다 적절한 finder로 UI 요소를 찾고, 탭이나 텍스트 입력 같은 인터랙션을 수행하며, expect로 예상한 결과가 나타나는지 검증합니다.
Key를 사용하여 동적으로 생성되는 리스트 아이템도 안정적으로 찾을 수 있습니다. 여러분이 이 코드를 사용하면 릴리스 전에 핵심 사용자 여정이 모두 정상 동작하는지 자동으로 확인할 수 있고, 여러 컴포넌트가 통합되면서 발생하는 버그를 조기에 발견할 수 있으며, 회귀 테스트(regression test)를 자동화하여 새로운 기능 추가 시 기존 기능이 깨지지 않았는지 확인할 수 있습니다.
실전 팁
💡 통합 테스트는 실행 시간이 오래 걸리므로 핵심 플로우만 선별하여 테스트하세요. 모든 것을 통합 테스트로 커버하려 하지 마세요.
💡 pumpAndSettle() 대신 pumpAndSettle(Duration(seconds: 5))처럼 타임아웃을 지정하면 무한 대기를 방지할 수 있습니다.
💡 실제 백엔드 대신 테스트용 백엔드나 Mock 서버를 사용하면 테스트가 더 안정적입니다. flutter_driver의 setUpAll에서 API 베이스 URL을 변경할 수 있습니다.
💡 스크린샷을 찍어두면 실패 시 디버깅에 도움이 됩니다. await binding.takeScreenshot('step_1');
💡 CI/CD에서 통합 테스트를 실행하려면 Firebase Test Lab이나 AWS Device Farm 같은 클라우드 테스트 서비스를 활용하세요. 다양한 기기에서 자동으로 테스트할 수 있습니다.
5. 테스트 주도 개발(TDD) - 테스트 먼저 작성하기
시작하며
여러분이 새로운 기능을 개발할 때 보통 어떻게 하나요? 먼저 코드를 작성하고 나중에 테스트를 추가하나요?
그런데 막상 테스트를 작성하려고 보니 코드가 너무 복잡하게 얽혀 있어서 테스트하기 어려운 경험 있으시죠? 이런 문제는 테스트를 나중에 생각하기 때문에 발생합니다.
이미 작성된 코드는 테스트 가능성을 고려하지 않았기 때문에 의존성이 강하게 결합되어 있고, 테스트를 위해 코드를 리팩토링해야 하는 추가 작업이 필요합니다. 바로 이럴 때 필요한 것이 테스트 주도 개발(TDD)입니다.
구현 코드를 작성하기 전에 테스트를 먼저 작성하는 방식으로, 자연스럽게 테스트 가능한 코드를 만들고 요구사항을 명확히 이해할 수 있게 해줍니다.
개요
간단히 말해서, TDD는 "실패하는 테스트 작성 → 테스트를 통과하는 최소한의 코드 작성 → 리팩토링" 사이클을 반복하는 개발 방법론입니다. 실무에서 새로운 기능을 추가하거나 복잡한 비즈니스 로직을 구현할 때 TDD를 적용하면 코드 품질이 크게 향상됩니다.
예를 들어, 복잡한 할인 계산 로직이나 다단계 폼 유효성 검사 같은 경우 TDD로 접근하면 요구사항을 놓치지 않고 구현할 수 있습니다. 기존에는 "일단 작동하는 코드를 만들고 나중에 테스트와 리팩토링을 해야지"라고 생각했다면, 이제는 테스트가 요구사항 명세이자 설계 도구가 되어 처음부터 깔끔한 코드를 작성할 수 있습니다.
TDD의 핵심 특징은 Red-Green-Refactor 사이클, 점진적 개발, 그리고 높은 테스트 커버리지입니다. 이러한 특징들이 버그를 조기에 발견하고 리팩토링을 안전하게 만들며 코드에 대한 자신감을 높여줍니다.
코드 예제
// 1단계 (Red): 실패하는 테스트 작성
// test/discount_calculator_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/discount_calculator.dart';
void main() {
test('10% 할인 쿠폰을 적용하면 정확한 할인 금액을 반환한다', () {
final calculator = DiscountCalculator();
final discountedPrice = calculator.apply(
originalPrice: 10000,
couponType: CouponType.percentage,
couponValue: 10,
);
expect(discountedPrice, 9000);
});
}
// 2단계 (Green): 테스트를 통과하는 최소한의 코드 작성
// lib/discount_calculator.dart
class DiscountCalculator {
double apply({
required double originalPrice,
required CouponType couponType,
required double couponValue,
}) {
if (couponType == CouponType.percentage) {
return originalPrice * (1 - couponValue / 100);
}
return originalPrice;
}
}
enum CouponType { percentage, fixed }
// 3단계 (Refactor): 코드 개선 (테스트는 계속 통과)
설명
이것이 하는 일: 위 코드는 TDD의 Red-Green-Refactor 사이클을 보여주는 예제로, 할인 계산기 기능을 테스트 먼저 작성하여 개발합니다. 첫 번째로, Red 단계에서는 아직 구현되지 않은 기능에 대한 테스트를 작성합니다.
이 테스트는 당연히 실패합니다(DiscountCalculator 클래스가 아직 없으므로). 이 단계의 핵심은 "무엇을 만들 것인가"를 명확히 정의하는 것입니다.
테스트를 작성하면서 API 설계(메서드 이름, 파라미터, 반환 타입)를 자연스럽게 결정하게 됩니다. 그 다음으로, Green 단계에서는 테스트를 통과시키는 최소한의 코드만 작성합니다.
완벽한 코드가 아니어도 괜찮습니다. 일단 테스트가 통과하면 됩니다.
이 예제에서는 percentage 타입만 처리하고 나머지는 무시합니다. 이렇게 작은 단계로 진행하면 복잡한 문제를 쉽게 해결할 수 있습니다.
마지막으로, Refactor 단계에서는 중복 제거, 가독성 향상, 성능 최적화 등을 진행합니다. 중요한 점은 테스트가 계속 통과하는 상태를 유지해야 한다는 것입니다.
테스트가 있기 때문에 리팩토링이 기능을 망가뜨리지 않는다는 확신을 가질 수 있습니다. 여러분이 이 방식을 사용하면 처음부터 테스트 가능한 구조로 코드를 작성하게 되고, 요구사항을 코드로 옮기기 전에 테스트로 명확히 정의하여 오해나 누락을 방지할 수 있으며, 리팩토링에 대한 두려움 없이 코드를 지속적으로 개선할 수 있습니다.
또한 테스트가 문서 역할을 하여 코드의 의도를 명확히 전달합니다.
실전 팁
💡 한 번에 하나의 테스트만 작성하세요. 모든 테스트를 한꺼번에 작성하면 TDD의 이점을 잃게 됩니다. 작은 단계로 진행하는 것이 핵심입니다.
💡 테스트가 실패하는 것을 확인한 후에 구현을 시작하세요. 테스트가 처음부터 통과하면 테스트가 제대로 작동하는지 알 수 없습니다.
💡 가짜 구현(fake implementation)으로 시작해도 괜찮습니다. 예를 들어, return 9000; 같은 하드코딩으로 시작하여 점진적으로 일반화할 수 있습니다.
💡 리팩토링 단계에서는 테스트 코드도 함께 리팩토링하세요. 중복된 setUp 코드, 헬퍼 함수 추출 등으로 테스트 가독성도 높이세요.
💡 TDD가 모든 상황에 적합한 것은 아닙니다. UI 프로토타이핑이나 탐색적 개발 단계에서는 유연하게 접근하고, 요구사항이 명확해지면 TDD를 적용하세요.
6. 테스트 커버리지 측정 - 테스트 충분도 확인하기
시작하며
여러분이 열심히 테스트를 작성했는데, 과연 충분한 테스트를 작성한 걸까요? 중요한 코드 경로가 테스트되지 않은 채로 남아있지는 않을까요?
특히 팀 프로젝트에서 다른 팀원이 작성한 코드의 테스트 상태를 어떻게 파악하나요? 이런 문제는 테스트의 양적 측정 없이는 답하기 어렵습니다.
"충분히" 테스트했다는 주관적인 느낌만으로는 실제로 어느 부분이 테스트되었고 어느 부분이 빠졌는지 알 수 없습니다. 바로 이럴 때 필요한 것이 테스트 커버리지 측정입니다.
전체 코드 중 몇 퍼센트가 테스트되었는지, 어떤 파일이나 함수가 테스트되지 않았는지 정량적으로 보여주어 테스트 전략을 개선할 수 있게 해줍니다.
개요
간단히 말해서, 테스트 커버리지는 테스트가 실행되는 동안 실제로 실행된 코드의 비율을 측정하는 지표입니다. 실무에서 코드 리뷰나 PR 승인 기준으로 일정 수준 이상의 커버리지를 요구하는 경우가 많습니다.
예를 들어, "새로운 기능은 최소 80% 커버리지를 유지해야 한다"는 규칙을 정해두면 테스트를 빼먹는 일을 방지할 수 있습니다. 기존에는 "이 정도면 충분하겠지"라는 감으로 테스트 완성도를 판단했다면, 이제는 구체적인 숫자와 시각화된 리포트로 어떤 부분이 취약한지 정확히 파악할 수 있습니다.
테스트 커버리지의 핵심 특징은 정량적 측정, 커버되지 않은 코드 식별, 그리고 팀 전체의 테스트 품질 기준 설정입니다. 이러한 특징들이 테스트 사각지대를 발견하고 팀의 테스트 문화를 개선하는 데 도움을 줍니다.
코드 예제
# 터미널에서 커버리지 측정
flutter test --coverage
# 커버리지 리포트를 HTML로 생성 (genhtml 필요)
# macOS: brew install lcov
# Linux: sudo apt-get install lcov
genhtml coverage/lcov.info -o coverage/html
# 브라우저로 리포트 열기
open coverage/html/index.html
# pubspec.yaml에 테스트 제외 설정
# *.g.dart, *.freezed.dart 등 생성된 파일은 커버리지에서 제외
# .lcovrc 파일 생성
# coverage/lcov.info에서 특정 파일 제거
lcov --remove coverage/lcov.info \
'**/*.g.dart' \
'**/*.freezed.dart' \
'**/generated_plugin_registrant.dart' \
-o coverage/lcov.info
# 커버리지 임계값 검증 (CI/CD에서 사용)
# coverage_threshold.dart
import 'dart:io';
void main() {
final lcovFile = File('coverage/lcov.info');
final lines = lcovFile.readAsLinesSync();
int totalLines = 0;
int coveredLines = 0;
for (var line in lines) {
if (line.startsWith('LH:')) {
coveredLines += int.parse(line.substring(3));
} else if (line.startsWith('LF:')) {
totalLines += int.parse(line.substring(3));
}
}
final coverage = (coveredLines / totalLines * 100).toStringAsFixed(2);
print('테스트 커버리지: $coverage%');
if (coveredLines / totalLines < 0.80) {
print('⚠️ 커버리지가 80% 미만입니다!');
exit(1);
}
print('✅ 커버리지 기준 통과');
}
설명
이것이 하는 일: 위 코드는 Flutter 프로젝트의 테스트 커버리지를 측정하고, HTML 리포트로 시각화하며, CI/CD에서 최소 커버리지 기준을 검증합니다. 첫 번째로, flutter test --coverage 명령은 테스트를 실행하면서 어떤 코드가 실행되었는지 추적하여 coverage/lcov.info 파일을 생성합니다.
이 파일은 LCOV 형식으로, 각 파일의 각 라인이 몇 번 실행되었는지 기록합니다. 한 번도 실행되지 않은 라인은 테스트되지 않은 코드입니다.
그 다음으로, lcov 도구로 자동 생성된 파일(*.g.dart, *.freezed.dart 등)을 제외합니다. 이런 파일들은 우리가 직접 작성한 코드가 아니므로 커버리지 측정에서 제외하는 것이 합리적입니다.
이렇게 하면 실제 작성한 코드의 커버리지를 정확히 파악할 수 있습니다. 마지막으로, genhtml로 HTML 리포트를 생성하면 브라우저에서 파일별, 함수별 커버리지를 시각적으로 확인할 수 있습니다.
빨간색으로 표시된 라인은 테스트되지 않은 코드입니다. coverage_threshold.dart 스크립트는 CI/CD 파이프라인에서 커버리지가 기준(예: 80%) 이하면 빌드를 실패시킵니다.
여러분이 이 방법을 사용하면 테스트되지 않은 코드를 시각적으로 빠르게 파악할 수 있고, 팀 전체가 일정 수준의 테스트 품질을 유지하도록 강제할 수 있으며, 리팩토링이나 새 기능 추가 시 테스트 커버리지가 떨어지지 않도록 관리할 수 있습니다. PR 리뷰 시 커버리지 리포트를 함께 보면 테스트가 충분한지 쉽게 판단할 수 있습니다.
실전 팁
💡 100% 커버리지를 목표로 하지 마세요. 80-90% 정도면 충분하며, 나머지는 에러 핸들링이나 엣지 케이스일 수 있습니다. 중요한 것은 핵심 로직의 커버리지입니다.
💡 커버리지가 높다고 테스트 품질이 좋은 것은 아닙니다. 의미 없는 테스트로 커버리지만 높이는 것보다 중요한 시나리오를 제대로 검증하는 것이 더 중요합니다.
💡 GitHub Actions나 GitLab CI에서 커버리지를 자동으로 측정하고 PR 코멘트로 표시하면 팀원들이 테스트 상태를 쉽게 파악할 수 있습니다.
💡 VSCode의 Coverage Gutters 익스텐션을 설치하면 에디터에서 직접 커버되지 않은 라인을 볼 수 있어 편리합니다.
💡 커버리지는 코드 라인 기준(line coverage)뿐만 아니라 분기 커버리지(branch coverage)도 중요합니다. if-else의 모든 경로를 테스트했는지 확인하세요.
7. 비동기 코드 테스트 - Future와 Stream 검증하기
시작하며
여러분이 API에서 데이터를 가져오는 비동기 함수를 테스트하려는데, 테스트가 함수가 완료되기 전에 끝나버려서 항상 실패하는 경험 있나요? 또는 Stream에서 여러 이벤트가 발생하는데 어떻게 모든 이벤트를 검증해야 할지 막막한 적 있나요?
이런 문제는 비동기 코드의 특성 때문에 발생합니다. 일반적인 동기 코드 테스트 방식으로는 작업이 완료되기 전에 테스트가 끝나버리거나, 예상치 못한 타이밍 이슈로 테스트가 불안정해집니다.
바로 이럴 때 필요한 것이 비동기 코드 테스트 기법입니다. async/await, expectLater, emitsInOrder 같은 도구를 사용하여 Future와 Stream을 안정적으로 검증할 수 있습니다.
개요
간단히 말해서, 비동기 코드 테스트는 Future나 Stream 같은 비동기 작업이 완료될 때까지 기다렸다가 결과를 검증하는 테스트 방식입니다. 실무에서 API 호출, 데이터베이스 쿼리, 파일 I/O, 타이머 같은 모든 비동기 작업은 제대로 테스트되어야 합니다.
예를 들어, 사용자가 검색어를 입력할 때 디바운싱(debouncing)을 적용하는 기능이나 실시간 채팅 메시지 스트림 같은 경우 비동기 테스트가 필수입니다. 기존에는 비동기 코드 테스트가 어려워서 아예 테스트를 생략하거나 불안정한 테스트를 만들었다면, 이제는 Flutter의 강력한 비동기 테스트 도구로 안정적이고 정확한 테스트를 작성할 수 있습니다.
비동기 테스트의 핵심 특징은 완료 대기(await), Stream 이벤트 검증, 그리고 타임아웃 처리입니다. 이러한 특징들이 복잡한 비동기 로직도 신뢰성 있게 테스트할 수 있게 만들어줍니다.
코드 예제
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/user_service.dart';
void main() {
group('비동기 코드 테스트', () {
// Future 테스트
test('API에서 사용자 데이터를 가져온다', () async {
final service = UserService();
// async 함수는 await로 결과를 기다림
final user = await service.fetchUser(1);
expect(user.name, 'John');
expect(user.email, 'john@example.com');
});
// Future 예외 테스트
test('존재하지 않는 사용자는 예외를 발생시킨다', () {
final service = UserService();
// expectLater는 Future 예외를 검증
expectLater(
service.fetchUser(999),
throwsA(isA<UserNotFoundException>()),
);
});
// Stream 테스트
test('검색 스트림이 올바른 순서로 결과를 방출한다', () async {
final service = SearchService();
final query = 'flutter';
// emitsInOrder로 순차적 이벤트 검증
expect(
service.search(query),
emitsInOrder([
[], // 초기 빈 결과
isA<List>().having((l) => l.length, 'length', greaterThan(0)),
emitsDone, // 스트림 종료
]),
);
await Future.delayed(Duration(seconds: 1)); // 스트림 완료 대기
});
// 디바운싱 테스트
test('검색어 입력 후 300ms 후에 API를 호출한다', () async {
final controller = SearchController();
// 빠르게 여러 번 입력
controller.onQueryChanged('f');
controller.onQueryChanged('fl');
controller.onQueryChanged('flu');
// 디바운싱 시간 전에는 호출 안됨
await Future.delayed(Duration(milliseconds: 200));
expect(controller.apiCallCount, 0);
// 디바운싱 시간 후에는 호출됨
await Future.delayed(Duration(milliseconds: 150));
expect(controller.apiCallCount, 1);
});
});
}
설명
이것이 하는 일: 위 코드는 Future를 반환하는 API 호출과 Stream을 사용하는 실시간 검색, 그리고 디바운싱 로직을 안정적으로 테스트합니다. 첫 번째로, Future를 테스트할 때는 테스트 함수를 async로 만들고 결과를 await로 기다립니다.
이렇게 하면 비동기 작업이 완료된 후에 expect가 실행되어 정확한 검증이 가능합니다. async를 빼먹으면 테스트가 즉시 끝나버려서 실제 검증이 일어나지 않습니다.
그 다음으로, Stream 테스트에서는 emitsInOrder 매처를 사용합니다. 이것은 Stream이 특정 순서로 값을 방출하는지 검증합니다.
isA<List>().having()으로 방출된 값의 속성도 검증할 수 있고, emitsDone으로 Stream이 정상적으로 종료되었는지 확인합니다. Stream은 여러 값을 시간에 걸쳐 방출하므로 이런 매처가 필수입니다.
마지막으로, 타이밍이 중요한 로직(디바운싱, 쓰로틀링 등)을 테스트할 때는 Future.delayed로 특정 시간을 기다린 후 상태를 검증합니다. 이 예제에서는 300ms 디바운싱이 제대로 동작하는지 확인하기 위해 200ms 시점과 350ms 시점의 상태를 각각 검증합니다.
여러분이 이 방법을 사용하면 API 호출, 데이터베이스 작업, 실시간 업데이트 같은 비동기 기능을 안정적으로 테스트할 수 있고, 타이밍 관련 버그를 조기에 발견할 수 있으며, 복잡한 비동기 플로우에 대한 자신감을 가질 수 있습니다. 특히 Stream 기반의 상태 관리(Bloc, Riverpod)를 사용할 때 매우 유용합니다.
실전 팁
💡 test 함수에 async를 추가하면 자동으로 비동기 작업이 완료될 때까지 기다립니다. 하지만 명시적인 await 없이는 작동하지 않으니 주의하세요.
💡 Stream 테스트 시 expectAsync나 expectLater를 사용하면 여러 이벤트를 순차적으로 검증할 수 있습니다. emits, emitsInOrder, emitsError 등 다양한 매처를 활용하세요.
💡 타임아웃이 필요한 테스트는 test(..., timeout: Timeout(Duration(seconds: 10)))으로 최대 대기 시간을 지정하세요. 무한 대기를 방지합니다.
💡 fake_async 패키지를 사용하면 실제 시간을 기다리지 않고도 타이머 기반 코드를 테스트할 수 있습니다. 테스트 속도가 크게 향상됩니다.
💡 Stream이 에러를 방출하는지 테스트하려면 emitsError 매처를 사용하세요. expect(stream, emitsError(isA<NetworkException>()));
8. 상태 관리 테스트 - Provider와 Bloc 검증하기
시작하며
여러분이 복잡한 상태 관리 로직을 작성했는데, 사용자가 버튼을 클릭했을 때 상태가 올바르게 변경되는지, 여러 이벤트가 연속으로 발생할 때 상태 전환이 정확한지 어떻게 확인하나요? UI 테스트로는 내부 로직을 세밀하게 검증하기 어렵습니다.
이런 문제는 상태 관리가 복잡할수록 심각해집니다. Provider, Bloc, Riverpod 같은 상태 관리 라이브러리를 사용하면 로직이 여러 레이어로 분산되어 있어서 어디서부터 테스트해야 할지 막막합니다.
바로 이럴 때 필요한 것이 상태 관리 테스트입니다. UI와 분리하여 상태 로직만 집중적으로 테스트하고, 모든 상태 전환과 부수 효과를 검증할 수 있습니다.
개요
간단히 말해서, 상태 관리 테스트는 Provider, Bloc, Riverpod 같은 상태 관리 솔루션의 로직을 UI 없이 독립적으로 검증하는 테스트입니다. 실무에서 비즈니스 로직이 상태 관리 레이어에 있다면 이것을 철저히 테스트해야 합니다.
예를 들어, 장바구니 상태 관리, 인증 상태 처리, 폼 유효성 검사 상태 같은 핵심 로직은 상태 관리 테스트로 완벽하게 커버해야 합니다. 기존에는 위젯 테스트로 상태 관리를 간접적으로 테스트했다면, 이제는 상태 관리 로직을 직접 테스트하여 더 빠르고 정확하며 다양한 엣지 케이스를 쉽게 검증할 수 있습니다.
상태 관리 테스트의 핵심 특징은 빠른 실행 속도(UI 렌더링 없음), 명확한 상태 전환 검증, 그리고 부수 효과(side effects) 확인입니다. 이러한 특징들이 복잡한 비즈니스 로직을 안전하게 만들고 리팩토링을 쉽게 합니다.
코드 예제
// test/counter_bloc_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/counter/counter_bloc.dart';
void main() {
group('CounterBloc 테스트', () {
// blocTest는 Bloc 전용 테스트 헬퍼
blocTest<CounterBloc, CounterState>(
'증가 이벤트를 받으면 카운트가 1 증가한다',
build: () => CounterBloc(),
act: (bloc) => bloc.add(CounterIncremented()),
expect: () => [
CounterState(count: 1),
],
);
blocTest<CounterBloc, CounterState>(
'여러 이벤트를 연속으로 처리한다',
build: () => CounterBloc(),
act: (bloc) {
bloc.add(CounterIncremented());
bloc.add(CounterIncremented());
bloc.add(CounterDecremented());
},
expect: () => [
CounterState(count: 1),
CounterState(count: 2),
CounterState(count: 1),
],
);
// Provider 테스트
test('CartProvider에 상품을 추가하면 총 금액이 업데이트된다', () {
final provider = CartProvider();
provider.addItem(Product(id: 1, price: 10000));
expect(provider.totalPrice, 10000);
provider.addItem(Product(id: 2, price: 20000));
expect(provider.totalPrice, 30000);
provider.removeItem(1);
expect(provider.totalPrice, 20000);
});
// 비동기 상태 전환 테스트
blocTest<AuthBloc, AuthState>(
'로그인 성공 시 인증 상태로 전환된다',
build: () => AuthBloc(mockAuthService),
setUp: () {
when(mockAuthService.login(any, any))
.thenAnswer((_) async => User(id: 1, name: 'John'));
},
act: (bloc) => bloc.add(LoginRequested('email', 'password')),
expect: () => [
AuthState.loading(),
AuthState.authenticated(User(id: 1, name: 'John')),
],
);
});
}
설명
이것이 하는 일: 위 코드는 Bloc과 Provider의 상태 변화를 UI 렌더링 없이 직접 테스트하여 비즈니스 로직이 올바르게 동작하는지 검증합니다. 첫 번째로, blocTest 함수는 Bloc 테스트를 위한 특화된 헬퍼입니다.
build에서 Bloc 인스턴스를 생성하고, act에서 이벤트를 추가하며, expect에서 예상되는 상태 변화 순서를 정의합니다. Bloc은 Stream 기반이므로 여러 상태를 순차적으로 방출하는데, blocTest가 이를 자동으로 수집하여 검증해줍니다.
그 다음으로, 여러 이벤트를 연속으로 추가하여 복잡한 상태 전환을 테스트할 수 있습니다. act 함수 내에서 bloc.add()를 여러 번 호출하면 각 이벤트에 따른 상태 변화가 순서대로 expect 배열에 나타나야 합니다.
이를 통해 상태 머신이 올바르게 동작하는지 확인합니다. 마지막으로, Provider는 일반 클래스처럼 테스트할 수 있습니다.
인스턴스를 만들고 메서드를 호출한 뒤 상태를 검증하면 됩니다. ChangeNotifier를 사용한다면 notifyListeners()가 호출되는지도 검증할 수 있습니다.
비동기 작업이 있는 Bloc은 setUp에서 Mock 객체를 설정하여 테스트합니다. 여러분이 이 방법을 사용하면 복잡한 상태 로직을 빠르게 테스트할 수 있고(UI 렌더링 없으므로), 모든 상태 전환 경로를 빠짐없이 검증할 수 있으며, 리팩토링 시 비즈니스 로직이 깨지지 않았는지 즉시 확인할 수 있습니다.
상태 관리가 복잡할수록 이런 테스트의 가치가 커집니다.
실전 팁
💡 bloc_test 패키지를 pubspec.yaml의 dev_dependencies에 추가하세요. Bloc 테스트를 훨씬 쉽게 만들어줍니다.
💡 seed를 사용하면 초기 상태를 설정할 수 있습니다. seed: () => CounterState(count: 5)처럼 특정 상태에서 시작하는 테스트를 만들 수 있습니다.
💡 skip을 사용하여 중간 상태를 건너뛸 수 있습니다. 로딩 상태 같은 중간 단계가 여러 개 있을 때 유용합니다.
💡 wait을 지정하면 특정 시간 동안 상태 변화를 기다립니다. 디바운싱이나 쓰로틀링이 있는 Bloc 테스트에 필요합니다.
💡 Riverpod을 사용한다면 ProviderContainer를 만들어 테스트하세요. final container = ProviderContainer(); final value = container.read(myProvider);
9. 에러 핸들링 테스트 - 예외 상황 검증하기
시작하며
여러분의 앱이 네트워크가 끊어졌을 때, 서버가 500 에러를 반환할 때, 또는 잘못된 데이터를 받았을 때 어떻게 동작하나요? 성공 케이스만 테스트하고 에러 상황은 "나중에"로 미루고 있지는 않나요?
이런 문제는 실제 프로덕션에서 심각한 버그로 이어집니다. 사용자는 정상적인 플로우보다 예외 상황에서 더 많은 문제를 겪습니다.
앱이 크래시되거나, 빈 화면만 보이거나, 무한 로딩에 빠지는 것이 대표적인 예입니다. 바로 이럴 때 필요한 것이 에러 핸들링 테스트입니다.
모든 가능한 에러 시나리오를 시뮬레이션하고, 앱이 우아하게(gracefully) 에러를 처리하는지 검증하여 사용자 경험을 크게 개선할 수 있습니다.
개요
간단히 말해서, 에러 핸들링 테스트는 예외가 발생했을 때 코드가 올바르게 반응하는지(에러 메시지 표시, 대체 UI 렌더링, 로깅 등) 검증하는 테스트입니다. 실무에서 네트워크 에러, 인증 실패, 데이터 파싱 에러, 권한 거부 같은 모든 예외 상황은 테스트되어야 합니다.
예를 들어, 파일 업로드 실패 시 재시도 로직이 동작하는지, API 타임아웃 시 적절한 에러 메시지가 표시되는지 등을 검증해야 합니다. 기존에는 "에러가 발생하면 대충 에러 메시지만 보여주면 되겠지"라고 생각했다면, 이제는 각 에러 타입별로 적절한 처리가 되는지, 사용자가 복구 액션을 취할 수 있는지까지 철저히 테스트할 수 있습니다.
에러 핸들링 테스트의 핵심 특징은 예외 발생 시뮬레이션, 에러 상태 검증, 그리고 복구 로직 확인입니다. 이러한 특징들이 프로덕션 환경에서 예상치 못한 문제에도 앱이 안정적으로 동작하도록 만들어줍니다.
코드 예제
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:my_app/exceptions.dart';
import 'package:my_app/user_repository.dart';
void main() {
group('에러 핸들링 테스트', () {
late MockApiClient mockApi;
late UserRepository repository;
setUp(() {
mockApi = MockApiClient();
repository = UserRepository(mockApi);
});
// 특정 예외 타입 검증
test('네트워크 에러 시 NetworkException을 발생시킨다', () {
when(mockApi.getUser(any))
.thenThrow(NetworkException('No internet connection'));
expect(
() => repository.fetchUser(1),
throwsA(isA<NetworkException>()
.having((e) => e.message, 'message', contains('internet'))),
);
});
// 에러 후 재시도 로직 테스트
test('네트워크 에러 발생 시 3번까지 재시도한다', () async {
var callCount = 0;
when(mockApi.getUser(any)).thenAnswer((_) async {
callCount++;
if (callCount < 3) {
throw NetworkException('Temporary failure');
}
return User(id: 1, name: 'John');
});
final user = await repository.fetchUserWithRetry(1);
expect(user.name, 'John');
expect(callCount, 3); // 2번 실패 후 3번째 성공
});
// 에러 로깅 검증
test('에러 발생 시 로그가 기록된다', () async {
final mockLogger = MockLogger();
repository.logger = mockLogger;
when(mockApi.getUser(any))
.thenThrow(ApiException(statusCode: 500, message: 'Server error'));
try {
await repository.fetchUser(1);
} catch (_) {}
verify(mockLogger.error(
argThat(contains('Server error')),
any,
any,
)).called(1);
});
// 위젯 에러 상태 테스트
testWidgets('에러 발생 시 에러 메시지가 표시된다', (tester) async {
when(mockApi.getUser(any))
.thenThrow(NetworkException('Connection failed'));
await tester.pumpWidget(
MaterialApp(home: UserProfileScreen(userId: 1)),
);
await tester.pumpAndSettle();
expect(find.text('네트워크 연결을 확인해주세요'), findsOneWidget);
expect(find.byType(RetryButton), findsOneWidget);
});
// 복구 액션 테스트
testWidgets('재시도 버튼을 누르면 다시 데이터를 가져온다', (tester) async {
when(mockApi.getUser(any))
.thenThrow(NetworkException('Failed'))
.thenAnswer((_) async => User(id: 1, name: 'John'));
await tester.pumpWidget(
MaterialApp(home: UserProfileScreen(userId: 1)),
);
await tester.pumpAndSettle();
// 재시도 버튼 클릭
await tester.tap(find.byType(RetryButton));
await tester.pumpAndSettle();
// 성공 후 데이터 표시
expect(find.text('John'), findsOneWidget);
});
});
}
설명
이것이 하는 일: 위 코드는 네트워크 에러, 서버 에러, 인증 에러 같은 다양한 예외 상황에서 앱이 올바르게 동작하는지 검증합니다. 첫 번째로, throwsA 매처로 특정 예외 타입이 발생하는지 확인합니다.
isA<NetworkException>()은 예외 타입을 검증하고, having()으로 예외 객체의 속성(메시지, 상태 코드 등)도 검증할 수 있습니다. 이를 통해 올바른 예외가 올바른 메시지와 함께 발생하는지 확인합니다.
그 다음으로, Mock 객체로 에러 시나리오를 시뮬레이션합니다. thenThrow()로 예외를 던지도록 설정하고, thenAnswer()를 여러 번 체이닝하여 "첫 두 번은 실패하고 세 번째는 성공"같은 복잡한 시나리오를 만들 수 있습니다.
재시도 로직을 테스트할 때 매우 유용합니다. 마지막으로, 위젯 테스트로 에러 UI를 검증합니다.
에러가 발생했을 때 사용자에게 적절한 메시지와 복구 옵션(재시도 버튼, 뒤로 가기 등)이 표시되는지 확인합니다. 재시도 버튼을 클릭했을 때 실제로 재시도가 일어나고 성공 시 정상 UI로 전환되는지까지 전체 플로우를 테스트합니다.
여러분이 이 방법을 사용하면 프로덕션에서 발생할 수 있는 모든 에러 시나리오를 미리 검증할 수 있고, 사용자가 에러 상황에서도 적절한 안내와 복구 옵션을 받을 수 있으며, 앱 크래시나 빈 화면 같은 치명적인 UX 문제를 방지할 수 있습니다. 특히 네트워크에 의존하는 앱에서는 필수적입니다.
실전 팁
💡 다양한 에러 타입을 만들어 구분하세요. NetworkException, ValidationException, AuthException처럼 세분화하면 각각에 맞는 처리를 테스트하기 쉽습니다.
💡 에러 발생 시 로깅뿐만 아니라 분석 이벤트(analytics)도 함께 테스트하세요. 프로덕션에서 어떤 에러가 얼마나 발생하는지 추적할 수 있습니다.
💡 타임아웃 에러도 테스트하세요. when(api.getUser(any)).thenAnswer((_) async { await Future.delayed(Duration(seconds: 100)); });
💡 에러 경계(Error Boundary) 위젯을 만들어 전역 에러를 처리하고 테스트하세요. 예상치 못한 에러도 우아하게 처리할 수 있습니다.
💡 Sentry나 Firebase Crashlytics 같은 에러 리포팅 도구 연동도 테스트하세요. Mock을 사용하여 에러 리포트가 올바르게 전송되는지 확인할 수 있습니다.
10. 성능 테스트 - 렌더링과 애니메이션 최적화 검증하기
시작하며
여러분의 앱이 데이터가 많아지면 스크롤이 버벅거리거나, 복잡한 애니메이션이 끊기는 경험 있나요? 사용자는 60fps로 부드럽게 동작하는 앱을 기대하는데, 성능 문제는 어떻게 조기에 발견하고 방지할 수 있을까요?
이런 문제는 기능 테스트만으로는 발견할 수 없습니다. 기능은 정상 동작하지만 성능이 나쁘면 사용자 경험이 크게 저하됩니다.
특히 저사양 기기나 많은 데이터를 다룰 때 성능 문제가 두드러집니다. 바로 이럴 때 필요한 것이 성능 테스트입니다.
위젯 빌드 시간, 프레임 드롭, 메모리 사용량 같은 성능 지표를 자동으로 측정하고 임계값을 설정하여 성능 저하를 조기에 발견할 수 있습니다.
개요
간단히 말해서, 성능 테스트는 앱의 렌더링 속도, 애니메이션 품질, 메모리 사용량 같은 성능 지표를 측정하고 기준치 이상인지 검증하는 테스트입니다. 실무에서 무한 스크롤 리스트, 복잡한 애니메이션, 대용량 데이터 렌더링 같은 성능에 민감한 기능은 성능 테스트로 검증해야 합니다.
예를 들어, 1000개 아이템이 있는 리스트를 스크롤할 때 프레임 드롭이 발생하지 않는지 확인할 수 있습니다. 기존에는 "눈으로 보기에 괜찮으면 되겠지"라고 생각했다면, 이제는 구체적인 숫자로 성능을 측정하고 회귀 방지를 위한 자동화된 체크를 만들 수 있습니다.
CI/CD에서 자동으로 성능이 저하되지 않았는지 확인할 수 있습니다. 성능 테스트의 핵심 특징은 정량적 측정, 프레임 타임 분석, 그리고 메모리 프로파일링입니다.
이러한 특징들이 사용자가 느끼는 앱의 부드러움과 반응성을 보장하고 저사양 기기에서도 좋은 경험을 제공합니다.
코드 예제
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/heavy_list.dart';
void main() {
group('성능 테스트', () {
// 위젯 빌드 시간 측정
testWidgets('대량 데이터 리스트가 100ms 이내에 빌드된다', (tester) async {
final stopwatch = Stopwatch()..start();
await tester.pumpWidget(
MaterialApp(
home: HeavyListScreen(itemCount: 1000),
),
);
stopwatch.stop();
expect(stopwatch.elapsedMilliseconds, lessThan(100));
});
// 프레임 드롭 측정
testWidgets('스크롤 중 프레임 드롭이 발생하지 않는다', (tester) async {
await tester.pumpWidget(
MaterialApp(home: HeavyListScreen(itemCount: 1000)),
);
await tester.pumpAndSettle();
// 프레임 타임 기록 시작
final timeline = await tester.runAsync(() async {
// 빠른 스크롤 시뮬레이션
await tester.fling(
find.byType(ListView),
Offset(0, -500), // 위로 500픽셀
1000, // 속도
);
await tester.pumpAndSettle();
});
// 모든 프레임이 16ms (60fps) 이하인지 확인
final frameTimes = timeline?.frameTimings ?? [];
for (var frameTime in frameTimes) {
expect(
frameTime.buildDuration.inMilliseconds,
lessThan(16),
reason: '프레임 빌드가 16ms를 초과했습니다',
);
}
});
// 리빌드 횟수 검증
testWidgets('불필요한 리빌드가 발생하지 않는다', (tester) async {
var buildCount = 0;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
buildCount++;
return OptimizedWidget();
},
),
),
);
// 초기 빌드
expect(buildCount, 1);
// 무관한 상태 변경
await tester.tap(find.byType(UnrelatedButton));
await tester.pump();
// 리빌드가 일어나지 않아야 함
expect(buildCount, 1);
});
// 메모리 누수 체크
test('위젯 dispose 후 리스너가 제거된다', () {
final controller = ScrollController();
var listenerCalled = false;
controller.addListener(() {
listenerCalled = true;
});
// 정상 동작 확인
controller.jumpTo(100);
expect(listenerCalled, true);
// dispose 호출
controller.dispose();
// dispose 후에는 리스너가 제거되어야 함
listenerCalled = false;
controller.jumpTo(200);
expect(listenerCalled, false);
});
});
}
설명
이것이 하는 일: 위 코드는 리스트 렌더링 속도, 스크롤 성능, 불필요한 리빌드, 메모리 누수 같은 성능 관련 문제를 자동으로 검증합니다. 첫 번째로, Stopwatch를 사용하여 위젯 빌드 시간을 측정합니다.
pumpWidget 전후로 시간을 재서 전체 빌드 과정이 얼마나 걸리는지 확인합니다. lessThan 매처로 임계값(예: 100ms)을 설정하여 이를 초과하면 테스트가 실패하도록 만듭니다.
이렇게 하면 성능 회귀(performance regression)를 조기에 발견할 수 있습니다. 그 다음으로, runAsync와 fling으로 실제 스크롤 시나리오를 시뮬레이션하고 프레임 타임을 측정합니다.
60fps를 유지하려면 각 프레임이 16ms 이내에 완료되어야 합니다. frameTimings를 확인하여 프레임 드롭이 발생하는지 검증합니다.
프레임 드롭은 사용자가 가장 쉽게 느끼는 성능 문제입니다. 마지막으로, Builder 위젯과 빌드 카운터를 사용하여 불필요한 리빌드를 감지합니다.
상태 변경이 없는데 위젯이 리빌드되면 성능 낭비입니다. const 생성자, Key 사용, shouldRebuild 최적화 등이 제대로 적용되었는지 확인할 수 있습니다.
메모리 누수는 dispose 후에도 리스너가 호출되는지 테스트하여 검증합니다. 여러분이 이 방법을 사용하면 성능 저하를 코드 리뷰 단계에서 발견할 수 있고, 리팩토링이나 새 기능 추가 시 성능이 악화되지 않았는지 자동으로 확인할 수 있으며, 저사양 기기를 직접 테스트하지 않아도 성능 문제를 예측할 수 있습니다.
CI/CD에 통합하면 매 커밋마다 성능을 모니터링할 수 있습니다.
실전 팁
💡 Flutter DevTools의 Timeline 탭을 활용하여 실제 앱의 성능을 프로파일링하세요. 테스트로는 발견하기 어려운 복잡한 성능 이슈를 시각적으로 파악할 수 있습니다.
💡 const 생성자를 사용하면 위젯이 리빌드되지 않아 성능이 크게 향상됩니다. expect(widget, isA<Widget>().having((w) => w.runtimeType.toString(), 'type', contains('_ConstWidget')))로 검증하세요.
💡 큰 리스트는 ListView.builder를 사용하고, 이미지는 cached_network_image를 사용하는 등 최적화 패턴을 따르는지 코드 리뷰에서 확인하세요.
💡 Profile 모드에서 실행하여 성능을 측정하세요. Debug 모드는 성능이 매우 느리고, Release 모드는 디버깅이 어렵습니다. flutter run --profile
💡 메모리 누수는 Flutter DevTools의 Memory 탭이나 LeakTracker로 검증할 수 있습니다. 특히 StatefulWidget의 dispose가 제대로 호출되는지 확인하세요.