Monday, July 3, 2023

코드 품질을 높이는 프론트엔드 테스트 전략

플러터 테스트: 기초부터 실전 TDD까지

서론: 왜 프론트엔드 테스트는 선택이 아닌 필수인가?

소프트웨어 개발의 세계에서 '테스트'라는 단어는 종종 프로젝트 마감일의 압박 속에서 가장 먼저 희생되는 비운의 존재로 여겨지곤 합니다. 특히 사용자 인터페이스(UI)와 사용자 경험(UX)이 비즈니스의 성패를 좌우하는 프론트엔드 개발에서 테스트는 번거롭고 시간이 많이 소요되는 작업으로 치부되기 쉽습니다. 하지만 현대의 복잡하고 동적인 애플리케이션 환경에서 자동화된 테스트는 더 이상 선택 사항이 아닌, 제품의 품질과 개발팀의 생산성을 보장하는 핵심적인 활동으로 자리 잡았습니다. 견고하고 신뢰할 수 있는 Flutter 애플리케이션을 구축하고자 한다면, 테스트는 그 무엇보다 견고한 초석이 되어줄 것입니다.

테스트의 부재는 눈덩이처럼 불어나는 기술 부채(Technical Debt)의 시작점입니다. 개발 초기 단계에서 발견된 버그는 수정 비용이 비교적 저렴하지만, 이 버그가 사용자의 손에까지 도달했을 때의 비용은 상상을 초월합니다. 이는 단순히 코드를 수정하는 시간을 넘어, 사용자의 신뢰 하락, 브랜드 이미지 손상, 잠재 고객 이탈과 같은 막대한 비즈니스 손실로 이어질 수 있습니다. 잘 작성된 테스트 코드는 이러한 위험을 사전에 방지하는 가장 효과적인 안전망입니다.

또한, 테스트는 개발의 속도와 유연성을 향상시키는 촉매제 역할을 합니다. 새로운 기능을 추가하거나 기존 코드를 리팩토링할 때, 잘 갖춰진 테스트 스위트(Test Suite)는 개발자에게 심리적 안정감을 제공합니다. 변경 사항으로 인해 기존 기능이 의도치 않게 손상되지 않았는지(회귀 버그, Regression Bug) 신속하게 확인할 수 있기 때문입니다. 이는 개발자가 더 과감하고 자신감 있게 코드를 개선하고 혁신할 수 있는 토양을 마련해주며, 결과적으로는 전체 개발 사이클을 단축시키는 효과를 가져옵니다. 특히 지속적 통합 및 지속적 배포(CI/CD) 파이프라인에 자동화된 테스트를 통합하는 것은 현대 DevOps 문화의 필수 요소로, 코드 변경 사항을 안정적으로 프로덕션 환경에 배포하는 과정을 보장합니다.

이 글에서는 Flutter 애플리케이션의 품질을 한 단계 끌어올리기 위한 다양한 테스트 전략을 심도 있게 다룰 것입니다. 단순히 테스트 코드를 작성하는 방법을 넘어, 각 테스트 유형의 목적과 역할을 이해하고, 이를 통해 어떻게 더 나은 소프트웨어 아키텍처를 설계할 수 있는지에 대한 통찰을 제공하고자 합니다. 코드의 가장 작은 단위부터 사용자의 전체 경험에 이르기까지, 체계적인 테스트를 통해 당신의 Flutter 앱을 더욱 견고하고 신뢰성 있게 만들어나가는 여정을 시작하겠습니다.

zxc

테스트 스펙트럼의 이해: 테스트 피라미드

모든 테스트가 동일한 목적과 비용 구조를 갖는 것은 아닙니다. 효과적인 테스트 전략을 수립하기 위해서는 '테스트 피라미드(Test Pyramid)'라는 개념을 이해하는 것이 중요합니다. 테스트 피라미드는 소프트웨어 테스트를 세 가지 주요 계층으로 나누어, 각 계층의 테스트가 차지해야 할 이상적인 비율과 특징을 시각적으로 표현한 모델입니다.

Test Pyramid

