단위 테스트는 코드의 개별 함수나 메서드를 독립적으로 검증하는 테스트 방식이다. 이를 통해 코드가 의도한 대로 동작하는지 확인하고, 버그를 초기에 발견하여 수정할 수 있다. Dart 언어에서 단위 테스트를 작성하는 방법은 비교적 간단하며, test 패키지를 사용하여 수행할 수 있다.

테스트의 기본 구성

단위 테스트는 일반적으로 다음의 세 가지 단계로 이루어진다: 1. 설정(Setup): 테스트가 실행되기 전에 필요한 데이터를 설정한다. 2. 실행(Execute): 테스트 대상인 코드를 실행한다. 3. 검증(Assert): 실행된 결과가 예상 결과와 일치하는지 확인한다.

Dart에서는 test 패키지를 사용하여 단위 테스트를 작성할 수 있다. 이 패키지를 통해 여러 개의 테스트를 그룹화하고, 테스트 간의 공통 설정을 할 수 있다.

import 'package:test/test.dart';

void main() {
  test('더하기 함수 테스트', () {
    var result = add(2, 3);
    expect(result, equals(5));
  });
}

int add(int a, int b) {
  return a + b;
}

위 코드에서 test() 함수는 개별 테스트 케이스를 나타내며, expect() 함수는 결과 값이 예상 값과 일치하는지를 검증하는 함수이다.

테스트 작성 시 고려 사항

단위 테스트를 작성할 때 다음의 사항들을 고려하는 것이 좋다.

1. 독립성

단위 테스트는 독립적이어야 한다. 즉, 각 테스트는 다른 테스트에 의존하지 않고 개별적으로 실행될 수 있어야 한다. 이를 위해 각 테스트 케이스는 필요한 설정과 정리 작업을 스스로 처리해야 한다.

setUp(() {
  // 테스트 실행 전에 필요한 설정
});

tearDown(() {
  // 테스트 실행 후에 필요한 정리 작업
});

2. 경계 조건 테스트

경계 조건은 소프트웨어 테스트에서 중요한 부분이다. 각 함수나 메서드의 입력이 가장 작은 값과 가장 큰 값을 처리할 수 있는지 테스트해야 한다. 예를 들어, add() 함수는 0과 음수 값에서도 정상적으로 동작해야 한다.

test('경계 조건 테스트: 음수 값', () {
  var result = add(-1, -1);
  expect(result, equals(-2));
});

3. 예외 처리 테스트

테스트 대상 함수가 예외를 발생시키는 경우도 테스트해야 한다. 예를 들어, 나누기 연산에서는 0으로 나누기를 처리하는 로직이 필요하다.

test('예외 테스트', () {
  expect(() => divide(10, 0), throwsA(isA<Exception>()));
});

매개변수화된 테스트

때로는 동일한 함수에 대해 여러 다른 입력 값을 테스트해야 할 때가 있다. 이 경우 매개변수화된 테스트를 사용할 수 있다. 매개변수화된 테스트는 동일한 코드를 반복하지 않고 여러 값을 검증할 수 있어 효율적이다.

import 'package:test/test.dart';

void main() {
  group('더하기 함수 테스트', () {
    var testCases = [
      [1, 2, 3],
      [2, 3, 5],
      [-1, 1, 0],
      [0, 0, 0]
    ];

    for (var testCase in testCases) {
      test('add(${testCase[0]},${testCase[1]}) == ${testCase[2]}', () {
        var result = add(testCase[0], testCase[1]);
        expect(result, equals(testCase[2]));
      });
    }
  });
}

int add(int a, int b) => a + b;

이 방식은 테스트 데이터를 배열로 저장하고 이를 기반으로 여러 테스트 케이스를 반복해서 실행한다.

테스트 그룹화

단위 테스트는 여러 개의 테스트 케이스로 이루어질 수 있다. 이를 관리하고 더 구조화된 테스트를 작성하기 위해, Dart의 group 함수를 사용할 수 있다. 이 함수는 관련된 테스트 케이스들을 하나의 그룹으로 묶어주는 역할을 한다.

