본문 바로가기
Flutter/Study

플러터에서 단번에 이해하는 유닛 테스트&위젯테스트 완벽 가이드: 기초부터 모의객체까지

by Maccrey Coding 2025. 4. 30.
반응형

 

Flutter 개발자라면 누구나 알아야 할 유닛 테스트 노하우!

오늘은 Flutter 애플리케이션에서 품질을 보장하는 핵심 기술인 유닛 테스트 방법을 처음부터 끝까지 상세히 알아보겠습니다.

테스트 코드 작성이 어렵게 느껴지셨나요?

이 글을 읽고 나면 유닛 테스트가 얼마나 쉽고 강력한지 깨닫게 될 것입니다!

목차

  1. Flutter 유닛 테스트란?
  2. 테스트 환경 설정하기
  3. 첫 번째 테스트 작성하기
  4. 비동기 코드 테스트하기
  5. 위젯 테스트 작성법
  6. Mock 객체로 의존성 처리하기
  7. 테스트 커버리지 확인하기
  8. 실무에서의 테스트 전략
  9. 자주 발생하는 문제와 해결법

Flutter 유닛 테스트란?

유닛 테스트는 애플리케이션의 가장 작은 단위인 '함수'나 '메서드'가 예상대로 동작하는지 확인하는 테스트입니다.

Flutter에서는 test 패키지를 사용하여 비즈니스 로직을 테스트하고, flutter_test 패키지를 통해 위젯의 동작까지 검증할 수 있습니다.

 

유닛 테스트의 주요 이점

  • 버그를 조기에 발견하여 수정 비용 절감
  • 코드 리팩토링 시 기능 보장
  • 코드의 설계와 품질 향상
  • 기능 명세서 역할

테스트 환경 설정하기

Flutter 프로젝트에서 유닛 테스트를 시작하려면 먼저 필요한 패키지를 pubspec.yaml에 추가해야 합니다.

dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito: ^5.4.2
  build_runner: ^2.4.6

각 패키지의 역할

  • flutter_test: Flutter 공식 테스트 프레임워크
  • mockito: 목(mock) 객체 생성을 위한 라이브러리
  • build_runner: 코드 생성을 위한 도구

패키지를 추가한 후에는 다음 명령어로 설치합니다.

flutter pub get

첫 번째 테스트 작성하기

간단한 계산기 클래스를 예로 들어 테스트를 작성해 보겠습니다.

먼저, lib/calculator.dart 파일을 생성합니다.

class Calculator {
  /// 두 수를 더합니다.
  int add(int a, int b) {
    return a + b;
  }

  /// 두 수를 뺍니다.
  int subtract(int a, int b) {
    return a - b;
  }

  /// 두 수를 곱합니다.
  int multiply(int a, int b) {
    return a * b;
  }

  /// 두 수를 나눕니다.
  /// [b]가 0이면 예외를 발생시킵니다.
  double divide(int a, int b) {
    if (b == 0) {
      throw Exception('0으로 나눌 수 없습니다.');
    }
    return a / b;
  }
}

 

이제 test/calculator_test.dart 파일을 생성하여 테스트 코드를 작성합니다.

import 'package:flutter_test/flutter_test.dart';
import 'package:your_app_name/calculator.dart'; // 실제 프로젝트명으로 변경하세요

void main() {
  // 테스트에서 사용할 Calculator 인스턴스를 선언합니다.
  late Calculator calculator;

  // 각 테스트 전에 실행될 코드를 설정합니다.
  setUp(() {
    // 각 테스트마다 새로운 Calculator 객체를 생성합니다.
    calculator = Calculator();
  });

  // 'add' 메서드를 테스트하는 그룹입니다.
  group('Calculator의 add 메서드는', () {
    test('양수 두 개를 더했을 때 올바른 결과를 반환해야 한다', () {
      // 준비(Arrange) - 테스트에 필요한 데이터를 준비합니다.
      final a = 5;
      final b = 7;
      
      // 실행(Act) - 테스트할 함수를 호출합니다.
      final result = calculator.add(a, b);
      
      // 검증(Assert) - 예상 결과와 일치하는지 확인합니다.
      expect(result, 12);
    });

    test('음수를 더했을 때 올바른 결과를 반환해야 한다', () {
      expect(calculator.add(-3, 7), 4);
      expect(calculator.add(5, -8), -3);
      expect(calculator.add(-5, -7), -12);
    });
  });

  group('Calculator의 divide 메서드는', () {
    test('정상적인 나눗셈을 수행해야 한다', () {
      expect(calculator.divide(10, 2), 5.0);
    });

    test('0으로 나누면 예외를 발생시켜야 한다', () {
      // expect 함수의 두 번째 매개변수로 throwsException을 사용하여
      // 예외가 발생하는지 검사합니다.
      expect(() => calculator.divide(10, 0), throwsException);
    });
  });
}