마틴 파울러가 제시한 테스트 피라미드 모델

  1. 유닛 테스트 (Unit Tests): 피라미드의 가장 넓은 기반을 차지하는 유닛 테스트는 가장 작고 독립적인 코드 단위(함수, 메서드, 클래스, 위젯 등)가 예상대로 동작하는지 검증합니다. 이 테스트들은 외부 의존성(네트워크, 데이터베이스, 파일 시스템 등)으로부터 완전히 격리된 상태에서 실행되므로 매우 빠르고 안정적입니다. 작성 비용이 낮고 실행 속도가 빨라 개발 과정에서 가장 빈번하게, 그리고 가장 많이 작성되어야 하는 테스트입니다.
  2. 통합 테스트 (Integration Tests): 피라미드의 중간 계층에 위치한 통합 테스트는 여러 개의 유닛(모듈, 컴포넌트, 서비스)이 함께 연동될 때 발생하는 문제를 검증합니다. 예를 들어, 특정 위젯이 상태 관리 로직과 올바르게 상호작용하는지, 또는 서비스 모듈이 데이터 저장소와 정확하게 데이터를 주고받는지 확인하는 것이 여기에 해당합니다. 유닛 테스트보다 더 넓은 범위를 다루기 때문에 실행 속도가 느리고 설정이 복잡하지만, 개별 단위에서는 발견할 수 없는 모듈 간의 상호작용 오류를 찾아내는 데 필수적입니다. Flutter에서는 위젯의 렌더링과 상호작용을 테스트하는 '위젯 테스트(Widget Test)'가 이 계층의 중요한 부분을 차지합니다.
  3. 엔드 투 엔드 테스트 (End-to-End Tests): 피라미드의 가장 좁은 최상층을 차지하는 E2E 테스트는 실제 사용자의 관점에서 전체 애플리케이션의 흐름을 시뮬레이션합니다. 사용자가 앱을 실행하고, 로그인하고, 특정 기능을 사용하고, 결과를 확인하는 전체 여정을 테스트합니다. 이 테스트는 프론트엔드, 백엔드, 데이터베이스 등 시스템의 모든 부분이 함께 동작하는 것을 검증하므로 가장 높은 신뢰도를 제공합니다. 하지만 실제 환경과 유사하게 구성해야 하므로 실행 속도가 매우 느리고, 작은 UI 변경에도 쉽게 깨지는(brittle) 경향이 있어 작성 및 유지보수 비용이 가장 높습니다. 따라서 전체 테스트 중 가장 적은 비율을 유지하는 것이 바람직합니다.

건강한 테스트 전략은 이 피라미드 구조를 따릅니다. 즉, 수백 개의 빠르고 안정적인 유닛 테스트가 코드의 기반을 다지고, 수십 개의 통합 테스트가 모듈 간의 협력을 보장하며, 소수의 핵심적인 E2E 테스트가 전체 시스템의 비즈니스 가치를 검증하는 형태입니다. 이 균형을 통해 최소한의 비용으로 최대한의 테스트 커버리지와 신뢰도를 확보할 수 있습니다.

1단계: 유닛 테스트 (Unit Test) - 코드의 초석 다지기

유닛 테스트는 Flutter 애플리케이션의 품질을 보장하는 가장 기본적인 단계입니다. 비즈니스 로직을 담고 있는 순수한 Dart 클래스, 상태를 관리하는 ViewModel이나 BLoC, 데이터를 처리하는 유틸리티 함수 등 앱의 '두뇌'에 해당하는 부분들이 정확하게 동작하는지를 개별적으로 검증합니다.

1.1 테스트 환경 설정

Flutter 프로젝트를 생성하면 기본적으로 테스트 환경이 갖춰져 있습니다. 가장 중요한 파일은 pubspec.yaml입니다. 여기서 테스트 관련 의존성을 관리합니다.


# ... 다른 의존성들
dependencies:
  flutter:
    sdk: flutter

# 개발 과정에서만 필요한 의존성들
dev_dependencies:
  flutter_test:
    sdk: flutter
  # Mocking을 위한 라이브러리 (추후 설명)
  mockito: ^5.4.4
  build_runner: ^2.4.8

# ...

주목할 점은 flutter_testdependencies가 아닌 dev_dependencies에 포함되어 있다는 것입니다. 이는 테스트 코드가 앱의 최종 빌드(릴리즈 버전)에는 포함되지 않는, 오직 개발 단계에서만 필요한 코드임을 의미합니다. 모든 테스트 파일은 프로젝트 루트의 test 디렉토리 아래에 위치해야 하며, _test.dart 접미사로 끝나는 파일명을 사용하는 것이 관례입니다(예: calculator_test.dart).