import 'package:test/test.dart';

void main() {
  group('더하기 함수 테스트', () {
    test('양수 값 테스트', () {
      expect(add(2, 3), equals(5));
    });

    test('음수 값 테스트', () {
      expect(add(-2, -3), equals(-5));
    });
  });
}

int add(int a, int b) => a + b;

위 코드에서 group 함수는 "더하기 함수 테스트"라는 그룹으로 두 개의 테스트 케이스를 묶어준다. 이를 통해 테스트의 가독성을 높이고, 테스트 그룹별로 실행 결과를 확인할 수 있다.

테스트 실행 및 결과 분석

Dart에서 테스트를 실행하려면 터미널에서 다음 명령을 사용할 수 있다.

dart test

테스트가 성공하면, 각 테스트 케이스가 통과되었음을 알려주며, 실패한 경우에는 그 이유와 함께 실패한 테스트 케이스를 보여준다. 이를 통해 디버깅과 수정이 용이해진다.

예시로, 테스트 결과가 다음과 같이 표시될 수 있다:

00:01 +2: All tests passed!

여기서 +2는 두 개의 테스트가 성공했음을 의미한다.

Mocking(모의 객체)

테스트를 작성할 때, 의존성 있는 객체나 서비스가 있을 경우 이를 테스트하기 어려울 수 있다. 이때 사용하는 것이 모의 객체(Mock)이다. Mock은 실제 객체처럼 동작하지만, 테스트에 필요한 상황만을 처리하는 가짜 객체이다. Dart에서는 mockito 패키지를 사용하여 모의 객체를 생성할 수 있다.

import 'package:mockito/mockito.dart';
import 'package:test/test.dart';

class MockService extends Mock implements RealService {}

void main() {
  test('모의 객체 사용 예시', () {
    var service = MockService();
    when(service.getData()).thenReturn('mocked data');

    var result = service.getData();
    expect(result, equals('mocked data'));
  });
}

위 예시에서는 MockService가 실제 RealService 대신 사용된다. when을 통해 모의 객체의 동작을 지정할 수 있으며, thenReturn으로 반환 값을 설정할 수 있다.

비동기 테스트

Dart에서는 비동기 코드를 많이 사용하기 때문에, 비동기 함수도 단위 테스트의 대상이 된다. 비동기 테스트는 asyncawait를 사용하여 작성할 수 있다.

import 'package:test/test.dart';

void main() {
  test('비동기 함수 테스트', () async {
    var result = await fetchData();
    expect(result, equals('fetched data'));
  });
}

Future<String> fetchData() async {
  await Future.delayed(Duration(seconds: 1));
  return 'fetched data';
}

위 코드는 비동기 함수를 테스트하는 예시로, await 키워드를 통해 비동기 함수의 실행 결과를 기다린 후 검증한다.

테스트 커버리지

단위 테스트 작성 시 중요한 부분 중 하나는 코드의 어느 정도가 테스트되고 있는지 확인하는 것이다. 이를 테스트 커버리지라고 한다. Dart에서는 dart test 명령어를 실행할 때 --coverage 플래그를 추가하여 커버리지 정보를 수집할 수 있다.

dart test --coverage coverage

위 명령을 사용하면 프로젝트 디렉토리 내에 coverage 디렉토리가 생성되고, 커버리지 데이터가 수집된다. 이 데이터를 기반으로 코드를 얼마나 테스트했는지 확인할 수 있으며, 이를 시각화하는 도구인 coverage 패키지를 사용하여 HTML 형식의 보고서를 생성할 수 있다.

dart run coverage:format_coverage --lcov --in=coverage --out=coverage.lcov --packages=.packages --report-on=lib

이 명령어는 수집된 커버리지 데이터를 lcov 형식으로 변환하고, 이를 시각화할 수 있는 HTML 보고서를 생성한다. genhtml 도구를 사용하면 이 파일을 HTML로 변환하여 브라우저에서 쉽게 확인할 수 있다.