테스트 코드 이해하기

위 테스트 코드를 한 줄씩 살펴보겠습니다

  1. import 구문: 필요한 패키지와 테스트할 클래스를 가져옵니다.
  2. main() 함수: 모든 테스트는 main() 함수 안에 작성합니다.
  3. setUp(): 각 테스트 실행 전에 준비 작업을 수행합니다.
  4. group(): 관련된 테스트들을 논리적으로 묶어줍니다.
  5. test(): 개별 테스트 케이스를 정의합니다.
  6. expect(): 실제 결과와 예상 결과를 비교합니다.

테스트 실행하기

터미널에서 다음 명령어로 테스트를 실행합니다.

flutter test

특정 테스트 파일만 실행하려면

flutter test test/calculator_test.dart

비동기 코드 테스트하기

Flutter 앱에서는 API 호출, 데이터베이스 작업 등 비동기 코드가 많이 사용됩니다. 이런 비동기 함수도 테스트할 수 있습니다.

예시를 위한 비동기 데이터 서비스

lib/data_service.dart

class DataService {
  /// API에서 사용자 데이터를 가져오는 것을 시뮬레이션하는 함수
  Future<Map<String, dynamic>> fetchUserData(String userId) async {
    // 실제로는 HTTP 요청을 보내겠지만, 여기서는 2초 지연 후 더미 데이터를 반환합니다
    await Future.delayed(Duration(seconds: 2));
    
    // 실패 케이스 테스트를 위한 조건
    if (userId == 'error') {
      throw Exception('사용자 데이터를 가져오는데 실패했습니다');
    }
    
    return {
      'id': userId,
      'name': '김플러터',
      'email': 'flutter@example.com',
      'age': 25
    };
  }

  /// 사용자 나이를 확인하여 성인 여부를 반환하는 함수
  Future<bool> isAdult(String userId) async {
    final userData = await fetchUserData(userId);
    return userData['age'] >= 18;
  }
}

비동기 함수 테스트 예시

lib/data_service_test.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:your_app_name/data_service.dart'; // 실제 프로젝트명으로 변경하세요

void main() {
  late DataService dataService;

  setUp(() {
    dataService = DataService();
  });

  group('DataService 클래스는', () {
    test('fetchUserData가 정상 사용자 ID로 호출되면 올바른 데이터를 반환해야 한다', () async {
      // async 키워드를 테스트 함수에 추가하여 비동기 테스트임을 표시합니다
      final userData = await dataService.fetchUserData('user123');
      
      expect(userData, isA<Map<String, dynamic>>());
      expect(userData['id'], 'user123');
      expect(userData['name'], '김플러터');
      expect(userData['email'], 'flutter@example.com');
      expect(userData['age'], 25);
    });

    test('fetchUserData가 "error" ID로 호출되면 예외를 발생시켜야 한다', () async {
      // 비동기 함수의 예외 테스트
      expect(() => dataService.fetchUserData('error'), throwsException);
    });
    
    test('isAdult 함수는 성인 사용자에 대해 true를 반환해야 한다', () async {
      final isAdult = await dataService.isAdult('user123');
      expect(isAdult, true);
    });
  });
}

비동기 테스트 포인트

  1. 테스트 함수에 async 키워드를 추가합니다.
  2. 비동기 함수 호출 시 await를 사용합니다.
  3. 예외 테스트는 함수 호출을 함수로 감싸서 expect와 함께 사용합니다.

위젯 테스트 작성법