1.2 기본적인 유닛 테스트 작성

유닛 테스트의 구조는 매우 간단합니다. test 함수를 사용하여 개별 테스트 케이스를 정의하고, expect 함수를 사용하여 실제 결과값이 기대하는 값과 일치하는지 확인합니다.

예를 들어, 간단한 할인율 계산 로직을 테스트해 보겠습니다.


// lib/utils/discount_calculator.dart
class DiscountCalculator {
  double applyDiscount(double price, double discountPercentage) {
    if (price < 0 || discountPercentage < 0 || discountPercentage > 100) {
      throw ArgumentError('Invalid input');
    }
    return price * (1 - discountPercentage / 100);
  }
}

이 클래스를 테스트하는 코드는 다음과 같습니다.


// test/utils/discount_calculator_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/utils/discount_calculator.dart'; // 실제 프로젝트 경로에 맞게 수정

void main() {
  // 여러 테스트를 논리적인 그룹으로 묶기 위해 group 함수를 사용합니다.
  group('DiscountCalculator', () {
    // 테스트 간에 공유할 인스턴스를 선언합니다.
    late DiscountCalculator calculator;

    // 각 테스트가 실행되기 전에 호출되는 함수입니다.
    // 여기서 테스트에 필요한 객체를 초기화합니다.
    setUp(() {
      calculator = DiscountCalculator();
    });

    test('should apply discount correctly for valid inputs', () {
      // Act & Assert
      expect(calculator.applyDiscount(100, 20), 80.0);
      expect(calculator.applyDiscount(50, 10), 45.0);
      expect(calculator.applyDiscount(200, 0), 200.0);
    });

    test('should return zero when discount is 100%', () {
      // Act & Assert
      expect(calculator.applyDiscount(150, 100), 0.0);
    });

    // 예외(Exception) 발생을 테스트하는 방법입니다.
    test('should throw ArgumentError for negative price', () {
      // Act & Assert
      expect(() => calculator.applyDiscount(-100, 20), throwsArgumentError);
    });

    test('should throw ArgumentError for invalid discount percentage', () {
      // Act & Assert
      expect(() => calculator.applyDiscount(100, -10), throwsArgumentError);
      expect(() => calculator.applyDiscount(100, 101), throwsArgumentError);
    });
  });
}

위 예제에서는 group, setUp과 같은 고급 기능을 사용하여 테스트를 구조화했습니다. group은 연관된 테스트들을 묶어 가독성을 높여주며, setUp은 각 테스트 케이스가 실행되기 전에 반복적으로 수행해야 할 초기화 작업을 정의하여 코드 중복을 줄여줍니다. 또한 throwsA matcher(throwsArgumentError는 그 중 하나)를 사용하여 특정 조건에서 함수가 올바르게 예외를 발생시키는지를 검증하는 방법을 보여줍니다. 이는 정상적인 경우(Happy path)뿐만 아니라 예외적인 경우(Edge case)까지 철저히 테스트하는 것이 중요함을 보여줍니다.

1.3 핵심 개념: 의존성 분리를 위한 Mocking

현실의 코드는 위 예제처럼 간단하지 않습니다. 대부분의 클래스는 다른 클래스나 외부 서비스(API, 데이터베이스 등)에 의존합니다. 유닛 테스트의 핵심 원칙 중 하나는 '격리(Isolation)'입니다. 즉, 테스트 대상(System Under Test, SUT)을 그 의존성으로부터 분리하여 오직 SUT의 로직만을 순수하게 테스트해야 합니다. 이때 사용되는 기술이 바로 '모킹(Mocking)'입니다.

예를 들어, 날씨 정보를 API로부터 가져와 가공하는 WeatherService가 있다고 가정해 봅시다. 이 서비스는 실제 네트워크 통신을 하는 ApiClient에 의존합니다.


// lib/services/api_client.dart
class ApiClient {
  Future<Map<String, dynamic>> fetchWeather(String city) async {
    // 실제 HTTP 요청을 보내는 로직
    // ...
    // 이 부분은 유닛 테스트에서 실행되어서는 안 됩니다.
  }
}

