Flutter에서 테스트 자동화는 어떻게 구현하나요?

질문

Flutter에서 효과적인 테스트 자동화를 구현하는 방법과 테스트 종류별 모범 사례에 대해 설명해주세요.

답변

Flutter 테스트 자동화는 앱의 품질을 보장하고 새로운 기능 추가나 리팩토링 시 발생할 수 있는 회귀 문제를 방지하는 중요한 과정입니다. Flutter는 단위 테스트부터 위젯 테스트, 통합 테스트까지 다양한 수준의 테스트를 지원합니다.

1. Flutter 테스트의 종류

Flutter에서는 주로 세 가지 유형의 테스트를 사용합니다:

1.1 단위 테스트 (Unit Tests)

클래스, 함수, 메서드와 같은 개별 단위의 기능을 테스트합니다. 외부 의존성이 없는 비즈니스 로직을 주로 테스트합니다.

1.2 위젯 테스트 (Widget Tests)

UI 컴포넌트의 동작을 테스트합니다. 단위 테스트보다 더 많은 코드를 테스트하지만, 전체 앱을 실행하는 것보다는 가벼우며 빠르게 실행됩니다.

1.3 통합 테스트 (Integration Tests)

전체 앱이나 앱의 큰 부분을 테스트합니다. 실제 기기나 에뮬레이터에서 실행되며 앱의 성능과 동작을 종합적으로 검증합니다.

2. 단위 테스트 구현

단위 테스트는 test 패키지를 사용하여 구현합니다. 주로 비즈니스 로직, 유틸리티 함수, 상태 관리 클래스 등을 테스트합니다.

2.1 기본 설정

먼저 pubspec.yaml에 필요한 의존성을 추가합니다:

dev_dependencies:
  test: ^1.21.0
  mockito: ^5.3.0
  build_runner: ^2.2.0

2.2 단위 테스트 예시

다음은 간단한 사용자 서비스 클래스와 그에 대한 단위 테스트 예시입니다:

// lib/services/user_service.dart
class UserService {
  Future<User> fetchUser(int id) async {
    // API 호출 또는 다른 로직
    return User(id: id, name: '사용자 $id');
  }

  bool isValidEmail(String email) {
    return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(email);
  }
}

class User {
  final int id;
  final String name;

  User({required this.id, required this.name});
}
// test/services/user_service_test.dart
import 'package:test/test.dart';
import 'package:your_app/services/user_service.dart';

void main() {
  late UserService userService;

  setUp(() {
    userService = UserService();
  });

  group('UserService', () {
    test('fetchUser returns a user with the correct id', () async {
      final user = await userService.fetchUser(1);
      expect(user.id, 1);
      expect(user.name, '사용자 1');
    });

    group('isValidEmail', () {
      test('returns true for valid email addresses', () {
        expect(userService.isValidEmail('test@example.com'), true);
        expect(userService.isValidEmail('user.name@domain.co.kr'), true);
      });

      test('returns false for invalid email addresses', () {
        expect(userService.isValidEmail('test@'), false);
        expect(userService.isValidEmail('test@domain'), false);
        expect(userService.isValidEmail('test'), false);
      });
    });
  });
}

2.3 모킹(Mocking)을 사용한 테스트

외부 의존성(API, 데이터베이스 등)이 있는 클래스를 테스트할 때는 Mockito를 사용하여 의존성을 모의 객체로 대체합니다:

// lib/repositories/user_repository.dart
class UserRepository {
  final ApiClient apiClient;

  UserRepository(this.apiClient);

  Future<User> getUser(int id) async {
    final data = await apiClient.get('/users/$id');
    return User.fromJson(data);
  }
}

abstract class ApiClient {
  Future<Map<String, dynamic>> get(String endpoint);
}
// test/repositories/user_repository_test.dart
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
import 'package:your_app/repositories/user_repository.dart';

import 'user_repository_test.mocks.dart';