이제 Flutter의 핵심인 위젯 테스트 방법을 알아보겠습니다. 버튼을 누르면 카운터가 증가하는 간단한 위젯을 테스트해 보겠습니다.
lib/count_widget.dart

import 'package:flutter/material.dart';

class CounterWidget extends StatefulWidget {
  const CounterWidget({Key? key}) : super(key: key);

  @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(
          '현재 카운트:',
        ),
        Text(
          '$_counter',
          key: Key('counter_value'), // 테스트에서 찾기 쉽도록 키 지정
          style: Theme.of(context).textTheme.headlineMedium,
        ),
        ElevatedButton(
          key: Key('increment_button'), // 테스트에서 찾기 쉽도록 키 지정
          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_name/counter_widget.dart'; // 실제 프로젝트명으로 변경하세요

void main() {
  group('CounterWidget', () {
    testWidgets('초기 카운터 값은 0이어야 한다', (WidgetTester tester) async {
      // 위젯을 화면에 그립니다
      await tester.pumpWidget(MaterialApp(
        home: Scaffold(
          body: CounterWidget(),
        ),
      ));

      // '0'이 표시되는지 확인합니다
      expect(find.text('0'), findsOneWidget);
      expect(find.text('1'), findsNothing);
    });

    testWidgets('버튼을 누르면 카운터가 증가해야 한다', (WidgetTester tester) async {
      // 위젯을 화면에 그립니다
      await tester.pumpWidget(MaterialApp(
        home: Scaffold(
          body: CounterWidget(),
        ),
      ));

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

      // 버튼을 찾아 탭합니다
      await tester.tap(find.byKey(Key('increment_button')));
      
      // 위젯을 다시 그려서 상태 변화를 반영합니다
      await tester.pump();

      // 카운터가 1로 증가했는지 확인합니다
      expect(find.text('1'), findsOneWidget);
      expect(find.text('0'), findsNothing);
    });
    
    testWidgets('버튼을 여러 번 누르면 카운터가 정확히 증가해야 한다', (WidgetTester tester) async {
      // 위젯을 화면에 그립니다
      await tester.pumpWidget(MaterialApp(
        home: Scaffold(
          body: CounterWidget(),
        ),
      ));

      // 버튼을 3번 탭합니다
      await tester.tap(find.byKey(Key('increment_button')));
      await tester.pump();
      await tester.tap(find.byKey(Key('increment_button')));
      await tester.pump();
      await tester.tap(find.byKey(Key('increment_button')));
      await tester.pump();

      // 카운터가 3으로 증가했는지 확인합니다
      expect(find.text('3'), findsOneWidget);
    });
  });
}

위젯 테스트 이해하기

위젯 테스트를 위한 핵심 개념들

  1. WidgetTester: 위젯과 상호작용할 수 있는 도구를 제공합니다.
  2. pumpWidget(): 위젯을 화면에 렌더링합니다.
  3. find: 위젯 트리에서 특정 위젯을 찾는 finder 객체를 생성합니다.
  4. tap(): 버튼 탭과 같은 사용자 상호작용을 시뮬레이션합니다.
  5. pump(): 위젯을 다시 그려 상태 변화를 반영합니다.

Mock 객체로 의존성 처리하기

실제 앱에서는 네트워크 요청, 데이터베이스 접근 등 외부 의존성이 있는 코드를 테스트해야 할 경우가 많습니다. 이런 경우 Mock 객체를 사용하여 의존성을 대체할 수 있습니다.

 

Mockito를 사용한 예제를 살펴보겠습니다.

lib/user_repository.dart

// 사용자 정보를 가져오는 인터페이스
abstract class UserRepository {
  Future<String> getUserName(String userId);
  Future<int> getUserAge(String userId);
}

// 실제 구현체 (API 호출 등을 담당)
class UserRepositoryImpl implements UserRepository {
  @override
  Future<String> getUserName(String userId) async {
    // 실제로는 API 호출 등의 로직이 있을 것입니다
    await Future.delayed(Duration(seconds: 1));
    return '김플러터';
  }

  @override
  Future<int> getUserAge(String userId) async {
    await Future.delayed(Duration(seconds: 1));
    return 25;
  }
}

// 사용자 정보를 처리하는 서비스
class UserService {
  final UserRepository _userRepository;

  UserService(this._userRepository);

  // 성인 사용자인지 확인하는 함수
  Future<bool> isAdult(String userId) async {
    final age = await _userRepository.getUserAge(userId);
    return age >= 18;
  }

  // 사용자 이름을 가져오는 함수
  Future<String> getFormattedUserName(String userId) async {
    final name = await _userRepository.getUserName(userId);
    return '회원: $name';
  }
}

 

Mockito를 사용하기 위한 설정

test/user_repository_test.mocks.dart

// 이 파일은 build_runner에 의해 자동생성됩니다.
// 실제 프로젝트에서는 아래 명령어를 실행하여 생성합니다:
// flutter pub run build_runner build

test/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_name/user_repository.dart'; // 실제 프로젝트명으로 변경하세요
import 'user_repository_test.mocks.dart';

// Mock 클래스 생성을 위한 어노테이션
@GenerateMocks([UserRepository])
void main() {
  late UserService userService;
  late MockUserRepository mockUserRepository;

  setUp(() {
    // MockUserRepository 인스턴스 생성
    mockUserRepository = MockUserRepository();
    // 생성된 Mock을 UserService에 주입
    userService = UserService(mockUserRepository);
  });

  group('UserService', () {
    test('isAdult는 18세 이상일 때 true를 반환해야 한다', () async {
      // Mock 객체의 동작 설정
      // getUserAge가 호출되면 20을 반환하도록 설정
      when(mockUserRepository.getUserAge(any)).thenAnswer((_) async => 20);

      // 테스트 실행
      final result = await userService.isAdult('user123');
      
      // 검증
      expect(result, true);
      
      // getUserAge 메서드가 정확히 한 번 호출되었는지 확인
      verify(mockUserRepository.getUserAge('user123')).called(1);
    });

    test('isAdult는 18세 미만일 때 false를 반환해야 한다', () async {
      // 17세를 반환하도록 설정
      when(mockUserRepository.getUserAge(any)).thenAnswer((_) async => 17);

      final result = await userService.isAdult('user123');
      
      expect(result, false);
    });

    test('getFormattedUserName은 올바른 형식으로 이름을 반환해야 한다', () async {
      // Mock 객체가 '홍길동'을 반환하도록 설정
      when(mockUserRepository.getUserName(any)).thenAnswer((_) async => '홍길동');

      final result = await userService.getFormattedUserName('user123');
      
      expect(result, '회원: 홍길동');
    });

    test('getUserAge가 예외를 발생시키면 isAdult도 예외를 전파해야 한다', () async {
      // 예외를 발생시키도록 설정
      when(mockUserRepository.getUserAge(any)).thenThrow(Exception('네트워크 오류'));

      // isAdult 호출 시 예외가 전파되는지 확인
      expect(() => userService.isAdult('user123'), throwsException);
    });
  });
}

Mockito 사용 이해하기

  1. @GenerateMocks 어노테이션: Mock 클래스를 자동 생성합니다.
  2. when().thenAnswer(): Mock 객체의 특정 메서드가 호출될 때 반환할 값을 설정합니다.
  3. verify(): Mock 객체의 특정 메서드가 호출되었는지 확인합니다.
  4. any: 어떤 인자가 전달되어도 매칭되는 매처(matcher)입니다.

실제 프로젝트에서는 Mock 클래스를 생성하기 위해 다음 명령어를 실행해야 합니다.

flutter pub run build_runner build

테스트 커버리지 확인하기

작성한 테스트가 코드의 어느 부분까지 검증하는지 확인하려면 커버리지 보고서를 생성할 수 있습니다.

flutter test --coverage

이 명령어를 실행하면 coverage/lcov.info 파일이 생성됩니다. 이 파일을 HTML 보고서로 변환하려면 LCOV를 설치하고 사용해야 합니다.

Linux나 macOS에서는

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

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

생성된 HTML 파일을 브라우저에서 열면 코드 커버리지를 시각적으로 확인할 수 있습니다.

실무에서의 테스트 전략

효과적인 테스트 전략을 위한 팁:

  1. 테스트 피라미드 적용:
    • 유닛 테스트(많음) → 위젯 테스트(중간) → 통합 테스트(적음)
  2. 중요 비즈니스 로직 우선 테스트:
    • 모든 코드를 테스트하기보다 중요한 로직부터 테스트합니다.
  3. TDD(Test-Driven Development) 고려:
    • 테스트를 먼저 작성하고 구현하는 방식으로 개발해 보세요.
  4. 테스트 가능한 코드 설계:
    • 의존성 주입을 활용하여 테스트하기 쉬운 구조로 설계합니다.
  5. CI/CD 파이프라인에 테스트 통합:
    • 테스트를 자동화하여 지속적으로 실행합니다.

자주 발생하는 문제와 해결법

1. 위젯 테스트에서 BuildContext 관련 오류

Errors on pumpWidget() is raised because your widget requires a MediaQuery ancestor.

해결책: MaterialApp으로 테스트 위젯을 감싸주세요.

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

2. 비동기 작업 후 UI 업데이트 테스트

해결책: pumpAndSettle()을 사용하여 모든 애니메이션과 비동기 작업이 완료될 때까지 기다립니다.

await tester.pumpAndSettle();

3. Mockito에서 Future 반환 메서드 모킹

해결책: thenAnswer를 사용하여 비동기 응답을 모킹합니다.

when(mock.asyncMethod()).thenAnswer((_) async => 'result');

 

Flutter에서의 유닛 테스트는 앱의 품질을 보장하는 필수적인 과정입니다.

이 글에서 배운 내용을 실제 프로젝트에 적용하면 버그를 줄이고, 리팩토링을 자신 있게 진행하며, 코드 품질을 높이는 데 큰 도움이 될 것입니다.테스트 코드 작성을 습관화하고, 점진적으로 테스트 커버리지를 높여가는 것이 중요합니다. 초기에는 시간이 더 소요되는 것처럼 느껴질 수 있지만, 장기적으로는 유지보수 비용을 크게 절감하고 안정적인 애플리케이션을 개발할 수 있습니다.

Flutter 유닛 테스트는 단순한 검증 과정이 아니라, 좋은 코드 설계를 유도하고 개발자의 자신감을 높여주는 강력한 도구입니다. 이 글에서 소개한 기법들을 활용하여 더 견고한 Flutter 앱을 만들어보세요!

 

통합 테스트 작성하기

지금까지 단위 테스트와 위젯 테스트에 대해 알아보았지만, 실제 앱에서는 여러 컴포넌트가 함께 작동하는 통합 테스트도 중요합니다. Flutter에서는 integration_test 패키지를 통해 이러한 통합 테스트를 지원합니다.

통합 테스트를 위한 패키지 설치

dev_dependencies:
  integration_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_name/main.dart'; // 실제 프로젝트의 main.dart 경로로 변경하세요

void main() {
  // 통합 테스트 바인딩 초기화
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('전체 앱 테스트', () {
    testWidgets('카운터 앱 테스트 - 버튼 누르면 카운터가 증가해야 함', (WidgetTester tester) async {
      // 실제 앱 실행
      await tester.pumpWidget(MyApp());
      await tester.pumpAndSettle();

      // 초기 상태 확인 - 앱이 초기 상태에서 카운터가 0을 표시하는지 확인
      expect(find.text('0'), findsOneWidget);

      // 플러스 버튼 찾기 (앱의 실제 구조에 맞게 수정 필요)
      final plusButton = find.byIcon(Icons.add);
      expect(plusButton, findsOneWidget);

      // 버튼 클릭
      await tester.tap(plusButton);
      await tester.pumpAndSettle();

      // 클릭 후 카운터가 1로 증가했는지 확인
      expect(find.text('1'), findsOneWidget);

      // 여러 번 클릭 테스트
      await tester.tap(plusButton);
      await tester.tap(plusButton);
      await tester.pumpAndSettle();

      // 총 3번 클릭 후 카운터가 3인지 확인
      expect(find.text('3'), findsOneWidget);
    });
  });
}

통합 테스트를 실행하려면

flutter test integration_test/app_test.dart

고급 테스트 기법

골든 테스트 (Golden Tests)

골든 테스트는 위젯의 시각적 모양을 검증하는 스냅샷 테스트입니다. UI 변경사항을 쉽게 감지할 수 있습니다.

test/golden_test.dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app_name/counter_widget.dart'; // 실제 프로젝트명으로 변경하세요

void main() {
  testWidgets('CounterWidget 골든 테스트', (WidgetTester tester) async {
    // 위젯 렌더링
    await tester.pumpWidget(
      MaterialApp(
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
          useMaterial3: true,
        ),
        home: Scaffold(
          body: Center(
            child: CounterWidget(),
          ),
        ),
      ),
    );

    // 골든 이미지와 비교
    await expectLater(
      find.byType(CounterWidget),
      matchesGoldenFile('counter_widget.png'),
    );
    
    // 버튼을 클릭하고 상태가 변경된 후의 골든 테스트
    await tester.tap(find.byKey(Key('increment_button')));
    await tester.pump();
    
    await expectLater(
      find.byType(CounterWidget),
      matchesGoldenFile('counter_widget_incremented.png'),
    );
  });
}

Finders와 Matchers 활용

Flutter 테스트 프레임워크는 다양한 Finder와 Matcher를 제공하여 UI 요소를 찾고 검증하는 작업을 용이하게 합니다.

test/advanced_finders_test.dart

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

void main() {
  testWidgets('다양한 Finder 사용법', (WidgetTester tester) async {
    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: Column(
            children: [
              Text('제목', key: Key('title')),
              Text('설명'),
              ElevatedButton(
                onPressed: () {},
                child: Text('버튼 1'),
              ),
              Container(
                child: ElevatedButton(
                  onPressed: () {},
                  child: Text('버튼 2'),
                ),
              )
            ],
          ),
        ),
      ),
    );