// lib/services/weather_service.dart
class WeatherService {
  final ApiClient apiClient;

  WeatherService(this.apiClient);

  Future<String> getFormattedWeather(String city) async {
    try {
      final data = await apiClient.fetchWeather(city);
      final temp = data['main']['temp'];
      final description = data['weather'][0]['description'];
      return '현재 $city의 날씨는 $description, 온도는 ${temp}°C 입니다.';
    } catch (e) {
      return '날씨 정보를 가져오는 데 실패했습니다.';
    }
  }
}

WeatherService를 유닛 테스트하기 위해 실제 네트워크 요청을 보내는 것은 여러 문제를 야기합니다. 테스트가 느려지고, 네트워크 상태에 따라 결과가 달라지며, 외부 API의 상태에 테스트가 종속됩니다. 이 문제를 해결하기 위해 mockito와 같은 라이브러리를 사용하여 ApiClient의 '가짜(Mock)' 객체를 만듭니다.


// test/services/weather_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/api_client.dart';
import 'package:your_app/services/weather_service.dart';

// Mockito에게 MockApiClient 클래스를 생성하라고 지시합니다.
@GenerateMocks([ApiClient])
import 'weather_service_test.mocks.dart'; // 생성된 mock 파일 import

void main() {
  late MockApiClient mockApiClient;
  late WeatherService weatherService;

  setUp(() {
    mockApiClient = MockApiClient();
    weatherService = WeatherService(mockApiClient);
  });

  group('getFormattedWeather', () {
    test('should return formatted weather string on success', () async {
      // Arrange: mock 객체의 행동을 미리 정의합니다.
      // 'any'는 어떤 인자가 들어와도 이 값을 반환하라는 의미입니다.
      when(mockApiClient.fetchWeather(any)).thenAnswer(
        (_) async => {
          'main': {'temp': 25.0},
          'weather': [
            {'description': '맑음'}
          ]
        },
      );

      // Act: 테스트할 메서드를 호출합니다.
      final result = await weatherService.getFormattedWeather('서울');

      // Assert: 결과를 검증합니다.
      expect(result, '현재 서울의 날씨는 맑음, 온도는 25.0°C 입니다.');
      // mockApiClient의 fetchWeather 메서드가 '서울' 인자와 함께 정확히 1번 호출되었는지 검증합니다.
      verify(mockApiClient.fetchWeather('서울')).called(1);
    });

    test('should return error message on failure', () async {
      // Arrange: mock 객체가 예외를 발생시키도록 정의합니다.
      when(mockApiClient.fetchWeather(any)).thenThrow(Exception('Network error'));

      // Act
      final result = await weatherService.getFormattedWeather('서울');

      // Assert
      expect(result, '날씨 정보를 가져오는 데 실패했습니다.');
    });
  });
}

위 테스트 코드를 실행하기 전에, 터미널에서 flutter pub run build_runner build 명령을 실행하여 weather_service_test.mocks.dart 파일을 생성해야 합니다. 이 파일에는 MockApiClient 클래스가 자동으로 정의됩니다.

이처럼 Mocking을 통해 WeatherService는 실제 ApiClient 대신 우리가 완벽하게 통제하는 가짜 객체와 상호작용합니다. 이를 통해 네트워크 상태와 관계없이 WeatherService의 데이터 가공 및 예외 처리 로직이 올바른지를 안정적으로 테스트할 수 있습니다.

2단계: 통합 테스트 (Integration Test) - 위젯의 상호작용 검증

유닛 테스트가 앱의 논리적인 부분을 검증했다면, 통합 테스트는 사용자가 직접 마주하는 UI, 즉 위젯들이 예상대로 렌더링되고 상호작용하는지를 검증합니다. Flutter에서 이 역할은 주로 '위젯 테스트(Widget Test)'가 담당합니다. 위젯 테스트는 실제 디바이스나 에뮬레이터 없이 가상 환경에서 위젯 트리를 빌드하고 테스트하므로, E2E 테스트보다 훨씬 빠르고 효율적입니다.

2.1 WidgetTester API 심층 분석