@GenerateMocks([ApiClient])
void main() {
  late UserRepository userRepository;
  late MockApiClient mockApiClient;

  setUp(() {
    mockApiClient = MockApiClient();
    userRepository = UserRepository(mockApiClient);
  });

  group('UserRepository', () {
    test('getUser returns user when API call is successful', () async {
      // 모의 동작 설정
      when(mockApiClient.get('/users/1')).thenAnswer((_) async => {
            'id': 1,
            'name': '홍길동',
          });

      final user = await userRepository.getUser(1);

      // 검증
      expect(user.id, 1);
      expect(user.name, '홍길동');
      verify(mockApiClient.get('/users/1')).called(1);
    });

    test('getUser throws exception when API call fails', () async {
      // 모의 동작 설정 - 예외 발생
      when(mockApiClient.get('/users/1'))
          .thenThrow(Exception('네트워크 오류'));

      // 예외 발생 검증
      expect(() => userRepository.getUser(1), throwsException);
      verify(mockApiClient.get('/users/1')).called(1);
    });
  });
}

Mockito 클래스를 생성하려면 다음 명령을 실행해야 합니다:

flutter pub run build_runner build

3. 위젯 테스트 구현

위젯 테스트는 flutter_test 패키지를 사용하여 구현합니다. 주로 UI 컴포넌트의 렌더링과 상호작용을 테스트합니다.

3.1 기본 설정

Flutter 프로젝트에는 flutter_test 패키지가 기본으로 포함되어 있습니다:

dev_dependencies:
  flutter_test:
    sdk: flutter

3.2 위젯 테스트 예시

다음은 간단한 로그인 폼 위젯과 그에 대한 테스트 예시입니다:

// lib/widgets/login_form.dart
import 'package:flutter/material.dart';

class LoginForm extends StatefulWidget {
  final Function(String, String) onLogin;

  const LoginForm({Key? key, required this.onLogin}) : super(key: key);

  @override
  _LoginFormState createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  final _formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            controller: _emailController,
            decoration: InputDecoration(labelText: '이메일'),
            validator: (value) {
              if (value == null || value.isEmpty) {
                return '이메일을 입력하세요';
              }
              if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')
                  .hasMatch(value)) {
                return '유효한 이메일을 입력하세요';
              }
              return null;
            },
          ),
          TextFormField(
            controller: _passwordController,
            decoration: InputDecoration(labelText: '비밀번호'),
            obscureText: true,
            validator: (value) {
              if (value == null || value.isEmpty) {
                return '비밀번호를 입력하세요';
              }
              if (value.length < 6) {
                return '비밀번호는 최소 6자 이상이어야 합니다';
              }
              return null;
            },
          ),
          ElevatedButton(
            onPressed: () {
              if (_formKey.currentState!.validate()) {
                widget.onLogin(
                  _emailController.text,
                  _passwordController.text,
                );
              }
            },
            child: Text('로그인'),
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }
}
// test/widgets/login_form_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/widgets/login_form.dart';

void main() {
  testWidgets('LoginForm validates input correctly', (WidgetTester tester) async {
    bool loginCalled = false;
    String? email;
    String? password;

    // 로그인 폼 위젯 렌더링
    await tester.pumpWidget(MaterialApp(
      home: Scaffold(
        body: LoginForm(
          onLogin: (e, p) {
            loginCalled = true;
            email = e;
            password = p;
          },
        ),
      ),
    ));

    // 로그인 버튼 찾기
    final loginButton = find.text('로그인');
    expect(loginButton, findsOneWidget);

    // 버튼 탭 - 아무것도 입력하지 않은 상태에서 유효성 검사 오류가 표시되어야 함
    await tester.tap(loginButton);
    await tester.pump();

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

    // 이메일 필드 채우기 (잘못된 형식)
    await tester.enterText(find.byType(TextFormField).at(0), 'invalid-email');
    await tester.pump();

    // 비밀번호 필드 채우기 (너무 짧음)
    await tester.enterText(find.byType(TextFormField).at(1), '123');
    await tester.pump();

    // 버튼 다시 탭
    await tester.tap(loginButton);
    await tester.pump();

    // 새로운 오류 메시지 확인
    expect(find.text('유효한 이메일을 입력하세요'), findsOneWidget);
    expect(find.text('비밀번호는 최소 6자 이상이어야 합니다'), findsOneWidget);
    expect(loginCalled, false);

    // 올바른 값으로 필드 채우기
    await tester.enterText(find.byType(TextFormField).at(0), 'test@example.com');
    await tester.enterText(find.byType(TextFormField).at(1), 'password123');
    await tester.pump();

    // 버튼 다시 탭
    await tester.tap(loginButton);
    await tester.pump();

    // 로그인 호출 확인
    expect(loginCalled, true);
    expect(email, 'test@example.com');
    expect(password, 'password123');
  });

  testWidgets('LoginForm has correct initial state', (WidgetTester tester) async {
    // 로그인 폼 위젯 렌더링
    await tester.pumpWidget(MaterialApp(
      home: Scaffold(
        body: LoginForm(onLogin: (_, __) {}),
      ),
    ));

    // 필드 레이블 확인
    expect(find.text('이메일'), findsOneWidget);
    expect(find.text('비밀번호'), findsOneWidget);

    // 초기 상태에서는 오류 메시지가 표시되지 않아야 함
    expect(find.text('이메일을 입력하세요'), findsNothing);
    expect(find.text('비밀번호를 입력하세요'), findsNothing);
  });
}

