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

질문

Flutter 앱에서 단위 테스트, 위젯 테스트, 통합 테스트를 작성하는 방법과 각각의 차이점에 대해 설명해주세요.

답변

Flutter에서는 앱의 품질과 안정성을 보장하기 위해 세 가지 주요 유형의 테스트를 지원합니다: 단위 테스트, 위젯 테스트, 통합 테스트. 각 테스트 유형은 다른 수준의 세부 사항과 범위를 다루며, 모두 함께 사용하면 앱의 전반적인 품질을 향상시킬 수 있습니다.

1. 단위 테스트 (Unit Test)

단위 테스트는 UI를 제외한 개별 함수, 메서드, 클래스의 동작을 검증하는 데 사용됩니다. Flutter의 비즈니스 로직, 데이터 모델, 유틸리티 함수 등을 테스트하는 데 적합합니다.

설정 방법

pubspec.yaml 파일에 테스트 의존성을 추가합니다:

dev_dependencies:
  test: ^1.24.0
  mocktail: ^1.0.0 # 모킹 라이브러리 (선택사항)

기본 단위 테스트 예제

// counter.dart (테스트할 클래스)
class Counter {
  int value = 0;

  void increment() => value++;
  void decrement() => value--;
}
// test/counter_test.dart
import 'package:test/test.dart';
import 'package:your_app/counter.dart';

void main() {
  group('Counter', () {
    late Counter counter;

    setUp(() {
      counter = Counter();
    });

    test('초기값은 0이어야 함', () {
      expect(counter.value, 0);
    });

    test('increment는 값을 1 증가시킴', () {
      counter.increment();
      expect(counter.value, 1);
    });

    test('decrement는 값을 1 감소시킴', () {
      counter.decrement();
      expect(counter.value, -1);
    });

    test('increment 후 decrement는 원래 값으로 복원됨', () {
      counter.increment();
      counter.decrement();
      expect(counter.value, 0);
    });
  });
}

비동기 코드 테스트

// user_repository.dart
class UserRepository {
  Future<User> fetchUser(int id) async {
    // API 호출 등의 비동기 작업
    return User(id: id, name: 'User $id');
  }
}

class User {
  final int id;
  final String name;
  User({required this.id, required this.name});
}
// test/user_repository_test.dart
import 'package:test/test.dart';
import 'package:your_app/user_repository.dart';

void main() {
  group('UserRepository', () {
    late UserRepository repository;

    setUp(() {
      repository = UserRepository();
    });

    test('fetchUser는 올바른 사용자 데이터를 반환함', () async {
      final user = await repository.fetchUser(1);
      expect(user.id, 1);
      expect(user.name, 'User 1');
    });
  });
}

모킹을 사용한 테스트

외부 의존성이 있는 코드를 테스트하려면 모킹을 사용할 수 있습니다:

// user_service.dart
class UserService {
  final HttpClient client;

  UserService(this.client);

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

class HttpClient {
  Future<Map<String, dynamic>> get(String url) async {
    // 실제 API 호출
    throw UnimplementedError();
  }
}
// test/user_service_test.dart
import 'package:test/test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:your_app/user_service.dart';

class MockHttpClient extends Mock implements HttpClient {}

void main() {
  group('UserService', () {
    late UserService service;
    late MockHttpClient mockClient;

    setUp(() {
      mockClient = MockHttpClient();
      service = UserService(mockClient);
    });

    test('getUser는 올바른 URL로 요청하고 User 객체를 반환함', () async {
      // 모의 응답 설정
      when(() => mockClient.get('api/users/1')).thenAnswer((_) async =>
        {'id': 1, 'name': 'John Doe'});

      // 메서드 호출 및 검증
      final user = await service.getUser(1);
      expect(user.id, 1);
      expect(user.name, 'John Doe');

      // 메서드가 호출되었는지 확인
      verify(() => mockClient.get('api/users/1')).called(1);
    });
  });
}

2. 위젯 테스트 (Widget Test)

위젯 테스트는 UI 구성 요소를 테스트하는 데 사용됩니다. 실제 기기 없이도 위젯의 렌더링 및 상호 작용을 테스트할 수 있습니다.

설정 방법

pubspec.yaml 파일에 Flutter 테스트 의존성을 추가합니다:

dev_dependencies:
  flutter_test:
    sdk: flutter

기본 위젯 테스트 예제

// 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(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        Text('카운터: $_counter'),
        ElevatedButton(
          onPressed: _incrementCounter,
          child: Text('증가'),
        ),
      ],
    );
  }
}
// test/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() {
  group('CounterWidget', () {
    testWidgets('증가 버튼을 누르면 카운터가 증가함', (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.text('증가'));

      // 프레임 업데이트
      await tester.pump();

      // 새 상태 확인
      expect(find.text('카운터: 0'), findsNothing);
      expect(find.text('카운터: 1'), findsOneWidget);
    });
  });
}