    // 텍스트로 찾기
    expect(find.text('제목'), findsOneWidget);
    expect(find.text('없는 텍스트'), findsNothing);
    
    // 키로 찾기
    expect(find.byKey(Key('title')), findsOneWidget);
    
    // 위젯 타입으로 찾기
    expect(find.byType(ElevatedButton), findsNWidgets(2));
    
    // 위젯의 상위/하위 요소 찾기
    expect(find.descendant(
      of: find.byType(Container),
      matching: find.byType(ElevatedButton),
    ), findsOneWidget);
    
    // 툴팁으로 찾기 (접근성 테스트에 유용)
    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: Tooltip(
            message: '도움말',
            child: Icon(Icons.help),
          ),
        ),
      ),
    );
    expect(find.byTooltip('도움말'), findsOneWidget);
    
    // 시맨틱 라벨로 찾기 (접근성 테스트)
    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: Semantics(
            label: '접근성 라벨',
            child: Container(),
          ),
        ),
      ),
    );
    expect(find.bySemanticsLabel('접근성 라벨'), findsOneWidget);
  });

  testWidgets('다양한 Matcher 사용법', (WidgetTester tester) async {
    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: Column(
            children: [
              Text('Flutter 테스트'),
              Container(
                width: 100,
                height: 100,
                color: Colors.blue,
              )
            ],
          ),
        ),
      ),
    );

    // 텍스트 매처
    expect(find.text('Flutter 테스트'), findsOneWidget);
    
    // 위젯 속성 검증
    final container = tester.widget<Container>(find.byType(Container));
    expect(container.constraints?.maxWidth, 100);
    expect(container.constraints?.maxHeight, 100);
    expect((container.decoration as BoxDecoration?)?.color, Colors.blue);
    
    // 여러 매처 조합
    expect(
      container,
      allOf([
        isA<Container>(),
        predicate((c) => (c as Container).color == null),
        predicate((c) => ((c as Container).decoration as BoxDecoration?)?.color == Colors.blue),
      ]),
    );
  });
}