genhtml -o coverage coverage.lcov

이제 coverage 폴더 안에 생성된 HTML 파일을 열어 테스트 커버리지 보고서를 시각적으로 확인할 수 있다. 이 보고서를 통해 코드의 어느 부분이 테스트되었는지, 어느 부분이 테스트되지 않았는지를 명확하게 파악할 수 있다.

테스트 사례 설계

단위 테스트에서 중요한 개념은 테스트 사례의 다양성이다. 테스트는 성공적인 케이스뿐만 아니라 실패한 케이스, 경계 조건, 비정상적인 입력 값 등에 대해서도 작성해야 한다. 이러한 테스트 사례를 고려하지 않으면 코드는 예상치 못한 상황에서 오류를 발생시킬 수 있다.

1. 성공적인 테스트 사례

성공적인 테스트 사례는 가장 기본적인 테스트이다. 함수가 예상된 결과를 반환하는지 확인하는 것이 주요 목적이다.

test('성공적인 테스트 사례', () {
  var result = add(2, 2);
  expect(result, equals(4));
});

2. 실패하는 테스트 사례

실패하는 테스트는 오류를 발생시키는 입력을 넣어 함수가 적절히 오류를 처리하는지 확인한다.

test('실패하는 테스트 사례', () {
  expect(() => divide(10, 0), throwsA(isA<Exception>()));
});

3. 경계 조건 테스트 사례

경계 조건은 입력 값의 극단적인 범위를 테스트하여 함수가 예상한 대로 동작하는지 확인하는 사례이다.

test('경계 조건 테스트 사례', () {
  expect(add(0, 0), equals(0));
});

4. 비정상적인 입력 테스트 사례

비정상적인 입력은 함수가 예상하지 못한 값으로 동작할 때 발생할 수 있는 문제를 방지하는 테스트이다.

test('비정상적인 입력 테스트 사례', () {
  expect(() => parseInt('abc'), throwsA(isA<FormatException>()));
});

코드 커버리지 계산

코드 커버리지는 일반적으로 다음과 같은 두 가지 지표로 측정된다:

  1. 라인 커버리지(Line Coverage): 코드의 총 라인 중 몇 라인이 테스트되었는지를 의미한다. 만약 전체 코드에 100개의 라인이 있고, 그 중 80개의 라인이 테스트되었다면 라인 커버리지는 80%이다.

  2. 브랜치 커버리지(Branch Coverage): 조건문에서 발생할 수 있는 모든 경로 중 몇 경로가 테스트되었는지를 의미한다. 예를 들어, if-else 문이 있다면 if 블록과 else 블록 모두가 테스트되었는지 확인해야 한다.

이를 수식으로 표현하면, 라인 커버리지(LC)와 브랜치 커버리지(BC)는 다음과 같이 계산된다:

LC = \frac{\text{테스트된 라인 수}}{\text{전체 라인 수}} \times 100
BC = \frac{\text{테스트된 브랜치 수}}{\text{전체 브랜치 수}} \times 100

이러한 커버리지 지표는 단순히 비율만으로 코드의 품질을 평가하는 것이 아니라, 테스트되지 않은 중요한 부분을 찾아내는 데 도움을 준다.

Mocking의 활용 예시 (상세)

Mocking은 외부 시스템이나 의존성을 테스트할 때 유용하다. 예를 들어, API 호출이나 데이터베이스와 같은 외부 의존성을 사용하는 함수에 대해 실제 호출 대신 모의 객체를 사용하여 테스트할 수 있다. 이를 통해 외부 환경에 종속되지 않고 독립적인 단위 테스트를 작성할 수 있다.

class ApiService {
  Future<String> fetchData() async {
    // 실제 API 호출을 수행하는 코드
    return 'Real Data';
  }
}

class MockApiService extends Mock implements ApiService {}