복잡한 위젯 테스트 예제

// login_screen.dart
import 'package:flutter/material.dart';

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

  LoginScreen({required this.onLogin});

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

class _LoginScreenState extends State<LoginScreen> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  String? _errorMessage;

  void _submitForm() {
    if (_formKey.currentState!.validate()) {
      try {
        widget.onLogin(_emailController.text, _passwordController.text);
      } catch (e) {
        setState(() {
          _errorMessage = e.toString();
        });
      }
    }
  }

  @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,
          ),
          TextFormField(
            controller: _passwordController,
            decoration: InputDecoration(labelText: '비밀번호'),
            obscureText: true,
            validator: (value) =>
              value == null || value.isEmpty ? '비밀번호를 입력하세요' : null,
          ),
          if (_errorMessage != null)
            Text(_errorMessage!, style: TextStyle(color: Colors.red)),
          ElevatedButton(
            onPressed: _submitForm,
            child: Text('로그인'),
          ),
        ],
      ),
    );
  }
}
// test/login_screen_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/login_screen.dart';

void main() {
  group('LoginScreen', () {
    testWidgets('빈 폼 제출 시 검증 오류가 표시됨', (WidgetTester tester) async {
      bool loginCalled = false;

      await tester.pumpWidget(MaterialApp(
        home: Scaffold(
          body: LoginScreen(
            onLogin: (email, password) {
              loginCalled = true;
            },
          ),
        ),
      ));

      // 빈 폼으로 로그인 버튼 클릭
      await tester.tap(find.text('로그인'));
      await tester.pump();

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

    testWidgets('유효한 폼 제출 시 onLogin 콜백이 호출됨', (WidgetTester tester) async {
      String? calledEmail;
      String? calledPassword;

      await tester.pumpWidget(MaterialApp(
        home: Scaffold(
          body: LoginScreen(
            onLogin: (email, password) {
              calledEmail = email;
              calledPassword = password;
            },
          ),
        ),
      ));

      // 폼 필드에 텍스트 입력
      await tester.enterText(find.byType(TextFormField).at(0), 'test@example.com');
      await tester.enterText(find.byType(TextFormField).at(1), 'password123');

      // 로그인 버튼 클릭
      await tester.tap(find.text('로그인'));
      await tester.pump();

      // 콜백 호출 확인
      expect(calledEmail, 'test@example.com');
      expect(calledPassword, 'password123');
    });
  });
}

3. 통합 테스트 (Integration Test)

통합 테스트는 여러 위젯, 화면, 서비스가 함께 작동하는 방식을 테스트하며, 실제 기기나 에뮬레이터에서 실행됩니다.

설정 방법

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

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('전체 앱 테스트', () {
    testWidgets('카운터 앱 흐름 테스트', (WidgetTester tester) async {
      // 앱 실행
      app.main();
      await tester.pumpAndSettle();

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

      // 버튼 탭 및 효과 확인
      await tester.tap(find.byIcon(Icons.add));
      await tester.pumpAndSettle();
      expect(find.text('1'), findsOneWidget);

      // 두 번째 화면으로 이동
      await tester.tap(find.byType(FloatingActionButton));
      await tester.pumpAndSettle();

      // 두 번째 화면에 카운터 값이 표시되는지 확인
      expect(find.text('현재 카운터 값: 1'), findsOneWidget);

      // 뒤로 가기
      await tester.tap(find.byType(BackButton));
      await tester.pumpAndSettle();

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

고급 통합 테스트 예제

로그인 흐름과 여러 화면 간의 이동을 테스트하는 예제:

// integration_test/login_flow_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('로그인 및 탐색 흐름 테스트', () {
    testWidgets('로그인하고 홈 화면으로 이동한 다음 프로필 페이지 확인',
      (WidgetTester tester) async {
      // 앱 실행
      app.main();
      await tester.pumpAndSettle();

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

      // 로그인 정보 입력
      await tester.enterText(find.byKey(Key('email')), 'test@example.com');
      await tester.enterText(find.byKey(Key('password')), 'password123');

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

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

      // 바텀 네비게이션에서 프로필 탭 선택
      await tester.tap(find.byIcon(Icons.person));
      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);

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

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

4. 세 가지 테스트 유형 비교

특성 단위 테스트 위젯 테스트 통합 테스트
목적 개별 클래스/함수 테스트 UI 컴포넌트 테스트 앱 전체 흐름 테스트
속도 매우 빠름 빠름 느림
범위 좁음 중간 넓음
실행 환경 Dart VM Flutter 테스트 환경 실제 기기/에뮬레이터
모킹 필요성 높음 중간 낮음
설정 복잡성 낮음 중간 높음
유지 관리 비용 낮음 중간 높음
현실성 낮음 중간 높음

5. 테스트 구성 및 모범 사례

테스트 구조화

my_app/
├── lib/
│   └── ...
├── test/
│   ├── unit/
│   │   ├── models/
│   │   ├── services/
│   │   └── utils/
│   └── widget/
│       ├── screens/
│       └── components/
└── integration_test/
    └── flows/

모범 사례

  1. AAA 패턴 사용: Arrange(준비), Act(실행), Assert(검증) 패턴을 따라 테스트를 구성합니다.
test('사용자 이름을 올바르게 포맷팅함', () {
  // Arrange (준비)
  final user = User(firstName: 'John', lastName: 'Doe');

  // Act (실행)
  final formattedName = user.getFullName();

  // Assert (검증)
  expect(formattedName, 'John Doe');
});
  1. 의미 있는 테스트 이름 사용: 테스트가 무엇을 검증하는지 명확하게 설명하는 이름을 사용합니다.
// 나쁜 예
test('getFullName', () { ... });

// 좋은 예
test('getFullName은 firstName과 lastName을 공백으로 연결해야 함', () { ... });
  1. 테스트 격리: 각 테스트는 다른 테스트에 의존하지 않고 독립적으로 실행되어야 합니다.

  2. setUp 및 tearDown 활용: 공통 초기화 및 정리 코드를 위해 setUptearDown을 사용합니다.

group('UserRepository', () {
  late UserRepository repository;
  late MockHttpClient mockClient;

  setUp(() {
    mockClient = MockHttpClient();
    repository = UserRepository(mockClient);
  });

  tearDown(() {
    // 필요한 정리 작업
  });

  // 테스트...
});
  1. 테스트 커버리지 모니터링: 테스트 커버리지를 확인하여 충분한 테스트가 있는지 확인합니다.
flutter test --coverage

6. 테스트 자동화 및 CI/CD 통합

GitHub Actions를 사용한 예시:

# .github/workflows/flutter-ci.yml
name: Flutter CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: "3.10.0"
          channel: "stable"
      - run: flutter pub get
      - run: flutter test
      - run: flutter test --coverage
      - uses: codecov/codecov-action@v3
        with:
          file: coverage/lcov.info

  integration_test:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v3
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: "3.10.0"
          channel: "stable"
      - run: flutter pub get
      - name: Run integration tests
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 29
          arch: x86_64
          profile: Nexus 6
          script: flutter test integration_test

결론

Flutter에서 테스트 작성은 앱의 품질과 유지 관리성을 향상시키는 중요한 부분입니다. 단위 테스트는 개별 로직을 검증하고, 위젯 테스트는 UI 구성 요소를 테스트하며, 통합 테스트는 전체 앱의 흐름을 검증합니다.

이상적인 테스트 전략은 세 가지 유형의 테스트를 모두 포함하지만, 프로젝트의 규모와 리소스에 따라 적절한 균형을 찾는 것이 중요합니다. 일반적으로 테스트 피라미드 접근 방식을 따르는 것이 좋습니다:

  • 많은 단위 테스트 (기반)
  • 적당한 위젯 테스트 (중간)
  • 적은 통합 테스트 (상단)

이 접근 방식은 빠른 피드백 주기를 유지하면서 코드 품질을 보장하는 데 도움이 됩니다.

results matching ""

    No results matching ""