단위테스트 책을 읽고 실습 하며 내용을 정리한 글입니다. 학습 과정에서 작성된 글이기 때문에 잘못된 내용이 있을 수 있으며, 이에 대한 지적이나 피드백은 언제든 환영입니다.
1. 통합 테스트
통합 테스트는 세 가지 요구사항 중 하나라도 충족하지 못하는 테스트를 말합니다. 실제로 통합 테스트는 대부분 시스템이 프로세스 외부 의존성과 통합해 어떻게 작동하는지를 검증합니다. 다시 말해 이 테스트는 컨트롤러 사분면에 속하는 코드를 다룹니다. 단위 테스트는 도메인 모델을 다루는 반면, 통합 테스트는 프로세스 외부 의존성과 도메인 모델을 연결하는 코드를 확인합니다.
- 단일 동작 단위를 검증한다.
- 빠르게 수행된다.
- 다른 테스트와 별도로 처리한다.
컨트롤러 사분면을 다루는 테스트가 단위 테스트일 수도 있습니다. 모든 프로세스 외부 의존성을 목으로 대체하면 테스트 간 공유하는 의존성이 없어지므로 테스트 속도가 빨라지고 서로 격리될 수 있습니다. 그러나 대부분의 애플리케이션은 목으로 대체할 수 없는 프로세스 외부 의존성이 존재합니다. 대게 데이터베이스이며, 다른 애플리케이션에서는 볼 수 없는 의존성입니다. 또한 다른 두 사분면은 전혀 테스트해서는 안됩니다. 간단한 코드는 노력을 들일 가치가 없고, 지나치게 복잡한 코드는 알고리즘과 컨트롤러로 리팩토링해야 합니다. 따라서 모든 테스트는 도메인 모델과 컨트롤러 사분면에만 초점을 맞춰야 합니다.
단위 테스트와 통합 테스트의 비율은 프로젝트 특성에 따라 다를 수 있습니다. 통합 테스트는 가능한 많은 비즈니스 시나리오의 예외 상황을 확인하고, 주요 흐름과 단위 테스트가 다루지 못하는 기타 예외 상황을 다룹니다. 주요 흐름은 시나리오의 성공적 실행입니다. 예외 상황은 비즈니스 시나리오 수행 중 오류가 발생하는 경우입니다. 대부분은 단위 테스트로 전환하면 유지비를 절감할 수 있습니다. 또한 중요한 통합 테스트가 비즈니스 시나리오당 하나 또는 두 개 있으면 시스템의 전체의 정확도를 보장할 수 있습니다. 이 지침은 아래와 같이 단위 테스트와 통합 테스트 사이의 피라미드 같은 비율을 만듭니다.
테스트 피라미드는 프로젝트의 복잡도에 따라 모양이 다를 수 있으며, 단순 애플리케이션은 도메인 모델과 알고리즘 사분면에 거의 코드가 없습니다. 결국 구성이 피라미드 대신 직사각형 모양이 되며, 단위 테스트와 통합 테스트의 수가 같습니다. 아주 단순한 경우라면 어떤한 단위 테스트도 없을 것입니다. 통합 테스트는 단순히 애플리케이션에서도 가치가 있는데, 코드가 얼마나 간단한지보다 다른 서브 시스템과 통합해 어떻게 작동하는지 확인이 더 중요합니다.
2. 통합 테스트와 빠른 실패
통합 테스트에서 프로세스 외부 의존성과 상호 작용을 모두 확인하려면 가장 긴 주요 흐름을 선택해야 합니다. 좋지 않은 테스트를 작성하는 것보다 테스트를 작성하지 않는 것이 좋습니다. 가치가 별로 없는 테스트는 좋지 않은 테스트입니다.
2-1. 빠른 실패 원칙
예기치 않은 오류가 발생하자마자 현재의 연산을 중단하는 것을 말합니다. 이를 통해 애플리케이션의 안전성을 높일 수 있는데, 보통 예외를 던져서 현재 연산을 중지합니다. 예외는 그 의미가 빠른 실패 원칙에 완벽히 부합되기 때문입니다. 예외는 프로그램 흐름을 중단하고 실행 스택에서 가장 높은 레벨로 올라간 후 로그를 남기고 작업을 종료하거나 재시작할 수 있습니다. 전제 조건이 실패하면 애플리케이션 상태에 대해 가정이 잘못된 것을 의미하는데, 이는 항상 버그에 해당합니다. 또 다른 예는 설정 파일에서 데이터를 읽는 것입니다. 설정 파일의 데이터가 불완전하거나 잘못된 경우 예외가 발생하도록 판독 로직을 구성할 수 있습니다. 이 로직을 애플리케이션 시작 부근에 둬서 문제가 있으면 애플리케이션이 시작하지 않도록 할 수도 있습니다.
| 내용 | 명령어 |
|---|---|
| 피드백 루프 단축 | 버그를 빨리 발견할수록 더 쉽게 해결할 수 있습니다. 이미 운영 환경으로 넘어온 버그를 개발 중에 발견된 버그보다 수정 비용이 큽니다. |
| 지속성 상태 보호 | 버그는 애플리케이션 상태를 손상시킵니다. 손상된 상태가 데이터베이스로 침투하면 고치기 어렵습니다. 빨리 실패하면 손상이 확산되는 것을 막을 수 있습니다. |
2-2. 어떤 프로세스 의존성을 테스트할까?
모든 프로세스 외부 의존성은 관리 의존성, 비관리 의존성 두 가지 범주로 나뉩니다. 이전에 5장에서 의존성과의 통신은 구현 세부 사항이라고 했으며, 반대로 비관리 의존성과의 통신은 시스템의 식별할 수 있는 동작입니다. 이러한 차이로 인해 통합 테스트에서 프로세스 외부 의존성의 처리가 달라집니다. 관리 의존성은 실제 인스턴스를 사용하고, 비관리 의존성은 목으로 대체해야 합니다. 비관리 의존성에 대한 통신 패턴을 유지해야 하는 것은 하위 호환성을 지켜야 하기 때문이며, 이 작업에는 목이 제격입니다. 목을 사용하면 모든 가능한 리팩토링을 고려해서 통신 패턴 영속성을 보장할 수 있습니다.
| 내용 | 명령어 |
|---|---|
| 관리 의존성 | 전체를 제어할 수 있는 프로세스 외부 의존성. 이러한 의존성은 애플리케이션을 통해서만 접근할 수 있으며 해당 의존성과의 상호 작용은 외부 환경에서 볼 수 없습니다. 대표적 예로 데이터베이스가 있습니다. 외부 시스템은 보통 데이터베이스에 직접 접근하지 않고 애플리케이션에서 제공하는 API를 통해 접근합니다. |
| 비관리 의존성 | 전체를 제어할 수 없는 프로세스 외부 의존성. 해당 의존성과의 상호 작용을 외부에서 볼 수 있습니다. 예를 들어 SMTP 서버와 메시지 버스 등이 있습니다. 둘 다 다른 애플리케이션에서 볼 수 있는 부작용을 발생시킵니다. |
그러나 관리 의존성과 통신하는 것은 애플리케이션뿐이므로 하위 호환성을 유지할 필요가 없습니다. 외부 클라이언트는 데이터베이스를 어떻게 구성하는지 신경쓰지 않습니다. 중요한 것은 시스템의 최종 상태입니다. 통합 테스트에서 관리 의존성의 실제 인스턴스를 사용하면 외부 클라이언트 관점에서 최종 상태를 확인할 수 있습니다. 또한 컬럼 이름을 변경하거나 데이터베이스를 이관하는 등 데이터베이스 리팩토링에도 도움이 됩니다. 즉 실제 상황가 가깝게 테스트할 수록 정확한 최종 결과를 알 수 있습니다.
2-3. 관리 의존성이면서 동시에 비관리 의존성일 경우
때로는 관리 의존성과 비관리 의존성 모두의 속성을 나타내는 프로세스 외부 의존성이 있을 수 있습니다. 좋은 예로 다른 애플리케이션이 접근할 수 있는 데이터베이스가 있습니다. 시스템은 전용 데이터베이스로 시작합니다. 이내 다른 시스템이 같은 데이터베이스의 데이터를 요구하기 시작하는데, 따라서 그 팀은 단지 다른 시스템과 쉽게 통합할 수 있도록 일부 테이블만 접근 권한을 공유하기로 결정했습니다. 결과적으로 데이터베이스는 관리 의존성이면서 비관리 의존성입니다. 여전히 애플리케이션에서만 볼 수 있는 부분이 있으나 이러한 부분 외에도 다른 애플리케이션에서 접근할 수 있는 테이블이 많이 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@GetMapping
public ResponseEntity<WeatherForecastResponse> searchWeatherForecast(
@RequestParam("cityName") String cityName,
@RequestParam("date") String date,
@RequestParam("time") String time
) {
// 내부 데이터베이스 사용
Position cityPosition = cityQueryService.findCityPositionByName(cityName);
WeatherSearchCondition searchCondition = createSearchCondition(date, time, cityPosition);
// 외부 API 호출
WeatherForecastOpenApiResponse response = openDataApiClient.callBackData(searchCondition);
return ResponseEntity.ok(WeatherForecastResponse.of(response));
}
시스템 간의 통합을 구현하는 데 데이터베이스를 사용하면 시스템이 서로 결합되고 추가 개발을 복잡하게 만들기 때문에 좋지 않습니다. 그러므로 다른 방법이 없을 경우에만 이 방법을 사용합니다.
이 경우 API나 메시지 버스를 사용하는 것이 더 낫습니다.
그러나 이미 공유 데이터베이스가 있고 근래에 할 수 있는 것이 아무것도 없다면 다른 애플리케이션에서 볼 수 있는 테이블을 비관리 의존성으로 취급합니다. 이러한 테이블은 사실상 메시지 버스 역할을 하고 각 행이 메시지 역할을 합니다. 이러한 테이블을 이용한 통신 패턴이 바뀌지 않도록 하려면 목을 사용합니다. 그리고 나머지 데이터베이스를 관리 의존성으로 처리하고 데이터베이스와의 상호 작용을 검증하지 말고 데이터베이스의 최종 상태를 확인합니다.
데이터베이스에서 이 두 부분을 구분하는 것이 중요합니다. 다시 말하면 공유 테이블은 외부에서 볼 수 있고 애플리케이션과 테이블 간의 통신 방식을 주의해야 하기 때문입니다. 꼭 필요한 경우가 아니라면 시스템이 해당 테이블과 상호 작용하는 방식을 변경하지 않습니다. 다른 애플리케이션이 이러한 변경에 어떻게 반응하는지 알 수 없기 때문입니다. 즉 아래와 같이 일정한 값을 반환받아서 기존의 테스트에 전혀 영향이 받지 않도록 해야 합니다.
3. 통합 테스트에서 실제 데이터베이스를 사용할 수 없는 경우
때로는 관리 범위를 벗어난다는 이유로 통합 테스트에서 관리 의존성을 실제 버전으로 사용할 수 없는 예도 있습니다. 테스트 자동화 환경에 배포할 수 없는 레거시 데이터베이스를 예로 들 수 있습니다. IT 보안 정책 때문이거나 테스트 데이터베이스 인스턴스를 설정하고 유지하는 비용이 만만찮기 때문입니다. 이러한 상황에서는 관리 의존성을 목으로 대체하면 통합 테스트의 리팩토링 내성이 저하되며, 또한 테스트는 회귀 방지도 떨어집니다.
또한 데이터베이스가 프로젝트에서 유일한 프로세스 외부 의존성이면 통합 테스트는 회귀 방지에 있어 기존 단위 테스트 세트와 다를 바 없습니다. 이러한 통합 테스트가 하는 일은 컨트롤러가 어떤 레포지토리 메서드를 호출하는지 검증하는 것뿐입니다. 다시 말해 컨트롤러에 있는 코드 세 줄을 제외하고 어떠한 것도 신뢰할 수 없으며 알아야 할 것이 많이 있습니다.
3-1. 목의 사용 유무 결정
통합 테스트를 작성하기 전에 프로세스 외부 의존성을 두 가지로 분류해서 직접 테스트할 대상과 목으로 대체할 대상을 결정해야 합니다. 애플리케이션 데이터베이스는 어떤 시스템도 접근할 수 없으므로 관리 의존성입니다.
3-2. 엔드 투 엔드 테스트
배포 후의 상태 점검을 위해 한 개 또는 두 개 정도의 중요한 엔드 투 엔드 테스트를 작성할 수 있습니다. 테스트가 가장 긴 주요 흐름을 거치게 해서 애플리케이션이 모든 프로세스 외부 의존성과 올바르게 통신할 수 있도록 합니다. 외부 클라이언트의 동작을 모방하려면 이를 모킹하고 데이터베이스 상태는 애플리케이션을 통해 검증합니다. 만약 외부 API가 변하지 않는 불변 의존성일 경우 이를 실제 호출해도 상관없습니다.
4. 의존성 추상화를 위한 인터페이스 사용
많은 개발자들이 데이터베이스나 메시지 버스와 같은 프로세스 외부 의존성을 위해 인터페이스를 도입합니다. 심지어 구현이 하나만 있는 경우에도 관습적으로 이를 적용합니다. 인터페이스를 사용하는 일반적 이유는 아래와 같습니다.
- 프로세스 외부 의존성을 추상화해 느슨한 결합을 달성하고
- 기존 코드를 변경하지 않고 새로운 기능을 추가해 공개 폐쇄 원칙을 지키기 때문입니다.
하지만 이 두 가지 이유 모두 오해입니다. 단일 구현을 위한 인터페이스는 추상화가 아니며 해당 인터페이스를 구현하는 구체 클래스보다 결합도가 낮지 않습니다. 진정한 추상화는 발견하는 것이지 발명하는 것이 아니기 때문이며, 즉 의미상 추상화가 이미 존재하지만 코드에서 아직 명확하게 정의되지 않았을 때 그 이후에 발견되는 것입니다. 따라서 인터페이스가 진정으로 추상화되려면 구현이 적어도 두 가지는 있어야 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
@Repository // Repository 구현체가 하나라면 굳이 Impl을 사용하지 않아도 됩니다.
public class WeatherForecastQueryDSLQueryqRepository {
private final JPAQueryFactory queryFactory;
public WeatherForecastQueryDSLQueryqRepository(JPAQueryFactory queryFactory) {
this.queryFactory = queryFactory;
}
......
}
또한 더 기본적인 원칙인 YANGI(You aren't gonna need it)를 위반하기 때문에 잘못된 생각입니다. 이는 현재 필요하지 않은 기능에 시간을 들이지 말라는 것입니다. 이러한 향후 기능이 어떤지 설명하려고 기능을 개발해서도 기존 코드를 수정해서도 안됩니다. 이는 기회비용과 코드가 적을수록 좋기 때문입니다.
코드를 작성하는 것은 문제를 해결하는 비싼 방법이며, 해결책에 필요한 코드가 적고 간단할 수록 더 좋습니다.
4-1. 프로세스 외부 의존성에 인터페이스를 사용하는 이유
목을 사용하기 위함이며 훨씬 더 실용적이고 현실적인 방법입니다. 인터페이스가 없으면 테스트 대역을 만들 수 없으므로 테스트 대상 시스템과 프로세스 외부 의존성 간의 상호 작용을 확인할 수 없습니다. 따라서 이러한 의존성을 목으로 처리할 필요가 없는 한 프로세스 외부 의존성에 대한 인터페이스를 두지 않는 것이 좋습니다. 비관리 의존성만 목으로 처리하므로 결국 비관리 의존성에 대해서만 인터페이스를 쓰라는 지침이 됩니다. 관리 의존성을 컨트롤러에 명시적으로 주입하고 해당 의존성을 구체 클래스로 사용합니다.
진정한 추상화는 목과 상관없이 인터페이스를 나타낼 수 있습니다. 그러나 목 대체 이외의 이유로 단일 구현을 위해 인터페이스를 도입하는 것은 YAGNI에 위배됩니다.
의존성의 메서드를 가상으로 만들면 인터페이스에 의존하지 않고 목 기반을 클래스로 사용해 해당 의존성을 목으로 처리할 수 있습니다. 그러나 이 방법은 인터페이스를 쓰는 방법에 비해 열약합니다.
4-2. 프로세스 내부 의존성을 위한 인터페이스 사용
때로는 프로세스 외부 의존성뿐 아니라 프로세스 내부 의존성도 인터페이스 기반인 코드들도 볼 수 있습니다. 이는 좋지 않은 신호이며 프로세스 외부 의존성과 마찬가지로 도메인 클래스에 대해 단일 구현으로 인터페이스를 도입하는 이유는 목으로 처리하기 위한 것뿐입니다. 그러나 프로세스 외부 의존성과 달리 도메인 클래스 간의 상호 작용을 확인해서는 안 됩니다. 그렇게 하면 깨지기 쉬운 테스트로 이어지게 되고 결국 리팩토링 내성이 떨어지게 됩니다.
5. 통합 테스트 모범 사례
통합 테스트를 최대한 활용하는 데 도움이 되는 몇 가지 지침이 있습니다.
- 도메인 모델 경계 명시하기
- 애플리케이션 내 계층 줄이기
- 순환 의존성 제거하기
5-1. 도메인 모델 경계 명시하기
도메인은 프로젝트가 해결하고자 하는 문제에 대한 도메인 지식의 모음입니다. 도메인 모델에 명시적 경계를 지정하면 코드의 해당 부분을 더 잘 보여주고 더 잘 설명할 수 있으며, 이는 테스트에도 도움이 됩니다. 이 장의 앞부분에서 언급했듯이 단위 테스트는 도메인 모델과 알고리즘을 대상으로 하고 통합 테스트는 컨트롤러를 대상으로 합니다. 도메인 클래스와 컨트롤러 사이의 명확한 경계로 단위 테스트와 통합 테스트의 차이점을 쉽게 구별할 수 있으며 이러한 경계는 별도의 어셈블리 또는 네임스페이스 형태를 취할 수 있습니다. 모든 도메인 로직이 명확한 경계로 나뉘어져 있고 코드 베이스 여기저기에 흩어져 있지 않는 한 그 세부 사항은 그다지 중요하지 않습니다.
5-2. 계층 수 줄이기
대부분 프로그래머는 간접 계층을 추가해 코드를 추상화하고 일반화하려고 합니다. 일반적인 엔터프라이즈급 애플리케이션에서 여러 계층을 쉽게 찾아볼 수 있습니다. 극단적 경우로 애플리케이션에 추상 계층이 너무 많으면 코드 베이스를 탐색하기 어렵고 아주 간단한 연산이라 해도 숨은 로직을 이해하기 어려워집니다. 단순히 직면한 문제의 구체적 해결 방법을 알고 싶을 뿐이지 외부와 단절된 채로 해결책을 일반화하려는 것은 아닙니다.
컴퓨터 과학의 모든 문제는 또 다른 간접 계층으로 해결할 수 있다. 간접 계층이 너무 많아서 문제가 생기지 않는다면. - 데이빗 휠러(David J.Wheeler)
간접 계층은 코드를 추론하는데 부정적 영향을 미칩니다. 모든 기능이 각각의 계층으로 전개되면 모든 조각을 하나의 그림으로 만드는 데 방해돼서 정신적으로 더 부담되기 때문입니다. 가능한 간접 계층을 적게 사용하며, 인프라 계층은 보통 도메인 모델에 속하지 않는 알고리즘과 프로세스 외부 의존성에 접근할 수 있는 코드로 구성합니다. 추상화가 지나치게 많으면 단위 테스트와 통합 테스트에도 도움이 되지 않습니다. 간접 계층이 많은 코드 베이스는 컨트롤러와 도메인 모델 사이에 명확한 경계가 없는 편입니다. 그리고 각 계층을 따로 검증하는 경향이 훨씬 강합니다. 이러한 경향으로 인해 통합 테스트는 가치가 떨어지며 각 테스트는 특정 계층의 코드만 실행하고 하위 계층은 목으로 처리합니다. 이렇게 되면 최종 결과는 항상 똑같이 낮은 리팩토링 내성과 불충분한 회귀 방지입니다.
5-3. 순환 의존성 제거
코드베이스의 유지 보수성을 대폭 개선하고 테스트를 더 쉽게 할 수 있는 또 다른 방법으로 순환 의존성을 제거하는 것이 있습니다. 순환 의존성의 대표적인 예는 콜백입니다. 추상 계층이 너무 많은 것과 마찬가지로 순환 의존성은 코드를 읽고 이해하려고 할 때 알아야 할 것이 많아서 큰 부담이 되며, 순환 의존성이 있으면 해결책을 찾기 위한 출발점이 명확하지 않기 때문입니다. 하나의 클래스를 이해하려면 주변 클래스 그래프 전체를 한 번에 읽고 이해해야 하며 심지어 소규모의 독립된 클래스조차 파악하기 어려워질 수 있습니다.
또한 순환 의존성은 테스트를 방해합니다. 클래스 그래프를 나눠서 동작 단위를 하나 분리하려면 인터페이스에 의존해 목으로 처리해야 하는 경우가 많으며 이는 도메인 모델을 테스트할 때 해서는 안 됩니다.
순환 의존성은 둘 이상의 클래스가 제대로 작동하고자 직간접적으로 서로 의존하는 것을 말합니다.
인터페이스 사용은 순환 의존성의 문제만 가립니다. 이는 컴파일 시점과 런타임 시점에 모두 적용되는데, 컴파일러가 더 이상 클래스 구성을 순환 참조로 여기지 않더라도 코드를 이해하는 데 알아야 하는 부담이 줄지 않으며 오히려 인터페이스 추가로 인해 늘어날 수도 있기 때문입니다. 순환 의존성을 처리하는 더 좋은 방법은 순환 의존성을 끊어내는 것이며, 작업 결과를 일반 값으로 리턴하면 됩니다.
1
2
3
4
5
public interface OrderService {
BigDecimal calculate();
}
5-4. 테스트에서 다중 실행 구절 적용
테스트는 두 개 이상의 준비나 실행 또는 검증 구절을 두는 것은 코드 악취에 해당합니다. 이는 테스트가 여러 가지 동작 단위를 확인해서 테스트의 유지 보수성을 저해하는 신호입니다.
1
2
3
4
5
6
7
8
9
10
11
12
@Test
@DisplayName("결제를 하기 위해서는 주문 정보가 필요하다.")
void 주문_테스트() throws Exception {
Order order = orderService.order( ...... );
// 이는 order 테스트의 영향을 받아서 실패할 지, 아닌지가 결정된다.
Payment payment = paymentService.validate(order);
......
}
이러한 방식은 사용자의 상태가 자연스럽게 흐르기 때문에 설득력이 있고 첫 번째 실행은 두 번째 실행의 준비 단계 역할을 할 수 있습니다. 문제는 이러한 테스트가 초점을 잃고 순식간에 너무 커질 수 있다는 것입니다. 각 실행을 고유의 테스트로 추출해 테스트를 나누는 것이 좋습니다. 불필요한 작업처럼 보일 수 있지만 이 작업은 장기적으로 유리합니다. 각 테스트가 단일 동작 단위에 초점을 맞추게 하면 테스트를 더 쉽게 이해하고 필요할 때 수정할 수 있습니다.
준비 > 실행 > 검증 > 실행 > 검증
이 지침의 예외로 원하는 상태로 만들기 어려운 프로세스 외부 의존성으로 작동하는 테스트가 있습니다. 이는 외부 프로세스의 의존성에 의해 테스트가 결정되기 때문입니다. 이때도 가능한 목을 사용해 일정한 결과를 반환하고, 여러 시나리오에서는 동작을 하나의 테스트로 묶어서 문제가 있는 프로세스 외부 의존성에 대한 상호 작용 횟수를 줄이는 것이 유리합니다. 실제로 다단계 테스트는 대부분 엔드 투 엔드 테스트 범주에 속합니다.
1
2
3
4
5
6
7
8
9
10
11
@Test
public void returns_ok_when_product_response_is_as_expected(){
// wiremock으로 외부 API 테스트를 모킹해서 하나의 테스트에만 집중할 수 있게 하기도 한다.
mockRecommendationsApi.stubFor(
get(urlPathEqualTo("/api/recommendations"))
.willReturn(ok()
.withHeader("Content-Type","application/json")
.withBodyFile("ok-recommended.json"))
);
}
6. 로깅을 테스트하는 방법
로깅은 회색 지대로 테스트에 관해서는 어떻게 해야 할지 분명하지 않습니다. 이는 아래와 같은 질문으로 나눌 수 있는 복잡한 주제입니다.
- 로깅을 조금이라도 테스트해야 하는가?
- 만약 그렇다면 어떻게 테스트해야 하는가?
- 로깅이 얼마나 많으면 충분한가?
- 로거 인스턴스를 어떻게 전달할까?
이를 테스트하는 기준은 로깅이 애플리케이션의 식별할 수 있는 동작인지, 아니면 구현 세부사항인지 입니다. 그런 면에서 다른 기능들과 다르지 않습니다. 결국 로깅은 텍스트 파일이나 데이터베이스와 같은 프로세스 외부 의존성에 부작용을 초래합니다. 만약 이러한 부작용을 고객이나 애플리케이션의 클라이언트 또는 개발자 이외의 다른 사람이 보는 경우라면 로깅은 식별할 수 있는 동작이므로 반드시 테스트해야 합니다. 하지만 보는 이가 개발자뿐이라면 아무도 모르게 자유로이 수정할 수 있는 구현 세부 사항이므로 테스트해서는 안됩니다.
이 또한 부작용의 유무로 판단합니다.
예를 들어, 로깅 라이브러리를 작성하는 경우 이 라이브러리가 생성하는 로그는 식별할 수 있는 동작에서 가장 중요한 부분입니다. 또 다른 예로 비즈니스 담당자가 주요 애플리케이션 작업 흐름을 기록해야 한다고 주장하는 경우가 있습니다. 이 경우 로그도 비즈니스 요구 사항이므로 테스트를 거쳐야 합니다. 그러나 개발자만을 위해 별도로 로깅을 할 수도 있습니다. 스티브 프리먼에서는 아래와 같이 두 가지 유형의 로깅을 나눕니다. 이 또한 위에서 봤던 것처럼 부작용의 유무로, 필요에 따라 테스트 하도록 합니다.
- 지원 로깅(support logging): 지원 담당자나 시스템 관리자가 추적할 수 있는 메시지를 생성합니다.
- 결단 로깅(diagnostic logging): 개발자가 애플리케이션 내부 사항을 파악할 수 있도록 돕습니다.
7. 정리
통합 테스트는 단위 테스트가 아닌 테스트에 해당합니다. 이는 시스템이 프로세스 외부 의존성과 통합해 작동하는 방식을 검증합니다.
- 통합 테스트는 컨트롤러를 다루고 단위 테스트는 알고리즘과 도메인 모델을 다룹니다.
- 통합 테스트는 회귀 방지와 리팩토링 내성이 우수하고 단위 테스트는 유지 보수성과 피드백 속도가 우수합니다.
- 통합 테스트의 기준은 단위 테스트보다 높습니다. 통합 테스트에서 회귀 방지와 리팩토링 내성 지표에 대한 점수는 단위 텟흐트보다 유지 보수성과 피드백 속도가 떨어진 만큼 높아야 합니다. 테스트 피라미드가 이러한 절충을 나타냅니다. 대부분의 테스트는 빠르면서 비용이 낮아야 하고, 시스템이 전체적으로 올바른지 확인하는 통합 테스트는 속도가 느리고 비용이 많이 발생하므로 그 수가 적어야 합니다
- 단위 테스트를 통해 가능한 많은 비즈니스 시나리오의 예외 상황을 확인합니다. 통합 테스트를 사용해 하나의 주요 흐름과 단위 테스트로 확인할 수 없는 예외 상황을 다루도록 합니다.
- 테스트 피라미드의 모양은 프로젝트 복잡도에 따라 달라집니다. 간단히 프로젝트는 도메인 모델에 코드가 거의 없으므로 단위 테스트와 통합 테스트의 개수가 동일합니다. 아주 단순한 경우 단위 테스트가 없을 수도 있습니다.
- 빠른 실패 원칙은 버그가 빠르게 나타날 수 있도록 하며 통합 테스트에서 할 수 있는 대안입니다.
- 관리 의존성은 애플리케이션을 통해서만 접근할 수 있는 프로세스 외부 의존성입니다. 관리 의존성과의 상호 작용은 외부에서 관찰할 수 없습니다. 대표적인 예는 애플리케이션 데이터베이스입니다.
- 비관리 의존성은 다른 애플리케이션이 접근할 수 있는 프로세스 외부 의존성입니다. 비관리 의존성과의 상호 작용은 외부에서 관찰할 수 있습니다. 대표적인 예로 SMTP 서버나 메시지 버스 등이 있습니다.
- 관리 의존성과의 통신은 구현 세부 사항이고, 비관리 의존성과의 통신은 식별할 수 있는 동작입니다.
- 통합 테스트에서 관리 의존성은 실제 인스턴스를 사용합니다. 비관리 의존성은 목으로 대체합니다.
- 때로는 관리 의존성과 비관리 의존성 모두의 특성을 나타내는 프로세스 외부 의존성이 있습니다. 전형적인 예로는 다른 애플리케이션이 접근할 수 있는 데이터베이스가 있습니다. 비관리 의존성의 식별 가능한 부분을 비관리 의존성으로 간주하고, 테스트에서 해당 부분을 목으로 대체합니다. 나머지 부분을 관리 의존성으로 간주하고 해당 부분과의 상호 작용 대신 최종 상태를 검증합니다.
- 통합 테스트는 관리 의존성과 작동하는 모든 계층을 거쳐야 합니다. 데이터베이스를 예로 들면, 입력 매개변수로 사용한 데이터와 별개로 해당 데이터베이스의 상태를 확인하는 것을 의미합니다.
- 구현이 하나뿐인 인터페이스는 추상화가 아니며 해당 인터페이스를 구현하는 구체 클래스보다 결합도가 낮지 않습니다. 이러한 인터페이스에 대한 향후 구현을 예상하면 YANGI 원칙을 위배합니다.
- 구현이 하나뿐인 인터페이스는 추상화가 아니며 해당 인터페이스를 구현하는 구현 클래스보다 결합도가 낮지 않습니다. 이러한 인터페이스에 대한 향후 구현을 예상하면 YANGNI 원칙을 위배합니다.
- 구현이 하나뿐인 인터페이스를 사용하기에 타당한 이유는 목을 사용하기 위한 것뿐입니다. 비관리 의존성에만 사용하고 관리 의존성은 구체 클래스를 사용합니다.
- 프로세스 내부 의존성에 대해 구현이 하나뿐인 인터페이스는 좋지 않습니다. 이러한 인터페이스는 목을 사용해 도메인 클래스 간 상호작용을 확인하게 되고, 테스트가 코드의 구현 세부 사항에 결합됩니다.
- 도메인 모델을 코드베이스에 명시적이고 잘 알려진 위치에 둡니다. 도메인 클래스와 컨트롤러 사이의 경계가 명확하면 단위 테스트와 통합 테스트를 조금 더 쉽게 구분할 수 있습니다.
- 간접 계층이 너무 많으면 코드를 추론하기 어려워집니다. 간접 계층을 가능한 한 적게 합니다. 대부분 백엔드 시스템은 도메인 모델, 애플리케이션 서비스 계층, 인프라 계층 세 가지만 존재합니다.
- 순환 의존성이 있으면 코드를 이해하려고 할때 알아야 할 것들이 너무 많아집니다. 대표적인 예로 콜백입니다. 값 객체를 도입해 순환을 없애고 호출부에 주는 결과를 값 객체로 반환합니다.
- 테스트에 여러 실행 구절이 있는 것은 올바른 상태가 되기 어려운 프로세스 외부 의존성으로 작동하는 경우에만 타당합니다. 단위 테스트는 프로세스 외부 의존성으로 수행되지 않기 때문에 여러 가지 실행을 해서는 안됩니다. 다단계 테스트는 대부분 엔드 투 엔드 테스트 범주에 속합니다.
- 지원 로깅은 지원 부서나 시스템 관리자를 위한 것이며 애플리케이션의 식별할 수 있는 동작입니다. 진단 로깅은 개발자가 애플리케이션 내부에서 진행되는 작업을 이해하는 데 도움을 주며 구현 세부 사항입니다.
- 지원 로깅은 비즈니스 요구 사항이므로 해당 요구 사항을 코드베이스에 명시적으로 반영합니다. 비즈니스에 필요한 모든 지원 로깅이 나열돼 있는 특별한 DomainLogger 클래스를 도입합니다.
- 지원 로깅을 프로세스 외부 의존성으로 작동하는 다른 기능처럼 취급합니다. 도메인 이벤트를 사용해 도메인 모델의 변경 사항을 추적합니다. 컨트롤러에서 도메인 이벤트를 DomainLogger 호출로 변환합니다.
- 진단 로깅은 테스트하지 않습니다. 지원 로깅과 달리 도메인 모델에서 직접 진단 로그를 남길 수도 있습니다.
- 진단 로깅은 가끔 사용합니다. 진단 로깅을 너무 많이 쓰면 코드를 복잡하게 하고 로그의 신호 대비 잡음 비율이 나빠집니다. 이상적으로는 진단 로깅을 처리되지 않은 예외에 대해서만 사용해야 합니다.
- 항상 모든 의존성을 생성자 또는 메서드 인수를 통해 명시적으로 주입합니다.
