Flutter 앱에서 어떻게 위젯 테스트를 작성하나요?

질문

Flutter에서 위젯 테스트를 작성하고 실행하는 방법을 설명해주세요.

답변

Flutter에서 위젯 테스트는 UI 컴포넌트가 예상대로 렌더링되고 동작하는지 확인하는 중요한 방법입니다. 위젯 테스트를 통해 사용자 상호작용에 따라 UI가 올바르게 반응하는지 검증할 수 있으며, 앱의 품질과 안정성을 향상시킬 수 있습니다.

1. 테스트 패키지 설정하기

Flutter에서 위젯 테스트를 작성하려면 먼저 flutter_test 패키지가 필요합니다. 이 패키지는 Flutter SDK에 포함되어 있으므로 별도로 설치할 필요는 없지만, pubspec.yaml 파일에 개발 의존성으로 추가해야 합니다:

# pubspec.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter

2. 기본 위젯 테스트 작성하기

테스트 파일은 일반적으로 test 디렉토리 아래에 위치하며, 파일 이름은 보통 widget_test.dart로 끝납니다. 예를 들어, counter_widget.dart의 테스트는 counter_widget_test.dart라는 이름으로 만들 수 있습니다.

다음은 간단한 카운터 위젯의 테스트 예시입니다:

// counter_widget.dart
import 'package:flutter/material.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(
      children: [
        Text(
          '$_counter',
          key: Key('counter_value'),
        ),
        ElevatedButton(
          key: Key('increment_button'),
          onPressed: _incrementCounter,
          child: Text('증가'),
        ),
      ],
    );
  }
}
// counter_widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/counter_widget.dart';

void main() {
  testWidgets('Counter increments smoke test', (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.pump();

    // 결과 검증
    expect(find.text('0'), findsNothing);
    expect(find.text('1'), findsOneWidget);
  });
}

3. 위젯 테스트의 핵심 개념

WidgetTester

WidgetTester는 위젯과 상호작용하고 테스트할 수 있게 해주는 유틸리티입니다. 주요 메서드:

// 위젯 렌더링
await tester.pumpWidget(widget);

// 특정 시간 동안 애니메이션 진행
await tester.pump(Duration(seconds: 1));

// 모든 애니메이션이 완료될 때까지 기다리기
await tester.pumpAndSettle();

// 위젯 탭하기
await tester.tap(finder);

// 텍스트 입력하기
await tester.enterText(finder, 'text');

// 드래그하기
await tester.drag(finder, Offset(0, 500));

// 스크롤하기
await tester.dragFrom(Offset(0, 300), Offset(0, -300));

Finder

Finder는 위젯 트리에서 특정 위젯을 찾기 위한 도구입니다. 다양한 방법으로 위젯을 찾을 수 있습니다:

// 텍스트로 찾기
find.text('특정 텍스트');

// 위젯 타입으로 찾기
find.byType(ElevatedButton);

// 키로 찾기
find.byKey(Key('widget_key'));

// 아이콘으로 찾기
find.byIcon(Icons.add);

// 위젯 인스턴스로 찾기
find.byWidget(myWidget);

// 툴팁으로 찾기
find.byTooltip('툴팁 텍스트');

// 시맨틱 레이블로 찾기
find.bySemanticsLabel('접근성 레이블');

// 위젯의 자손 중에서 찾기
find.descendant(
  of: find.byType(ListView),
  matching: find.byType(ListTile),
);

Matcher

Matcher는 검증 조건을 지정하는 데 사용됩니다:

// 정확히 하나의 위젯을 찾음
expect(finder, findsOneWidget);

// 정확히 n개의 위젯을 찾음
expect(finder, findsNWidgets(n));

// 위젯을 찾지 못함
expect(finder, findsNothing);

// 여러 개의 위젯을 찾음(1개 이상)
expect(finder, findsWidgets);

// 특정 텍스트를 포함하는지 확인
expect(find.text('Hello'), findsOneWidget);

// 위젯 속성 확인
final textWidget = tester.widget<Text>(find.byType(Text));
expect(textWidget.style?.fontSize, 16.0);

4. 복잡한 위젯 테스트 예시

폼 입력 테스트

testWidgets('Login form validates inputs', (WidgetTester tester) async {
  await tester.pumpWidget(MaterialApp(home: LoginScreen()));

  // 빈 폼 제출 시도
  await tester.tap(find.byType(ElevatedButton));
  await tester.pump();

  // 유효성 검사 오류 메시지 확인
  expect(find.text('이메일을 입력하세요'), findsOneWidget);
  expect(find.text('비밀번호를 입력하세요'), findsOneWidget);

  // 이메일 입력
  await tester.enterText(find.byKey(Key('email_field')), 'test@example.com');
  // 비밀번호 입력
  await tester.enterText(find.byKey(Key('password_field')), 'password123');
  await tester.pump();

  // 폼 제출
  await tester.tap(find.byType(ElevatedButton));
  await tester.pump();

  // 성공 메시지 확인
  expect(find.text('로그인 성공!'), findsOneWidget);
});

