개발

Flutter Unit Test 작성하기: 기초부터 실전까지

효과적인 단위 테스트로 안정적인 코드 작성하기

2025년 9월 18일
35분 읽기
Flutter Unit Test 작성하기: 기초부터 실전까지

Unit Test란?

Unit Test는 코드의 가장 작은 단위를 테스트하는 것입니다. Flutter에서는 주로 함수, 메서드, 클래스를 대상으로 합니다. UI나 외부 시스템과 상관없이 순수한 로직만을 검증합니다.

Unit Test의 핵심은 격리(Isolation)입니다. 테스트 대상 코드만 실행하고, 다른 모든 의존성은 제거하거나 가짜로 대체합니다. 이렇게 하면 테스트가 빠르고 안정적이며, 실패했을 때 정확히 어디가 문제인지 알 수 있습니다.

테스트 환경 설정

1. 의존성 추가

Flutter 프로젝트를 생성하면 기본적으로 테스트 환경이 준비되어 있습니다. pubspec.yaml을 확인해보면:
yaml
dev_dependencies:
  flutter_test:
    sdk: flutter

  # 추가로 유용한 테스트 패키지들
  test: ^1.24.0
  mockito: ^5.4.0
  build_runner: ^2.4.0
flutter_test는 Flutter의 기본 테스트 프레임워크입니다. test 패키지의 모든 기능과 Flutter 특화 기능을 포함합니다.

2. 테스트 파일 구조

테스트 파일은 test/ 폴더에 위치합니다. 일반적으로 소스 코드의 구조를 그대로 따라갑니다:
text
lib/
  ├── models/
  │   └── product.dart
  ├── services/
  │   └── calculator.dart
  └── utils/
      └── validator.dart

test/
  ├── models/
  │   └── product_test.dart
  ├── services/
  │   └── calculator_test.dart
  └── utils/
      └── validator_test.dart
테스트 파일 이름은 대상 파일명에 _test를 붙입니다. 이 규칙을 따르면 IDE가 자동으로 테스트를 인식하고, 테스트 커버리지 도구도 올바르게 동작합니다.

첫 번째 Unit Test 작성하기

간단한 계산기 클래스를 만들고 테스트해봅시다.

테스트 대상 코드

Dart
// lib/services/calculator.dart

class Calculator {
  double add(double a, double b) {
    return a + b;
  }

  double subtract(double a, double b) {
    return a - b;
  }

  double multiply(double a, double b) {
    return a * b;
  }

  double divide(double a, double b) {
    if (b == 0) {
      throw ArgumentError('0으로 나눌 수 없습니다');
    }
    return a / b;
  }

  double calculateTax(double amount, double taxRate) {
    if (taxRate < 0 || taxRate > 1) {
      throw ArgumentError('세율은 0과 1 사이여야 합니다');
    }
    return amount * taxRate;
  }
}

테스트 코드

Dart
// test/services/calculator_test.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/services/calculator.dart';

void main() {
  // 테스트 그룹으로 관련 테스트들을 묶습니다
  group('Calculator', () {
    // 매 테스트마다 새로운 Calculator 인스턴스를 생성
    late Calculator calculator;

    setUp(() {
      calculator = Calculator();
    });

    group('add', () {
      test('두 양수를 더한다', () {
        final result = calculator.add(2, 3);
        expect(result, equals(5));
      });

      test('양수와 음수를 더한다', () {
        final result = calculator.add(5, -3);
        expect(result, equals(2));
      });

      test('소수점 수를 더한다', () {
        final result = calculator.add(1.5, 2.5);
        expect(result, equals(4.0));
      });
    });

    group('divide', () {
      test('정상적인 나눗셈', () {
        final result = calculator.divide(10, 2);
        expect(result, equals(5));
      });

      test('0으로 나누면 예외 발생', () {
        // 예외가 발생하는지 테스트
        expect(
          () => calculator.divide(10, 0),
          throwsArgumentError,
        );
      });

      test('0으로 나누면 특정 메시지와 함께 예외 발생', () {
        expect(
          () => calculator.divide(10, 0),
          throwsA(
            isA<ArgumentError>().having(
              (e) => e.message,
              'message',
              contains('0으로 나눌 수 없습니다'),
            ),
          ),
        );
      });
    });

    group('calculateTax', () {
      test('정상적인 세금 계산', () {
        final tax = calculator.calculateTax(1000, 0.1);
        expect(tax, equals(100));
      });

      test('세율이 음수면 예외 발생', () {
        expect(
          () => calculator.calculateTax(1000, -0.1),
          throwsArgumentError,
        );
      });

      test('세율이 100%를 초과하면 예외 발생', () {
        expect(
          () => calculator.calculateTax(1000, 1.5),
          throwsArgumentError,
        );
      });
    });
  });
}