void main() {
  test('Mocking을 사용한 테스트', () async {
    var mockService = MockApiService();
    when(mockService.fetchData()).thenAnswer((_) async => 'Mock Data');

    var result = await mockService.fetchData();
    expect(result, equals('Mock Data'));
  });
}

위 예시에서 MockApiService는 실제 ApiService 대신 사용되며, fetchData() 메서드가 호출될 때 가짜 데이터를 반환하도록 설정된다. 이를 통해 실제 API 호출 없이 테스트할 수 있다.

통합 테스트와 단위 테스트의 차이

단위 테스트는 개별 함수나 메서드를 독립적으로 테스트하는 데 중점을 두지만, 통합 테스트는 여러 모듈이나 시스템이 함께 동작할 때 발생하는 상호작용을 테스트하는 데 초점을 맞춘다. 따라서 통합 테스트는 더 광범위한 테스트 범위를 가지며, 개별 모듈 간의 인터페이스를 검증하는 데 유용하다.

단위 테스트는 빠르게 실행되며, 특정 모듈의 동작이 올바른지 확인하는 데 유용하다. 반면, 통합 테스트는 실제 환경에서의 시스템 동작을 검증할 수 있다. 통합 테스트는 주로 데이터베이스나 파일 시스템과 같은 외부 시스템과의 상호작용을 포함하기 때문에 더 많은 설정이 필요하고, 실행 시간이 더 길어질 수 있다.

통합 테스트의 예시

통합 테스트는 외부 의존성(예: API 호출, 데이터베이스 등)을 함께 테스트하는 방식으로, 모의 객체(Mock)를 사용하여 이들을 대체할 수 있다.

class DatabaseService {
  Future<String> fetchData() async {
    return 'Database Data';
  }
}

class ApiService {
  final DatabaseService dbService;

  ApiService(this.dbService);

  Future<String> getData() async {
    var data = await dbService.fetchData();
    return 'Processed $data';
  }
}

void main() {
  test('통합 테스트', () async {
    var mockDbService = MockDatabaseService();
    when(mockDbService.fetchData()).thenAnswer((_) async => 'Mock Data');

    var apiService = ApiService(mockDbService);
    var result = await apiService.getData();

    expect(result, equals('Processed Mock Data'));
  });
}

이 예시에서 DatabaseService는 실제 데이터베이스와의 상호작용을 담당하며, ApiService는 그 데이터를 처리한다. 통합 테스트는 DatabaseService의 모의 객체를 사용하여 전체 시스템이 올바르게 동작하는지 검증한다.

단위 테스트의 Best Practices

단위 테스트를 작성할 때는 몇 가지 최선의 관행(Best Practices)을 따르는 것이 중요하다. 이러한 관행은 테스트의 유지보수성과 가독성을 높이고, 코드의 품질을 향상시키는 데 도움을 준다.

1. 테스트는 독립적이어야 한다

각 테스트 케이스는 독립적으로 실행되도록 작성해야 한다. 테스트가 서로 의존하게 되면, 한 테스트의 실패가 다른 테스트에도 영향을 미칠 수 있다. 테스트 간의 의존성을 없애기 위해서는 각 테스트가 자신만의 설정을 가지도록 setUptearDown을 적절히 사용해야 한다.

setUp(() {
  // 각 테스트 전에 필요한 설정
});

tearDown(() {
  // 각 테스트 후에 필요한 정리 작업
});

2. 테스트 명은 직관적으로 작성

테스트의 이름은 해당 테스트가 무엇을 하는지 명확하게 설명해야 한다. 이를 통해 다른 개발자가 테스트의 목적을 쉽게 이해할 수 있으며, 테스트가 실패할 경우 실패한 이유를 즉시 파악할 수 있다.

test('2와 3을 더하면 5가 되어야 한다', () {
  var result = add(2, 3);
  expect(result, equals(5));
});

3. 작고 집중된 테스트 작성

테스트는 특정한 기능을 검증해야 하며, 너무 많은 경우를 한꺼번에 테스트하려고 해서는 안 된다. 각 테스트는 작은 단위로 쪼개서 집중적으로 검증할수록 더 효과적이다.

