# 단위 테스트 작성

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

#### 테스트의 기본 구성

단위 테스트는 일반적으로 다음의 세 가지 단계로 이루어진다:

1. **설정(Setup):** 테스트가 실행되기 전에 필요한 데이터를 설정한다.
2. **실행(Execute):** 테스트 대상인 코드를 실행한다.
3. **검증(Assert):** 실행된 결과가 예상 결과와 일치하는지 확인한다.

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

```dart
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. 독립성**

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

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

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

**2. 경계 조건 테스트**

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

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

**3. 예외 처리 테스트**

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

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

#### 매개변수화된 테스트

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

```dart
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` 함수를 사용할 수 있다. 이 함수는 관련된 테스트 케이스들을 하나의 그룹으로 묶어주는 역할을 한다.

```dart
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에서 테스트를 실행하려면 터미널에서 다음 명령을 사용할 수 있다.

```bash
dart test
```

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

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

```plaintext
00:01 +2: All tests passed!
```

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

#### Mocking(모의 객체)

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

```dart
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에서는 비동기 코드를 많이 사용하기 때문에, 비동기 함수도 단위 테스트의 대상이 된다. 비동기 테스트는 `async`와 `await`를 사용하여 작성할 수 있다.

```dart
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` 플래그를 추가하여 커버리지 정보를 수집할 수 있다.

```bash
dart test --coverage coverage
```

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

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

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

```bash
genhtml -o coverage coverage.lcov
```

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

#### 테스트 사례 설계

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

**1. 성공적인 테스트 사례**

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

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

**2. 실패하는 테스트 사례**

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

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

**3. 경계 조건 테스트 사례**

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

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

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

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

```dart
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 호출이나 데이터베이스와 같은 외부 의존성을 사용하는 함수에 대해 실제 호출 대신 모의 객체를 사용하여 테스트할 수 있다. 이를 통해 외부 환경에 종속되지 않고 독립적인 단위 테스트를 작성할 수 있다.

```dart
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)를 사용하여 이들을 대체할 수 있다.

```dart
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. 테스트는 독립적이어야 한다**

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

#### 예외 처리 테스트의 중요성

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

**예외 처리 테스트 예시**

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

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

#### 비동기 코드 테스트

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

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

**비동기 코드 테스트 예시**

```dart
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` 함수는 기본적으로 타임아웃을 지원하며, 타임아웃 시간을 지정할 수 있다.

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

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

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

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

```dart
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 프로젝트의 테스트를 자동화하는 예시이다.

```yaml
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 기반의 단위 테스트만 다룬다.

```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의 단위 테스트와 같은 원칙을 적용할 수 있다.