위젯 테스트의 핵심은 WidgetTester 객체입니다. 이 객체는 테스트 환경에서 위젯과 상호작용할 수 있는 다양한 메서드를 제공합니다.

  • pumpWidget(Widget widget): 테스트할 최상위 위젯을 화면에 렌더링(빌드)합니다. 테스트의 시작점에서 단 한 번 호출됩니다.
  • pump([Duration duration]): 지정된 시간만큼 애니메이션 프레임을 진행시킵니다. setState 호출 후 UI가 업데이트되기를 기다리거나, 짧은 애니메이션(e.g., FadeIn)이 완료되기를 기다릴 때 사용합니다.
  • pumpAndSettle(): 모든 애니메이션이 끝날 때까지 반복적으로 pump를 호출합니다. 화면 전환이나 복잡한 애니메이션이 완전히 완료된 상태를 테스트할 때 유용합니다.

2.2 Finder를 활용한 위젯 탐색

위젯과 상호작용하거나 상태를 검증하려면, 먼저 위젯 트리에서 원하는 위젯을 찾아야 합니다. 이때 사용되는 것이 Finder입니다.

  • find.text('Hello'): 'Hello'라는 텍스트를 가진 Text 위젯을 찾습니다.
  • find.byKey(Key('counter')): 특정 Key를 가진 위젯을 찾습니다. 테스트 코드에서 위젯을 명확하게 식별하는 가장 안정적인 방법입니다.
  • find.byType(ElevatedButton): 특정 타입의 위젯을 모두 찾습니다.
  • find.byIcon(Icons.add): 특정 아이콘을 가진 Icon 위젯을 찾습니다.
  • find.descendant() / find.ancestor(): 특정 위젯의 자손 또는 조상 위젯을 찾는 데 사용됩니다.

Finder로 위젯을 찾은 후에는 Matcher를 사용하여 상태를 검증합니다.

  • findsOneWidget: 정확히 하나의 위젯을 찾았는지 확인합니다.
  • findsNothing: 위젯을 찾지 못했는지 확인합니다.
  • findsNWidgets(n): 정확히 n개의 위젯을 찾았는지 확인합니다.

2.3 복잡한 시나리오 예제: 로그인 폼(Form) 위젯 테스트

이메일과 비밀번호를 입력하고, 유효성 검사를 수행한 뒤, 로그인 버튼을 누르는 간단한 로그인 폼을 테스트하는 예제를 통해 위젯 테스트의 실제 활용법을 살펴보겠습니다.


// lib/widgets/login_form.dart (테스트 대상 위젯)
import 'package:flutter/material.dart';

class LoginForm extends StatefulWidget {
  final Future<void> Function(String email, String password) onLogin;

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

  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();
  String _email = '';
  String _password = '';
  bool _isLoading = false;