상태 관리 테스트 (Provider 사용)

Provider를 사용하는 위젯을 테스트할 때는 테스트 위젯에도 Provider를 포함해야 합니다:

testWidgets('Counter with Provider', (WidgetTester tester) async {
  final counterModel = CounterModel();

  await tester.pumpWidget(
    ChangeNotifierProvider<CounterModel>.value(
      value: counterModel,
      child: MaterialApp(
        home: CounterPage(),
      ),
    ),
  );

  expect(find.text('0'), findsOneWidget);

  await tester.tap(find.byIcon(Icons.add));
  await tester.pump();

  expect(find.text('1'), findsOneWidget);
});

네비게이션 테스트

testWidgets('Navigate to details screen', (WidgetTester tester) async {
  await tester.pumpWidget(MaterialApp(
    routes: {
      '/': (context) => HomeScreen(),
      '/details': (context) => DetailsScreen(),
    },
  ));

  // 홈 화면에서 상세 화면으로 이동하는 버튼 탭
  await tester.tap(find.byKey(Key('navigate_to_details')));
  await tester.pumpAndSettle(); // 애니메이션 완료 대기

  // 상세 화면의 특정 요소 확인
  expect(find.text('상세 정보'), findsOneWidget);
});

스크롤 테스트

testWidgets('Scrolling ListView', (WidgetTester tester) async {
  await tester.pumpWidget(MaterialApp(
    home: Scaffold(
      body: ListView.builder(
        itemCount: 100,
        itemBuilder: (context, index) => ListTile(
          title: Text('Item $index'),
        ),
      ),
    ),
  ));

  // 리스트의 첫 번째 항목 확인
  expect(find.text('Item 0'), findsOneWidget);
  expect(find.text('Item 30'), findsNothing);

  // 스크롤 동작 수행
  await tester.dragFrom(
    tester.getCenter(find.byType(ListView)),
    Offset(0, -500),
  );
  await tester.pumpAndSettle();

  // 스크롤 후 항목 확인
  expect(find.text('Item 0'), findsNothing); // 화면 밖으로 스크롤됨
  expect(find.text('Item 30'), findsOneWidget); // 이제 화면에 보임
});

5. 비동기 동작 테스트

위젯이 비동기 작업을 수행하는 경우(예: API 호출), 이를 테스트하는 방법:

testWidgets('Test async data loading', (WidgetTester tester) async {
  // Mock 데이터 서비스 생성
  final mockService = MockDataService();
  when(mockService.fetchData()).thenAnswer((_) async => ['Item 1', 'Item 2']);

  await tester.pumpWidget(MaterialApp(
    home: DataScreen(service: mockService),
  ));

  // 초기 로딩 상태 확인
  expect(find.byType(CircularProgressIndicator), findsOneWidget);

  // 비동기 작업 완료 대기
  await tester.pumpAndSettle();

  // 데이터 로드 후 상태 확인
  expect(find.byType(CircularProgressIndicator), findsNothing);
  expect(find.text('Item 1'), findsOneWidget);
  expect(find.text('Item 2'), findsOneWidget);
});

6. Mocking 및 의존성 주입

테스트에서 외부 의존성(예: HTTP 요청, 로컬 스토리지 등)을 모킹하는 것이 좋습니다. mockito 패키지를 사용하여 모킹할 수 있습니다:

# pubspec.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito: ^5.4.2
  build_runner: ^2.4.6
// user_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:your_app/services/user_service.dart';
import 'package:your_app/services/api_client.dart';

import 'user_service_test.mocks.dart';

@GenerateMocks([ApiClient])
void main() {
  late UserService userService;
  late MockApiClient mockApiClient;

  setUp(() {
    mockApiClient = MockApiClient();
    userService = UserService(apiClient: mockApiClient);
  });

  testWidgets('User profile widget displays user data', (WidgetTester tester) async {
    // API 응답 모킹
    when(mockApiClient.getUser(1)).thenAnswer((_) async => {
      'id': 1,
      'name': '홍길동',
      'email': 'hong@example.com'
    });

    // 위젯 렌더링
    await tester.pumpWidget(MaterialApp(
      home: UserProfileWidget(
        userId: 1,
        userService: userService,
      ),
    ));

    // 로딩 상태 확인
    expect(find.byType(CircularProgressIndicator), findsOneWidget);

    // 데이터 로딩 대기
    await tester.pumpAndSettle();

    // 사용자 데이터 표시 확인
    expect(find.text('홍길동'), findsOneWidget);
    expect(find.text('hong@example.com'), findsOneWidget);
  });
}