4. 통합 테스트 구현

통합 테스트는 integration_test 패키지를 사용하여 구현합니다. 실제 기기나 에뮬레이터에서 앱의 전체 흐름을 테스트합니다.

4.1 기본 설정

먼저 pubspec.yaml에 필요한 의존성을 추가합니다:

dev_dependencies:
  integration_test:
    sdk: flutter
  flutter_test:
    sdk: flutter

4.2 통합 테스트 디렉토리 구조 설정

프로젝트 루트에 integration_test 디렉토리를 만들고 테스트 파일을 추가합니다:

your_app/
  integration_test/
    app_test.dart

4.3 통합 테스트 예시

다음은 로그인 화면과 홈 화면 간의 흐름을 테스트하는 통합 테스트 예시입니다:

// 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:your_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('end-to-end test', () {
    testWidgets('login and navigate to home screen', (WidgetTester tester) async {
      // 앱 시작
      app.main();
      await tester.pumpAndSettle();

      // 로그인 화면에 있는지 확인
      expect(find.text('로그인'), findsOneWidget);

      // 로그인 정보 입력
      await tester.enterText(find.byType(TextField).at(0), 'test@example.com');
      await tester.enterText(find.byType(TextField).at(1), 'password123');
      await tester.pumpAndSettle();

      // 로그인 버튼 클릭
      await tester.tap(find.byType(ElevatedButton));
      await tester.pumpAndSettle();

      // 홈 화면으로 이동했는지 확인
      expect(find.text('홈 화면'), findsOneWidget);
      expect(find.text('환영합니다, test@example.com님!'), findsOneWidget);

      // 로그아웃 버튼 클릭
      await tester.tap(find.text('로그아웃'));
      await tester.pumpAndSettle();

      // 다시 로그인 화면으로 돌아왔는지 확인
      expect(find.text('로그인'), findsOneWidget);
    });
  });
}

4.4 통합 테스트 실행

통합 테스트를 실행하려면 다음 명령을 사용합니다:

flutter test integration_test/app_test.dart

실제 디바이스나 에뮬레이터에서 실행:

flutter drive --driver=test_driver/integration_test.dart --target=integration_test/app_test.dart

5. 테스트 자동화 모범 사례

5.1 테스트 가능한 코드 작성

테스트 용이성을 염두에 두고 코드를 설계하세요:

  1. 관심사 분리: 비즈니스 로직과 UI를 분리하세요.
  2. 의존성 주입: 모의 객체로 대체할 수 있도록 의존성을 주입하세요.
  3. 단일 책임 원칙: 클래스와 함수는 하나의 책임만 가지도록 하세요.

5.2 테스트 피라미드 적용

테스트 피라미드 원칙을 따르세요:

  1. 단위 테스트: 가장 많은 수의 테스트 (빠르고 적은 리소스 필요)
  2. 위젯 테스트: 중간 수준의 테스트
  3. 통합 테스트: 가장 적은 수의 테스트 (느리고 많은 리소스 필요)

5.3 효과적인 테스트 작성

  1. 설정-실행-검증 패턴 사용:

    • 설정(Arrange): 테스트에 필요한 객체와 상태 준비
    • 실행(Act): 테스트할 동작 수행
    • 검증(Assert): 예상 결과 확인
  2. 명확한 테스트 이름 사용:

    test('should show error message when email is invalid', () { ... });
    
  3. 각 테스트는 독립적으로 실행될 수 있어야 함:

    setUp(() {
      // 각 테스트 전에 테스트 환경 초기화
    });
    
    tearDown(() {
      // 각 테스트 후에 리소스 정리
    });
    