테스트 구조와 구성 요소

1. group() - 테스트 그룹화

group()은 관련된 테스트들을 논리적으로 묶습니다. 테스트 결과를 읽기 쉽게 만들고, 특정 그룹만 실행할 수도 있습니다.
Dart
group('Feature A', () {
  group('Scenario 1', () {
    test('case 1', () { /* ... */ });
    test('case 2', () { /* ... */ });
  });

  group('Scenario 2', () {
    test('case 3', () { /* ... */ });
  });
});

2. setUp()과 tearDown()

setUp()은 각 테스트 실행 전에 호출되고, tearDown()은 각 테스트 실행 후에 호출됩니다. 테스트 환경을 준비하고 정리하는 데 사용합니다.
Dart
group('Database Test', () {
  late Database database;

  setUp(() async {
    // 각 테스트 전에 실행
    database = await openDatabase(':memory:');
    await database.createTables();
  });

  tearDown(() async {
    // 각 테스트 후에 실행
    await database.close();
  });

  test('데이터 삽입', () async {
    await database.insert('users', {'name': 'John'});
    final users = await database.query('users');
    expect(users.length, equals(1));
  });
});
setUpAll()과 tearDownAll()도 있습니다. 이들은 그룹 내 모든 테스트 실행 전후에 한 번씩만 호출됩니다.

3. expect() - 결과 검증

expect()는 테스트의 핵심입니다. 실제 값과 예상 값을 비교합니다.
Dart
// 기본 비교
expect(actual, equals(expected));
expect(actual, expected);  // equals()는 생략 가능

// 숫자 비교
expect(value, greaterThan(10));
expect(value, lessThanOrEqualTo(20));
expect(value, inInclusiveRange(10, 20));

// 컬렉션
expect(list, contains(item));
expect(list, containsAll([item1, item2]));
expect(list, hasLength(5));
expect(map, containsPair('key', 'value'));

// 타입 확인
expect(object, isA<String>());
expect(object, isInstanceOf<MyClass>());

// null 확인
expect(value, isNull);
expect(value, isNotNull);

// 예외 확인
expect(() => dangerousOperation(), throwsException);
expect(() => divideByZero(), throwsA(isA<ArgumentError>()));

비즈니스 로직 테스트하기

실제 앱에서 자주 사용되는 비즈니스 로직을 테스트해봅시다.

장바구니 클래스

Dart
// lib/models/shopping_cart.dart

class ShoppingCart {
  final List<CartItem> _items = [];
  final double _taxRate;
  final double _freeShippingThreshold;

  ShoppingCart({
    double taxRate = 0.1,
    double freeShippingThreshold = 50000,
  }) : _taxRate = taxRate,
       _freeShippingThreshold = freeShippingThreshold;

  List<CartItem> get items => List.unmodifiable(_items);

  void addItem(Product product, {int quantity = 1}) {
    if (quantity <= 0) {
      throw ArgumentError('수량은 1개 이상이어야 합니다');
    }

    final existingItemIndex = _items.indexWhere(
      (item) => item.product.id == product.id,
    );

    if (existingItemIndex != -1) {
      // 이미 있는 상품은 수량만 증가
      _items[existingItemIndex] = _items[existingItemIndex].copyWith(
        quantity: _items[existingItemIndex].quantity + quantity,
      );
    } else {
      // 새로운 상품 추가
      _items.add(CartItem(product: product, quantity: quantity));
    }
  }

  void removeItem(String productId) {
    _items.removeWhere((item) => item.product.id == productId);
  }