7. 골든 테스트 (Golden Tests)

골든 테스트는 위젯의 시각적 출력을 검증하는 방법입니다. 위젯의 스크린샷을 찍어 참조 이미지와 비교합니다:

testWidgets('Golden test for MyWidget', (WidgetTester tester) async {
  await tester.pumpWidget(MaterialApp(
    home: MyWidget(),
  ));

  // 골든 파일과 비교
  await expectLater(
    find.byType(MyWidget),
    matchesGoldenFile('my_widget.png'),
  );
});

골든 테스트를 처음 실행할 때 참조 이미지가 생성되고, 이후 테스트에서는 이 이미지와 비교합니다. 골든 파일은 test/goldens 디렉토리에 저장하는 것이 일반적입니다.

8. 플랫폼별 테스트

특정 플랫폼에서만 동작하는 위젯을 테스트하려면:

testWidgets('Platform-specific widget test', (WidgetTester tester) async {
  // 테스트에서 사용할 플랫폼 설정
  debugDefaultTargetPlatformOverride = TargetPlatform.iOS;

  await tester.pumpWidget(MaterialApp(
    home: PlatformSpecificWidget(),
  ));

  // iOS 특정 위젯 확인
  expect(find.byType(CupertinoButton), findsOneWidget);
  expect(find.byType(ElevatedButton), findsNothing);

  // 원래 값으로 복원
  debugDefaultTargetPlatformOverride = null;
});

9. 테스트 그룹화 및 설정

테스트를 구조화하기 위해 group을 사용하고, setUptearDown으로 공통 코드를 관리할 수 있습니다:

group('Counter Widget', () {
  late WidgetTester tester;

  setUp(() async {
    tester = WidgetTester();
    await tester.pumpWidget(MaterialApp(
      home: CounterWidget(),
    ));
  });

  testWidgets('starts at 0', (WidgetTester tester) async {
    expect(find.text('0'), findsOneWidget);
  });

  testWidgets('increments when button is tapped', (WidgetTester tester) async {
    await tester.tap(find.byType(ElevatedButton));
    await tester.pump();
    expect(find.text('1'), findsOneWidget);
  });
});

10. 테스트 실행하기

테스트는 다음 명령으로 실행할 수 있습니다:

# 모든 테스트 실행
flutter test

# 특정 테스트 파일 실행
flutter test test/counter_widget_test.dart

# 특정 디렉토리의 모든 테스트 실행
flutter test test/widgets/

VSCode나 Android Studio와 같은 IDE에서도 테스트를 쉽게 실행할 수 있습니다.

실전 팁

  1. 위젯 키 사용: 테스트에서 위젯을 쉽게 찾을 수 있도록 중요한 위젯에 키를 할당하세요.

  2. 테스트 크기 유지: 각 테스트는 하나의 기능만 검증하도록 유지하세요.

  3. 중복 코드 줄이기: setUptearDown을 활용하여 반복적인 코드를 줄이세요.

  4. 의존성 주입 활용: 외부 의존성을 모킹하기 쉽도록 의존성 주입 패턴을 사용하세요.

  5. 테스트 가능한 코드 작성: 위젯과 비즈니스 로직을 분리하여 테스트하기 쉬운 코드를 작성하세요.

  6. pumpAndSettle 주의: pumpAndSettle()은 무한 애니메이션이 있으면 무한 루프에 빠질 수 있으므로 주의하세요.

  7. 시각적 디버깅 활용: 테스트 실패 원인을 파악하기 어려울 때 debugDumpApp()이나 debugDumpRenderTree()를 사용하여 위젯 트리를 검사하세요.

  8. 통합 테스트 병행: 위젯 테스트로 모든 것을 검증하는 것은 어렵습니다. 복잡한 시나리오는 통합 테스트(integration_test 패키지)를 활용하세요.

요약

Flutter에서 위젯 테스트는 UI 컴포넌트의 동작을 검증하는 강력한 방법입니다. flutter_test 패키지는 위젯을 렌더링하고 상호작용하는 데 필요한 도구를 제공합니다.

위젯 테스트를 작성할 때는:

  1. 위젯을 렌더링하고 (pumpWidget)
  2. 사용자 상호작용을 시뮬레이션하며 (tap, enterText 등)
  3. 결과를 검증합니다 (expect와 다양한 Matcher 사용)

적절한 테스트는 리팩토링이나 새 기능 추가 시 회귀를 방지하고, 코드의 품질과 신뢰성을 향상시킵니다. 효과적인 테스트를 위해 모킹과 의존성 주입 기술을 활용하세요.

results matching ""

    No results matching ""