test('음수 값 테스트', () {
  var result = add(-1, -2);
  expect(result, equals(-3));
});

4. 테스트 케이스마다 다른 상황을 검증

같은 함수에 대해 다양한 상황에서 동작을 검증하는 것이 중요하다. 예를 들어, 성공적인 입력뿐만 아니라 실패하는 경우도 함께 테스트하여, 함수가 다양한 상황에서 올바르게 동작하는지 확인해야 한다.

test('0으로 나누기 예외 테스트', () {
  expect(() => divide(10, 0), throwsA(isA<Exception>()));
});

5. 테스트는 빠르게 실행되어야 한다

단위 테스트는 가능하면 빠르게 실행되어야 한다. 테스트의 실행 시간이 길어지면, 개발 속도가 느려지고 테스트를 자주 실행하기 어렵기 때문에, 가능한 한 최소한의 자원을 사용하는 방식으로 테스트를 작성해야 한다.

6. 테스트 코드는 주석으로 명확히 설명

테스트 코드도 주석을 통해 설명하는 것이 중요하다. 특히 복잡한 로직을 테스트하는 경우, 해당 테스트의 목적을 주석으로 설명하면, 다른 개발자들이 테스트 코드를 이해하기 쉽다.

예외 처리 테스트의 중요성

모든 코드가 예상한 대로 동작하지 않을 때, 예외 처리가 제대로 이루어지는지 확인하는 것도 중요하다. 예외 처리 테스트는 프로그램이 예외 상황에서도 안정적으로 동작하는지를 검증하는 테스트이다.

예외 처리 테스트 예시

test('예외가 발생해야 한다', () {
  expect(() => throwException(), throwsA(isA<Exception>()));
});

위 테스트에서는 throwException() 함수가 예외를 발생시키는지를 검증하며, throwsA()를 사용하여 특정 예외 유형을 예상할 수 있다.

비동기 코드 테스트

Dart에서 비동기 코드는 매우 일반적이며, 이러한 코드도 테스트해야 한다. 비동기 테스트는 단순히 함수의 동작을 확인하는 것 외에도, 함수가 올바르게 대기하고, 올바른 시점에 완료되는지 등을 확인할 수 있어야 한다.

비동기 코드를 테스트할 때는 test() 함수 내에서 async 키워드를 사용하고, await를 통해 비동기 함수의 결과를 기다린다. 이를 통해 비동기 함수가 정상적으로 완료되고 결과를 반환하는지 확인할 수 있다.

비동기 코드 테스트 예시

import 'package:test/test.dart';

void main() {
  test('비동기 함수 테스트', () async {
    var result = await fetchData();
    expect(result, equals('fetched data'));
  });
}

Future<String> fetchData() async {
  await Future.delayed(Duration(seconds: 1));
  return 'fetched data';
}

이 테스트에서는 fetchData() 함수가 비동기적으로 데이터를 반환하는지 확인한다. await 키워드를 사용하여 함수가 완료될 때까지 기다린 후, 그 결과를 검증할 수 있다. 비동기 함수는 일반적으로 네트워크 요청이나 파일 입출력처럼 시간이 걸리는 작업에서 사용된다.

타임아웃 처리

비동기 코드의 또 다른 중요한 테스트 항목은 타임아웃이다. 비동기 함수가 예상보다 오래 걸릴 경우, 테스트를 일정 시간 내에 끝내도록 설정하는 것이 좋다. Dart의 test 함수는 기본적으로 타임아웃을 지원하며, 타임아웃 시간을 지정할 수 있다.

test('비동기 함수 타임아웃 테스트', () async {
  await Future.delayed(Duration(seconds: 2));
}, timeout: Timeout(Duration(seconds: 1)));

위 예시에서 test 함수는 1초 안에 완료되어야 한다. 그러나 함수는 2초간 지연되므로, 테스트는 타임아웃으로 실패하게 된다. 타임아웃 처리는 서버 요청이나 대규모 파일 처리가 예상보다 오래 걸릴 때 중요한 역할을 한다.