  void updateQuantity(String productId, int newQuantity) {
    if (newQuantity <= 0) {
      removeItem(productId);
      return;
    }

    final itemIndex = _items.indexWhere(
      (item) => item.product.id == productId,
    );

    if (itemIndex == -1) {
      throw StateError('상품을 찾을 수 없습니다: $productId');
    }

    _items[itemIndex] = _items[itemIndex].copyWith(
      quantity: newQuantity,
    );
  }

  void clear() {
    _items.clear();
  }

  double get subtotal {
    return _items.fold(
      0,
      (total, item) => total + (item.product.price * item.quantity),
    );
  }

  double get tax {
    return subtotal * _taxRate;
  }

  double get shippingFee {
    return subtotal >= _freeShippingThreshold ? 0 : 3000;
  }

  double get total {
    return subtotal + tax + shippingFee;
  }

  int get totalItemCount {
    return _items.fold(0, (sum, item) => sum + item.quantity);
  }

  bool get isEmpty => _items.isEmpty;
  bool get isNotEmpty => _items.isNotEmpty;
}

장바구니 테스트

Dart
// test/models/shopping_cart_test.dart

void main() {
  group('ShoppingCart', () {
    late ShoppingCart cart;
    late Product testProduct1;
    late Product testProduct2;

    setUp(() {
      cart = ShoppingCart(
        taxRate: 0.1,
        freeShippingThreshold: 30000,
      );

      testProduct1 = Product(
        id: 'prod-1',
        name: 'Laptop',
        price: 20000,
      );

      testProduct2 = Product(
        id: 'prod-2',
        name: 'Mouse',
        price: 5000,
      );
    });

    group('상품 추가', () {
      test('새 상품을 추가하면 장바구니에 들어간다', () {
        cart.addItem(testProduct1);

        expect(cart.items.length, equals(1));
        expect(cart.items.first.product.id, equals('prod-1'));
        expect(cart.items.first.quantity, equals(1));
      });

      test('같은 상품을 다시 추가하면 수량이 증가한다', () {
        cart.addItem(testProduct1, quantity: 2);
        cart.addItem(testProduct1, quantity: 3);

        expect(cart.items.length, equals(1));
        expect(cart.items.first.quantity, equals(5));
      });

      test('수량이 0 이하면 예외가 발생한다', () {
        expect(
          () => cart.addItem(testProduct1, quantity: 0),
          throwsArgumentError,
        );

        expect(
          () => cart.addItem(testProduct1, quantity: -1),
          throwsArgumentError,
        );
      });
    });

    group('수량 업데이트', () {
      setUp(() {
        cart.addItem(testProduct1, quantity: 3);
      });

      test('수량을 변경할 수 있다', () {
        cart.updateQuantity('prod-1', 5);

        expect(cart.items.first.quantity, equals(5));
      });

      test('수량을 0으로 하면 상품이 제거된다', () {
        cart.updateQuantity('prod-1', 0);

        expect(cart.isEmpty, isTrue);
      });

      test('존재하지 않는 상품의 수량 변경 시 예외 발생', () {
        expect(
          () => cart.updateQuantity('non-existent', 5),
          throwsStateError,
        );
      });
    });

    group('가격 계산', () {
      test('상품 소계가 올바르게 계산된다', () {
        cart.addItem(testProduct1, quantity: 2);  // 40000
        cart.addItem(testProduct2, quantity: 1);  // 5000

        expect(cart.subtotal, equals(45000));
      });

      test('세금이 올바르게 계산된다', () {
        cart.addItem(testProduct1, quantity: 1);  // 20000

        expect(cart.tax, equals(2000));  // 10%
      });

      test('무료 배송 임계값 이상이면 배송비가 0원이다', () {
        cart.addItem(testProduct1, quantity: 2);  // 40000

        expect(cart.shippingFee, equals(0));
      });

      test('무료 배송 임계값 미만이면 배송비가 3000원이다', () {
        cart.addItem(testProduct2, quantity: 1);  // 5000

        expect(cart.shippingFee, equals(3000));
      });

      test('총액이 올바르게 계산된다', () {
        cart.addItem(testProduct1, quantity: 1);  // 20000
        // 소계: 20000, 세금: 2000, 배송비: 3000

        expect(cart.total, equals(25000));
      });
    });

    group('장바구니 상태', () {
      test('초기 상태는 비어있다', () {
        expect(cart.isEmpty, isTrue);
        expect(cart.isNotEmpty, isFalse);
        expect(cart.totalItemCount, equals(0));
      });

      test('clear() 호출 시 모든 상품이 제거된다', () {
        cart.addItem(testProduct1);
        cart.addItem(testProduct2);

        cart.clear();

        expect(cart.isEmpty, isTrue);
        expect(cart.subtotal, equals(0));
      });

      test('총 아이템 개수가 올바르게 계산된다', () {
        cart.addItem(testProduct1, quantity: 3);
        cart.addItem(testProduct2, quantity: 2);

        expect(cart.totalItemCount, equals(5));
      });
    });
  });
}