테스트 그룹화와 설정

복잡한 테스트 스위트를 구성할 때는 setUp, setUpAll, tearDown, tearDownAll을 활용하여 코드 중복을 줄일 수 있습니다.

test/test_group_setup.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:your_app_name/calculator.dart'; // 실제 프로젝트명으로 변경하세요

void main() {
  group('Calculator 테스트 그룹', () {
    late Calculator calculator;
    
    // 각 테스트 전에 실행
    setUp(() {
      print('테스트 시작 전 셋업');
      calculator = Calculator();
    });
    
    // 각 테스트 후에 실행
    tearDown(() {
      print('테스트 종료 후 정리');
      // 리소스 정리 등의 작업
    });
    
    // 모든 테스트 시작 전에 한 번만 실행
    setUpAll(() {
      print('전체 테스트 그룹 시작 전 한 번만 실행');
      // 데이터베이스 연결 등 무거운 초기화 작업
    });
    
    // 모든 테스트 완료 후 한 번만 실행
    tearDownAll(() {
      print('전체 테스트 그룹 완료 후 한 번만 실행');
      // 데이터베이스 연결 종료 등 정리 작업
    });
    
    // 중첩된 그룹도 가능
    group('덧셈 테스트', () {
      test('양수 덧셈', () {
        expect(calculator.add(2, 3), 5);
      });
      
      test('음수 덧셈', () {
        expect(calculator.add(-2, -3), -5);
      });
    });
    
    group('나눗셈 테스트', () {
      test('정상 나눗셈', () {
        expect(calculator.divide(10, 2), 5.0);
      });
      
      test('0으로 나누기 예외 테스트', () {
        expect(() => calculator.divide(10, 0), throwsException);
      });
    });
  });
}