5.4 테스트 데이터 관리

  1. 테스트 픽스처 사용:

    // test/fixtures/user_data.dart
    final testUser = User(id: 1, name: '홍길동', email: 'hong@example.com');
    
    // 테스트에서 사용
    import 'fixtures/user_data.dart';
    test('user display', () {
      // testUser 사용
    });
    
  2. 팩토리 함수로 테스트 데이터 생성:

    User createTestUser({int? id, String? name, String? email}) {
      return User(
        id: id ?? 1,
        name: name ?? '홍길동',
        email: email ?? 'hong@example.com',
      );
    }
    

5.5 CI/CD 파이프라인 통합

자동화된 테스트를 CI/CD 파이프라인에 통합하여 코드 품질을 지속적으로 모니터링하세요:

  1. GitHub Actions 예시:

    # .github/workflows/flutter_test.yml
    name: Flutter Tests
    on:
      push:
        branches: [main]
      pull_request:
        branches: [main]
    
    jobs:
      test:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v2
          - uses: subosito/flutter-action@v2
            with:
              flutter-version: "3.0.0"
          - run: flutter pub get
          - run: flutter format --set-exit-if-changed .
          - run: flutter analyze
          - run: flutter test
    

6. 고급 테스트 기법

6.1 Golden 테스트

UI 레이아웃을 시각적으로 테스트하는 방법으로, 위젯의 스크린샷을 찍어 저장된 "golden" 이미지와 비교합니다:

testWidgets('MyWidget matches golden file', (WidgetTester tester) async {
  await tester.pumpWidget(MaterialApp(home: MyWidget()));
  await expectLater(find.byType(MyWidget), matchesGoldenFile('my_widget.png'));
});

6.2 Provider와 함께 테스트

상태 관리 라이브러리(Provider, Riverpod 등)와 함께 테스트하는 예시:

testWidgets('Counter increments when button is pressed', (WidgetTester tester) async {
  // Provider 설정
  await tester.pumpWidget(
    ChangeNotifierProvider(
      create: (_) => CounterModel(),
      child: MyCounterApp(),
    ),
  );

  // 초기 상태 확인
  expect(find.text('0'), findsOneWidget);
  expect(find.text('1'), findsNothing);

  // 버튼 탭
  await tester.tap(find.byIcon(Icons.add));
  await tester.pump();

  // 상태 변경 확인
  expect(find.text('1'), findsOneWidget);
  expect(find.text('0'), findsNothing);
});

6.3 네트워크 요청 모킹

http 패키지의 요청을 모킹하는 방법:

import 'package:http/http.dart' as http;
import 'package:http/testing.dart';

void main() {
  late UserRepository userRepository;
  late http.Client mockClient;

  setUp(() {
    mockClient = MockClient((request) async {
      if (request.url.toString() == 'https://api.example.com/users/1') {
        return http.Response('{"id": 1, "name": "홍길동"}', 200);
      }
      return http.Response('Not found', 404);
    });

    userRepository = UserRepository(mockClient);
  });

  test('fetches user correctly', () async {
    final user = await userRepository.getUser(1);
    expect(user.id, 1);
    expect(user.name, '홍길동');
  });
}

7. 테스트 커버리지 측정

테스트 커버리지는 코드 중 얼마나 많은 부분이 테스트되었는지를 측정하는 지표입니다.

# 테스트 커버리지 측정 및 보고서 생성
flutter test --coverage

생성된 커버리지 데이터를 HTML 보고서로 변환하려면 lcov 도구가 필요합니다:

# lcov 설치 (Ubuntu)
sudo apt-get install lcov

# HTML 보고서 생성
genhtml coverage/lcov.info -o coverage/html

결론

Flutter에서 테스트 자동화는 앱의 품질과 안정성을 보장하는 필수적인 과정입니다. 단위 테스트, 위젯 테스트, 통합 테스트를 적절히 조합하여 테스트 피라미드를 구성하고, 테스트 가능한 코드 설계 원칙을 따르면 효과적인 테스트 자동화를 구현할 수 있습니다.

앱 개발 초기 단계부터 테스트를 염두에 두고 설계하면, 장기적으로 개발 속도와 코드 품질이 모두 향상됩니다. 또한 CI/CD 파이프라인에 테스트를 통합하여 지속적인 코드 품질 관리가 가능해집니다.

테스트는 한 번 작성하고 끝나는 것이 아니라, 앱이 발전함에 따라 함께 발전해야 하는 살아있는 문서와 같습니다. 정기적으로 테스트를 검토하고 업데이트하여 항상 최신 상태를 유지하는 것이 중요합니다.

results matching ""

    No results matching ""