  void _submit() async {
    if (_formKey.currentState!.validate()) {
      _formKey.currentState!.save();
      setState(() {
        _isLoading = true;
      });
      await widget.onLogin(_email, _password);
      if (mounted) {
        setState(() {
          _isLoading = false;
        });
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Form(
          key: _formKey,
          child: Column(
            children: [
              TextFormField(
                key: const Key('email_field'),
                decoration: const InputDecoration(labelText: 'Email'),
                validator: (value) => value!.isEmpty ? 'Email is required' : null,
                onSaved: (value) => _email = value!,
              ),
              TextFormField(
                key: const Key('password_field'),
                decoration: const InputDecoration(labelText: 'Password'),
                obscureText: true,
                validator: (value) => value!.length < 6 ? 'Password too short' : null,
                onSaved: (value) => _password = value!,
              ),
              ElevatedButton(
                onPressed: _isLoading ? null : _submit,
                child: _isLoading ? const CircularProgressIndicator() : const Text('Login'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

// 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 validation and submission', (WidgetTester tester) async {
    String? loggedInEmail;
    String? loggedInPassword;

    // Arrange: 테스트할 위젯을 빌드합니다.
    await tester.pumpWidget(LoginForm(
      onLogin: (email, password) async {
        loggedInEmail = email;
        loggedInPassword = password;
      },
    ));

    // 1. 초기 상태 검증
    expect(find.byKey(const Key('email_field')), findsOneWidget);
    expect(find.byKey(const Key('password_field')), findsOneWidget);
    expect(find.text('Login'), findsOneWidget);

    // 2. 빈 폼 제출 시 validation 에러 메시지 확인
    await tester.tap(find.byType(ElevatedButton));
    await tester.pump(); // setState() 호출 후 UI 업데이트를 위해 pump 호출

    expect(find.text('Email is required'), findsOneWidget);
    expect(find.text('Password too short'), findsOneWidget);
    expect(loggedInEmail, isNull); // onLogin 콜백은 호출되지 않아야 합니다.

    // 3. 유효한 데이터 입력
    final emailField = find.byKey(const Key('email_field'));
    final passwordField = find.byKey(const Key('password_field'));
    
    await tester.enterText(emailField, 'test@example.com');
    await tester.enterText(passwordField, 'password123');
    await tester.pump();

    // 4. 유효한 폼 제출
    await tester.tap(find.byType(ElevatedButton));
    await tester.pump(); // 로딩 상태 시작을 위해 pump

    // 로딩 인디케이터가 보이는지 확인
    expect(find.byType(CircularProgressIndicator), findsOneWidget);

    // 비동기 작업(onLogin)이 완료될 때까지 기다립니다.
    await tester.pumpAndSettle();

    // 로딩이 끝나고 버튼이 다시 활성화되었는지 확인
    expect(find.byType(CircularProgressIndicator), findsNothing);
    expect(find.text('Login'), findsOneWidget);

    // onLogin 콜백이 올바른 데이터로 호출되었는지 확인
    expect(loggedInEmail, 'test@example.com');
    expect(loggedInPassword, 'password123');
  });
}

이 테스트는 사용자의 행동(버튼 탭, 텍스트 입력)을 tap, enterText와 같은 메서드로 시뮬레이션하고, 그에 따른 UI의 변화(에러 메시지 표시, 로딩 인디케이터 표시 등)와 내부 로직의 호출(onLogin 콜백)을 정확하게 검증하고 있습니다. 이처럼 위젯 테스트는 UI 컴포넌트의 동작을 완벽하게 문서화하고 안정성을 보장하는 강력한 도구입니다.

3단계: 엔드 투 엔드 테스트 (E2E Test) - 사용자 여정의 완성

E2E 테스트는 테스트 피라미드의 정점에 위치하며, 애플리케이션 전체를 사용자의 관점에서 검증합니다. 개별 위젯이나 로직이 아닌, 여러 화면과 서비스가 유기적으로 연동되어 하나의 완전한 사용자 시나리오를 완성하는지를 확인하는 것이 목적입니다. Flutter에서는 integration_test 패키지를 사용하여 E2E 테스트를 작성하며, 이 테스트는 실제 디바이스나 에뮬레이터에서 앱을 실행하여 진행됩니다.

3.1 E2E 테스트 환경 구축

먼저 pubspec.yamlintegration_test 의존성을 추가해야 합니다.


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

E2E 테스트 코드는 프로젝트 루트에 integration_test 라는 별도의 디렉토리를 만들어 그 안에 작성합니다. 이는 일반적인 위젯 테스트와 구분하기 위함입니다.

3.2 실전 E2E 시나리오 작성

상품 목록을 보여주고, 특정 상품을 클릭하면 상세 화면으로 이동하여 '장바구니 담기' 버튼을 누르는 시나리오를 E2E 테스트로 작성해 보겠습니다. 이 테스트는 화면 전환, 비동기 데이터 로딩, 상태 변화 등 여러 요소가 결합된 복합적인 흐름을 검증합니다.


// integration_test/app_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; // 앱의 main 진입점

void main() {
  // integration_test를 실행하기 위한 필수 초기화 코드
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('E2E App Flow Test', () {
    testWidgets('Full user journey from product list to cart', (WidgetTester tester) async {
      // 1. 앱 실행
      app.main();
      // 앱이 완전히 로드되고 첫 프레임이 렌더링될 때까지 기다립니다.
      await tester.pumpAndSettle();

      // 2. 초기 화면(상품 목록) 검증
      // 데이터 로딩을 위해 CircularProgressIndicator가 보이는지 확인
      expect(find.byType(CircularProgressIndicator), findsOneWidget);

      // 데이터 로딩이 완료될 때까지 기다립니다. (실제 앱에서는 API 호출이 완료될 때까지 기다려야 함)
      await tester.pumpAndSettle(const Duration(seconds: 3)); // 예시: 3초 대기

      // 상품 목록이 정상적으로 표시되는지 확인
      expect(find.text('Product 1'), findsOneWidget);
      expect(find.text('Product 2'), findsOneWidget);

      // 3. 상품 상세 화면으로 이동
      // 첫 번째 상품을 탭합니다.
      await tester.tap(find.text('Product 1'));
      // 화면 전환 애니메이션이 끝날 때까지 기다립니다.
      await tester.pumpAndSettle();

      // 4. 상품 상세 화면 검증
      expect(find.text('Product 1 Details'), findsOneWidget); // 상세 화면의 제목
      final addToCartButton = find.byKey(const Key('add_to_cart_button'));
      expect(addToCartButton, findsOneWidget);

      // 5. 장바구니 담기 액션 수행 및 결과 검증
      await tester.tap(addToCartButton);
      await tester.pumpAndSettle();

      // 장바구니에 상품이 담겼다는 스낵바나 메시지가 표시되는지 확인
      expect(find.text('Product 1 has been added to your cart.'), findsOneWidget);

      // 6. 뒤로 가기 및 이전 화면 상태 확인
      // 앱 바의 뒤로 가기 버튼을 찾아서 탭합니다.
      await tester.tap(find.byTooltip('Back'));
      await tester.pumpAndSettle();

      // 다시 상품 목록 화면으로 돌아왔는지 확인
      expect(find.text('Product 1'), findsOneWidget);
    });
  });
}

3.3 E2E 테스트 실행 및 고려사항

작성된 E2E 테스트는 터미널에서 다음 명령어로 실행합니다.


flutter test integration_test/app_flow_test.dart

이 명령은 연결된 실제 디바이스나 실행 중인 에뮬레이터에 앱을 설치하고 테스트 스크립트를 실행합니다. E2E 테스트는 매우 강력하지만, 다음과 같은 함정을 인지해야 합니다:

  • 불안정성 (Flakiness): 네트워크 지연, 예기치 않은 시스템 팝업, 애니메이션 타이밍 등 외부 요인으로 인해 동일한 테스트가 성공과 실패를 반복할 수 있습니다. pumpAndSettle을 적절히 사용하고, 명시적인 대기(Future.delayed)는 가급적 피하여 테스트의 안정성을 높여야 합니다.
  • 유지보수 비용: UI 디자인이나 사용자 흐름이 조금만 변경되어도 E2E 테스트는 쉽게 실패합니다. 따라서 가장 핵심적이고 비즈니스적으로 중요한 시나리오에 대해서만 E2E 테스트를 작성하고, 나머지는 유닛 테스트와 위젯 테스트로 커버하는 것이 효율적입니다.

개발 문화의 전환: 테스트 주도 개발 (TDD)

테스트 주도 개발(Test-Driven Development, TDD)은 단순히 테스트 코드를 작성하는 행위를 넘어선, 소프트웨어 설계 및 개발 방법론입니다. TDD의 핵심은 프로덕션 코드를 작성하기 전에, 실패하는 자동화된 테스트 케이스를 먼저 작성하는 것입니다. 이 과정은 'Red-Green-Refactor'라는 짧은 사이클을 통해 진행됩니다.

  1. Red: 구현되지 않은 기능에 대한 실패하는 테스트(Red)를 작성합니다. 이 단계는 구현할 기능의 요구사항을 명확하게 정의하는 과정입니다.
  2. Green: 방금 작성한 테스트를 통과시키는 가장 간단하고 빠른 방법으로 프로덕션 코드(Green)를 작성합니다. 이 단계에서는 코드의 품질이나 구조보다는 오직 테스트를 통과시키는 데에만 집중합니다.
  3. Refactor: 테스트가 통과하는 것을 확인한 후, 코드의 중복을 제거하고 가독성을 높이며 구조를 개선하는 리팩토링을 진행합니다. 이 과정에서 기존 테스트 스위트가 코드의 안정성을 보장해주는 안전망 역할을 합니다.

4.1 Flutter에서의 TDD 실전 예제: Counter BLoC 개발

간단한 카운터 앱의 상태를 관리하는 BLoC(Business Logic Component)를 TDD 방식으로 개발해 보겠습니다.

1단계: Red - 실패하는 테스트 작성

먼저 BLoC이 `Increment` 이벤트를 받았을 때 상태(state)를 1 증가시켜야 한다는 요구사항을 테스트로 표현합니다.


// test/bloc/counter_bloc_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/bloc/counter_bloc.dart'; // 아직 존재하지 않음

void main() {
  group('CounterBloc', () {
    // bloc_test 라이브러리를 사용한 BLoC 테스트
    blocTest<CounterBloc, int>(
      'emits [1] when CounterIncrementPressed is added.',
      build: () => CounterBloc(), // CounterBloc 생성
      act: (bloc) => bloc.add(CounterIncrementPressed()), // 이벤트 추가
      expect: () => [1], // 기대되는 상태 변화
    );
  });
}

이 코드를 실행하면 CounterBlocCounterIncrementPressed가 존재하지 않으므로 컴파일 에러(Red)가 발생합니다.

2단계: Green - 테스트를 통과하는 최소한의 코드 작성

이제 컴파일 에러를 해결하고 테스트를 통과시킬 최소한의 코드를 작성합니다.


// lib/bloc/counter_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';

// Events
abstract class CounterEvent {}
class CounterIncrementPressed extends CounterEvent {}

// BLoC
class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) { // 초기 상태는 0
    on<CounterIncrementPressed>((event, emit) {
      emit(state + 1); // 상태를 1 증가시켜 emit
    });
  }
}

이제 다시 테스트를 실행하면 성공(Green)하는 것을 볼 수 있습니다.

3단계: Refactor - 코드 개선

현재 코드는 매우 간단하여 리팩토링할 부분이 거의 없습니다. 하지만 만약 로직이 복잡해졌다면, 이 단계에서 가독성을 높이거나 중복을 제거하는 등의 작업을 수행할 수 있습니다. 예를 들어, 이벤트 핸들러 로직을 별도의 private 메서드로 분리할 수 있습니다.

이어서 `Decrement` 기능에 대한 테스트를 추가하고 다시 Red-Green-Refactor 사이클을 반복하며 기능을 점진적으로 완성해 나갑니다. TDD는 이처럼 테스트를 통해 요구사항을 명확히 하고, 동작하는 코드를 기반으로 설계를 점진적으로 개선해 나가는 강력한 개발 방식입니다. 이는 자연스럽게 테스트 커버리지를 높여줄 뿐만 아니라, 각 컴포넌트가 단일 책임 원칙(Single Responsibility Principle)을 따르고 의존성이 낮아지도록 유도하여 유지보수하기 좋은 코드를 만드는 데 기여합니다.

결론: 지속 가능한 성장을 위한 테스트 전략

지금까지 Flutter 애플리케이션의 품질을 보장하기 위한 다양한 테스트 기법들을 살펴보았습니다. 유닛 테스트로 코드의 기초를 다지고, 위젯 테스트로 컴포넌트 간의 상호작용을 검증하며, E2E 테스트로 전체 사용자 경험을 보장하는 테스트 피라미드 전략은 안정적이고 확장 가능한 앱을 만드는 데 필수적입니다.

성공적인 테스트 전략은 단순히 많은 테스트 코드를 작성하는 것에서 그치지 않습니다. 각 테스트 계층의 역할과 비용을 이해하고, 프로젝트의 특성에 맞게 균형 잡힌 테스트 포트폴리오를 구성해야 합니다. 모든 것을 E2E 테스트로 해결하려는 '아이스크림 콘(Ice Cream Cone)' 안티 패턴을 피하고, 빠르고 안정적인 유닛 테스트를 기반으로 견고한 테스트 피라미드를 구축하는 것이 중요합니다.

궁극적으로 테스트는 버그를 찾는 활동을 넘어, 더 나은 소프트웨어를 설계하고, 개발자 간의 협업을 원활하게 하며, 변화에 자신감 있게 대응할 수 있도록 돕는 개발 문화의 일부입니다. 테스트를 CI/CD 파이프라인에 통합하여 모든 코드 변경이 자동으로 검증되도록 만들고, TDD와 같은 방법론을 통해 테스트를 개발 프로세스의 중심으로 가져오십시오. 품질은 특정 단계에서 챙기는 것이 아니라, 개발의 모든 과정에 스며들어 있어야 하는 핵심 가치이기 때문입니다. 이러한 노력을 통해 당신의 Flutter 앱은 사용자의 사랑을 받는 성공적인 제품으로 성장해 나갈 것입니다.


0 개의 댓글:

Post a Comment