테스트 자동화와 CI/CD 통합

테스트를 지속적 통합(CI) 파이프라인에 통합하면 코드 품질을 지속적으로 유지할 수 있습니다. GitHub Actions를 사용한 예시를 살펴보겠습니다.

.github/workflows/flutter_test.yml

name: Flutter Test

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

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      
      # Flutter 설치
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.19.3'
          channel: 'stable'
      
      # 의존성 패키지 설치
      - name: Install dependencies
        run: flutter pub get
      
      # 정적 분석 실행
      - name: Analyze project source
        run: flutter analyze
      
      # 유닛 테스트 실행
      - name: Run tests
        run: flutter test --coverage
      
      # 테스트 커버리지 보고서 생성 및 업로드
      - name: Install lcov
        run: sudo apt-get install -y lcov
        
      - name: Generate coverage report
        run: genhtml coverage/lcov.info -o coverage/html
      
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          file: coverage/lcov.info

개발자로서 테스트 습관 들이기

Flutter 개발자로서 테스트 문화를 정착시키기 위한 팁:

  1. 작은 것부터 시작하기: 모든 코드를 테스트하려고 하지 말고, 중요한 로직부터 시작하세요.
  2. 리팩토링 전에 테스트 작성하기: 코드를 수정하기 전에 테스트를 작성하면 리팩토링 후에도 기능이 정상 작동하는지 확인할 수 있습니다.
  3. 실패 테스트 먼저 작성하기: TDD 방법론에 따라 실패하는 테스트를 먼저 작성한 후, 그 테스트를 통과하는 코드를 작성해보세요.
  4. 테스트 가능한 코드 설계하기: 단일 책임 원칙, 의존성 주입 등의 디자인 패턴을 활용하여 테스트하기 쉬운 코드를 작성하세요.
  5. 테스트 자동화하기: CI/CD 파이프라인에 테스트를 통합하여 자동으로 실행되게 하세요.

결론

Flutter에서의 유닛 테스트는 앱의 안정성과 품질을 보장하는 필수적인 과정입니다. 기본적인 유닛 테스트부터 위젯 테스트, Mock 객체 활용, 통합 테스트까지 다양한 테스트 방법을 익히고 적용한다면, 더욱 견고하고 유지보수하기 쉬운 Flutter 앱을 개발할 수 있습니다.

테스트는 단순히 버그를 찾는 도구가 아니라, 더 나은 코드 설계를 유도하고 개발자에게 자신감을 부여하는 강력한 수단입니다. 오늘부터 테스트 코드 작성을 습관화하여 더 전문적인 Flutter 개발자로 성장해보세요!

 

째깍째깍...흘러가는 시간 붙잡고 싶다면? 

Study Duck 학습 타이머 즉시 ON! 랭킹 경쟁 참여하고 학습 습관 만들 기회, 놓치지 마세요!

www.studyduck.net

반응형