비동기 함수에서의 예외 처리

비동기 함수에서도 예외 처리를 테스트해야 한다. 예외가 발생할 가능성이 있는 비동기 코드를 테스트할 때는 throwsA와 함께 await를 사용하여 비동기 코드의 예외 처리가 올바르게 이루어지는지 검증할 수 있다.

test('비동기 예외 테스트', () async {
  expect(() async => await fetchWithError(), throwsA(isA<Exception>()));
});

Future<String> fetchWithError() async {
  throw Exception('Error occurred');
}

이 테스트는 fetchWithError() 함수가 비동기적으로 예외를 던지는지를 확인한다. expect() 안에서 async 함수를 호출하여, 비동기 함수의 예외 처리를 검사할 수 있다.

테스트의 우선 순위 설정

여러 개의 테스트가 있을 때, 실행 순서를 특정할 필요는 없다. 테스트는 독립적으로 실행되어야 하므로, 특정 테스트가 먼저 실행되어야 할 이유는 없다. 하지만 경우에 따라 테스트의 우선 순위를 조정하고 싶은 상황이 있을 수 있다.

Dart에서는 이러한 기능을 기본적으로 제공하지 않으며, 각 테스트는 독립적이어야 한다는 원칙을 따른다. 만약 테스트 순서가 중요해진다면, 이는 단위 테스트 설계의 문제일 수 있다. 모든 테스트는 독립적으로 실행되며, 서로의 결과에 영향을 미쳐서는 안 된다.

테스트 결과 자동화

테스트 자동화는 개발자들이 매번 수동으로 테스트를 실행할 필요 없이, 코드를 수정할 때마다 자동으로 테스트를 실행하는 과정이다. Dart에서는 이를 위해 CI(Continuous Integration) 도구를 사용할 수 있다. Travis CI, GitHub Actions, Jenkins 등과 같은 도구를 설정하여, 코드가 변경될 때마다 자동으로 테스트가 실행되도록 구성할 수 있다.

CI를 설정하면 코드가 푸시될 때마다 테스트가 실행되며, 모든 테스트가 통과했는지를 확인할 수 있다. 이 과정은 개발 주기에서 매우 중요한 부분이며, 특히 여러 개발자가 함께 작업하는 프로젝트에서 코드 품질을 유지하는 데 큰 도움이 된다.

GitHub Actions 예시

다음은 GitHub Actions를 사용하여 Dart 프로젝트의 테스트를 자동화하는 예시이다.

name: Dart CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2
    - name: Install Dart
      uses: dart-lang/setup-dart@v1
      with:
        sdk: "stable"
    - run: dart pub get
    - run: dart test

이 YAML 파일을 프로젝트의 .github/workflows 폴더에 추가하면, GitHub에서 코드를 푸시할 때마다 자동으로 Dart 테스트를 실행할 수 있다. 이를 통해 코드가 항상 테스트를 통과하는지 자동으로 확인할 수 있다.

플러터에서의 단위 테스트

Dart는 Flutter에서도 많이 사용되며, Flutter 프로젝트에서도 단위 테스트를 적용할 수 있다. Flutter의 경우, 위젯 테스트와 통합 테스트도 가능하지만, 여기서는 Flutter에서 Dart 기반의 단위 테스트만 다룬다.

import 'package:flutter_test/flutter_test.dart';

void main() {
  test('더하기 함수 테스트', () {
    var result = add(2, 3);
    expect(result, equals(5));
  });
}

int add(int a, int b) {
  return a + b;
}

Flutter에서의 테스트는 기본적으로 Dart의 단위 테스트 방식과 매우 유사하며, flutter_test 패키지를 통해 여러 Flutter-specific 기능들을 사용할 수 있다. Flutter에서 테스트를 작성할 때도 Dart의 단위 테스트와 같은 원칙을 적용할 수 있다.