테스트 실행하기

명령줄에서 실행

bash
# 모든 테스트 실행
flutter test

# 특정 파일만 테스트
flutter test test/services/calculator_test.dart

# 특정 이름을 포함하는 테스트만 실행
flutter test --name "add"

# 커버리지 리포트 생성
flutter test --coverage

IDE에서 실행

VS Code나 Android Studio에서는 테스트 파일이나 개별 테스트 옆에 실행 버튼이 표시됩니다. 클릭하면 해당 테스트만 실행할 수 있습니다.

테스트 작성 팁

1. 테스트 이름은 명확하게

테스트 이름은 무엇을 테스트하는지 명확히 설명해야 합니다. "동작한다"보다는 "세율이 10%일 때 1000원의 세금은 100원이다"가 좋습니다.

2. 하나의 테스트는 하나만 검증

한 테스트에서 여러 기능을 검증하면 실패했을 때 어떤 부분이 문제인지 파악하기 어렵습니다.
Dart
// ❌ 나쁜 예
test('장바구니 기능', () {
  cart.addItem(product);
  expect(cart.items.length, equals(1));

  cart.updateQuantity(product.id, 5);
  expect(cart.items.first.quantity, equals(5));

  cart.clear();
  expect(cart.isEmpty, isTrue);
});

// ✅ 좋은 예
test('상품을 추가하면 장바구니에 들어간다', () {
  cart.addItem(product);
  expect(cart.items.length, equals(1));
});

test('수량을 업데이트할 수 있다', () {
  cart.addItem(product);
  cart.updateQuantity(product.id, 5);
  expect(cart.items.first.quantity, equals(5));
});

test('clear()를 호출하면 장바구니가 비워진다', () {
  cart.addItem(product);
  cart.clear();
  expect(cart.isEmpty, isTrue);
});

3. 경계값 테스트

경계값에서 버그가 자주 발생합니다. 0, 1, 최댓값, 최솟값 등을 테스트하세요.
Dart
group('수량 검증', () {
  test('수량 0은 허용되지 않는다', () {
    expect(() => cart.addItem(product, quantity: 0), throwsArgumentError);
  });

  test('수량 1은 허용된다', () {
    cart.addItem(product, quantity: 1);
    expect(cart.items.first.quantity, equals(1));
  });

  test('최대 수량 99는 허용된다', () {
    cart.addItem(product, quantity: 99);
    expect(cart.items.first.quantity, equals(99));
  });

  test('수량 100은 허용되지 않는다', () {
    expect(() => cart.addItem(product, quantity: 100), throwsArgumentError);
  });
});

정리

Unit Test는 코드의 품질을 보장하는 첫 번째 방어선입니다. 작성하기도 쉽고 실행도 빠르기 때문에 가장 많이 작성해야 합니다.

좋은 Unit Test의 특징:

빠르다: 밀리초 단위로 실행
독립적이다: 다른 테스트나 외부 시스템에 의존하지 않음
반복 가능하다: 몇 번을 실행해도 같은 결과
자체 검증한다: 성공/실패가 명확함
적시에 작성된다: 코드와 함께 작성

Unit Test를 작성하는 습관을 들이면:

• 버그를 빨리 발견할 수 있습니다
• 코드를 더 모듈화하게 됩니다
• 리팩토링이 안전해집니다
• 코드의 의도가 명확해집니다

다음 편에서는 Mock과 Stub을 사용하여 외부 의존성이 있는 코드를 테스트하는 방법을 알아보겠습니다.
#Flutter
#Unit Test
#Testing
#TDD
#Dart
#Best Practices
#Testing Framework