////
Search
Duplicate
🃏

8. 통합 테스트를 하는 이유

8장에서 다루는 내용
통합 테스트의 역할 이해
테스트 피라미드의 개념 자세히 살펴보기
가치 있는 통합 테스트 작성
단위 테스트에만 사용해선 시스템이 정상적으로 동작하는지 확신할 수 없다.
단위 테스트는 비즈니스 로직을 확인하는 것에는 좋지만 비즈니스 로직을 외부와 독립된 상태에서 확인하는 것만으로는 시스템의 신뢰성을 확보할 수 없다.
각 부분이 데이터베이스나 메시지 버스 등의 외부 시스템과 어떻게 통합되는지 확인해야 한다.
이 장에서는 통합 테스트의 역할, 즉 언제 적용해야 하는지와 일반적인 단위 테스트, 빠른 실패 원칙과 같은 다른 기법에 의존하는 것이 좋을지 등을 알아본다.
프로세스 외부 의존성 중에서 어느 것을 통합 테스트에서 사용하고 모킹할지 알 수 있다.
도메인 모델 경계를 명시하고 애플리케이션 계층을 줄이고 순환 의존성을 제거하는 등 코드를 개선하는 데 도움이 되는 통합 테스트 모범 사례도 찾아볼 것이다.
마지막으로 구현이 하나 뿐인 인터페이스 사용을 자제해야하는 이유와 로깅 기능을 언제, 어떻게 테스트하는지 알아본다.

1. 통합 테스트는 무엇인가?

통합 테스트는 테스트에서 중요한 역할을 하며 단위 테스트와 균형을 맞추는 것이 중요하다.
먼저 통합 테스트와 단위 테스트의 차이점을 알아보자.
1.
통합 테스트의 역할
통합 테스트는 다음 세 가지 요구 사항 중 하나라도 충족하지 않는 테스트다.
단일 동작 단위를 검증하고
빠르게 수행하며
다른 테스트와 별도로 처리한다.
통합 테스트는 시스템이 프로세스 외부 의존성과 통합해 어떻게 동작하는지를 검증한다. 다시 말해 이 테스트는 컨트롤러 사분면에 속하는 코드를 테스트한다.
단위 테스트는 도메인 모델을 다루므로 코드 복잡도를 테스트하는 반면 통합 테스트는 프로세스 외부 의존성 즉 협력자를 주로 다룬다.
2.
다시 보는 테스트 피라미드
통합 테스트는 다음과 같은 장, 단점들을 가진다.
단점
통합 테스트는 프로세스 외부 의존성에 의존하므로 느리며 유지비용이 증가한다.
프로세스 외부 의존성 세팅이 필요하다
관련된 협력자가 많아서 테스트가 커진다.
장점
코드를 더 많이 수행하므로 회귀 방지가 단위 테스트보다 우수하다.
진정한 동작 단위를 검증하므로 제품 코드와의 결합도가 낮아서 리팩토링 내성도 우수하다.
3.
통합 테스트와 빠른 실패
통합 테스트에서 프로세스 외부 의존성과의 상호 작용을 모두 확인하려면 가장 긴 시나리오를 선택하라.
부득이하다면 외부 시스템과의 통신을 모두 확인하는데 필요한만큼 통합 테스트를 추가로 작성하면 된다.

2. 직접 테스트해야 하는 프로세스 의존성은 무엇인가?

통합 테스트는 시스템이 프로세스 외부 의존성과 어떻게 통합되는지를 검증한다.
이러한 검증을 구현하는 방식은 다음과 같이 두 가지 방법이 있다.
실제 프로세스 외부 의존성을 사용한다.
해당 의존성을 목으로 대체한다.
1.
프로세스 외부 의존성의 두 가지 유형
모든 프로세스 외부 의존성은 두 가지 범주로 나뉜다.
관리 의존성
어플리케이션을 통해서만 접근할 수 있으며 해당 의존성과의 상호작용은 외부에서 확인할 수 없다.
대표적인 예로 데이터베이스가 있다. 외부 시스템은 보통 데이터베이스에 직접 접근하지 못하고 어플리케이션에서 제공하는 API를 통해 접근한다.
비관리 의존성
해당 의존성과의 상호작용은 외부에서 확인 가능하다.
예를 들어 SMTP 서버와 메시지 버스 등이 있다. 둘 다 다른 어플리케이션에서 확인 가능한 사이드 이펙트를 발생시킨다.
앞서 관리 의존성과의 통신은 구현 세부 사항이라고 했다. 반대로 비관리 의존성과의 통신은 시스템의 식별할 수 있는 동작이다.
이러한 차이로 인해 통합 테스트에서 프로세스 외부 의존성의 처리가 달라진다.
관리 의존성은 실제 인스턴스를 사용하고 비관리 의존성은 목으로 대체하라.
비관리 의존성에 대한 통신 패턴을 우리가 마음대로 변경할 수 없는 이유는 하위 호환성을 지켜야하기 때문이다.
이는 모킹이 유용하다. 목을 사용하면 모든 가능한 리팩토링을 고려해서 통신 패턴의 영속성을 보장할 수 있다.
통합 테스트에서 관리 의존성의 실제 인스턴스를 사용하면 외부 클라이언트 관점에서 최종 상태를 확인할 수 있다.
2.
관리 의존성이면서 비관리 의존성인 프로세스 외부 의존성 다루기
때로는 이 둘의 성격을 모두 가지는 프로세스 외부 의존성이 있다.
좋은 예로 다른 어플리케이션도 접근 가능한 데이터베이스가 있다. 아마 MSA 구조의 데이터베이스.. 아닐까?
데이터베이스의 테이블이 팀 간 공유된다면 이는 비관리 의존성으로 취급해야 한다.
이러한 테이블은 사실상 메시지 버스 역할을 하고 각 행이 메시지 역할을 한다.
이러한 테이블은 목을 사용하라. 나머지 데이터베이스를 관리 의존성으로 처리하고 데이터베이스와의 상호 작용을 검증하지 말고 최종 상태를 확인하라
데이터베이스에서 이 두 부분을 분리하는 것이 중요하다.
3.
통합 테스트에서 실제 데이터베이스를 사용하는 것이 불가능하다면?
때로는 통합 테스트에서 관리 의존성을 실제 데이터베이스로 사용할 수 없는 경우도 있다.
관리 의존성임에도 불구하고 목으로 처리해야할까? 불가하다 이는 리팩토링 내성을 약화시키는 행위다.
데이터베이스를 테스트하는 것이 불가능하다면 단위 테스트에 집중하는 것이 좋다.

3. 통합 테스트: 예제

다음 코드에 대한 통합 테스트를 작성한다고 생각해보자.
public class UserController { private readonly Database _database = new Database(); private readonly MessageBus _messageBus = new MessageBus(); public string ChangeEmail(int userId, string newEmail) { object[] userData = _database.GetUserById(userId); User user = UserFactory.Create(userData); string error = user.CanChangeEmail(); if (error != null) return error; object[] companyData = _database.GetCompany(); Company company = CompanyFactory.Create(companyData); user.ChangeEmail(newEmail, company); _database.SaveCompany(company); _database.SaveUser(user); foreach (EmailChangedEvent ev in user.EmailChangedEvents) { _messageBus.SendEmailChangedMessage(ev.UserId, ev.NewEmail); } return "OK"; } }
C#
복사
여기서 시스템은 사용자의 이메일 변경 기능만 수행한다. 나머지는 데이터베이스, 메시지 버스 등의 프로세스 외부 의존성에게 동작을 위임한다.
1.
어떤 시나리오를 테스트할까?
통합 테스트의 일반적인 지침은 가장 긴 주요 흐름과 단위 테스트로 수행할 수 없는 모든 예외 상황을 다루는 것이다.
가장 긴 주요 흐름이란 모든 프로세스 외부 의존성을 거치는 것이다.
CRM 프로젝트에서 가장 긴 주요 흐름은 기업 이메일에서 일반 이메일로 변경하는 것이다. 이 시나리오가 가장 많은 사이드 이펙트를 만들어낸다.
데이터베이스에서 사용자와 회사가 모두 업데이트된다. 사용자는 유형이 변경되며 회사는 직원 수를 변경한다.
메시지 버스로 메시지를 보낸다.
2.
데이터베이스와 메시지 버스 분류하기
두 프로세스 외부 의존성에 대해서 어떤 것을 모킹하고 어떤 것을 실제로 사용할지 결정해보자.
먼저 어플리케이션 데이터베이스는 어떤 시스템도 접근할 수 없으므로 관리 의존성이다. 따라서 실제 인스턴스를 사용해야 한다.
통합 테스트는 데이터베이스에 사용자와 회사를 삽입하고
해당 데이터베이스에서 이메일 변경 시나리오를 실행하며
데이터베이스 상태를 검증하게 된다.
반면 메시지 버스는 비관리 의존성이다. 따라서 통합 테스트는 메시지 버스를 목으로 대체하고 컨트롤러와 목 간의 상호 작용을 검증하게 된다.
3.
E2E 테스트는 어떤가?
E2E 테스트를 작성한다는 것은 모든 프로세스 외부 의존성을 실제 인스턴스로 유지하여 테스트하겠다는 것이다.
통합 테스트는 관리 의존성을 포함시키고 비관리 의존성을 목으로 대체하는 것만으로도 보호 수준이 E2E 테스트와 비슷해지므로 생략할 수 있다.
하지만 배포 후 상태 점검을 위해 한 개 또는 두 개 정도의 중요한 E2E 테스트를 작성할 수 있다.
4.
통합 테스트: 첫 번째 버전
public void Changing_email_from_corporate_to_non_corporate() { // Arrange var db = new Database(ConnectionString); // Database repository User user = CreateUser("user@mycorp.com", UserType.Employee, db); // Creates the user and company in the database CreateCompany("mycorp.com", 1, db); var messageBusMock = new Mock<IMessageBus>(); // Sets up a mock for the message bus var sut = new UserController(db, messageBusMock.Object); // Act string result = sut.ChangeEmail(user.UserId, "new@gmail.com"); // Assert Assert.Equal("OK", result); // Asserts the user’s state object[] userData = db.GetUserById(user.UserId); User userFromDb = UserFactory.Create(userData); Assert.Equal("new@gmail.com", userFromDb.Email); Assert.Equal(UserType.Customer, userFromDb.Type); // Asserts the company’s state object[] companyData = db.GetCompany(); Company companyFromDb = CompanyFactory .Create(companyData); Assert.Equal(0, companyFromDb.NumberOfEmployees); // Checks the interactions with the mock messageBusMock.Verify( x => x.SendEmailChangedMessage( user.UserId, "new@gmail.com"), Times.Once); }
C#
복사
입력 매개변수로 사용한 데이터와 별개로 데이터베이스의 최종 상태를 확인하는 것이 중요하다.
이를 위해 통합 테스트는 검증 영역에서 사용자와 회사 데이터를 각각 조회하고 새로운 인스턴스를 생성한 후에 해당 상태를 검증만 한다.
이렇게 하면 테스트가 읽기 쓰기를 모두 수행하므로 회귀 방지를 극대화시킬 수 있다.
읽기는 컨트롤러에서 내부적으로 사용하는 동일한 코드를 써서 구현해야 한다.
이 정도 코드로도 통합 테스트는 그 역할을 다 했다고 볼 수 있지만, 헬퍼 메서드 등을 이용해 좀 더 개선할 여지가 남아있긴 하다.

4. 의존성 추상화를 위한 인터페이스 사용

단위 테스트 시 가장 많이 오해하는 주제 중 하나가 인터페이스 사용이다.
인터페이스를 둔 이유를 개발자들이 잘못 설명하고 그 결과 남용하게되는 경향이 있다.
이 절에서는 잘못된 이유를 알아보고 인터페이스 사용이 어떤 환경에서 바람직한지 알아본다.
1.
인터페이스와 느슨한 결합
많은 개발자가 데이터베이스나 메시지 버스와 같은 프로세스 외부 의존성을 위해 인터페이스를 도입한다.
심지어 인터페이스의 구현이 하나만 있는 경우도 그렇게 한다.
이 관습은 오늘날 널리 펴져 있어서 아무도 이를 지적하지 않는다.
인터페이스를 사용하는 주 목적으로는 프로세스 외부 의존성을 추상화하여 느슨한 결합을 하게 해주고 기존 코드를 변경하지 않고 새로운 기능을 추가해 OCP를 지키기 때문이다.
이 두 이유 모두 오해에 해당한다.
단일 구현을 위한 인터페이스는 추상화가 아니며 해당 인터페이스를 구현하는 구체 클래스보다 결합도가 낮지 않다.
따라서 인터페이스가 추상화의 역할을 충실히 수행해내려면 최소한 구현이 두 가지는 있어야 한다.
2번째 이유는 YAGNI를 위반하기 때문이다. 현재 필요하지 않은 기능에 불필요한 시간을 낭비하지 말라는 원칙이다.
2.
프로세스 외부 의존성에 인터페이스를 사용하는 이유는 무엇일까?
그렇다면 각 인터페이스에 구현이 하나만 있을때, 프로세스 외부 의존성에 인터페이스를 사용하는 이유는 무엇일까?
간단히 말하자면 목을 사용하기 위함이다. 인터페이스가 없다면 테스트 더블을 만들 수 없으므로 테스트 시 상호작용을 확인하기 힘들다.
따라서 이러한 의존성을 목으로 처리할 필요가 없는한 프로세스 외부 의존성에 대한 인터페이스를 두지 말라.
결국 비관리 의존성만 목으로 처리할 필요가 있으므로 프로세스 외부 의존성 중 비관리 의존성에만 인터페이스를 작성하게 된다.
3.
프로세스 내부 의존성을 위한 인터페이스 사용
때로는 프로세스 외부 의존성뿐만 아니라 내부 의존성도 인터페이스 기반인 코드가 존재한다.
프로세스 외부 의존성과 마찬가지로 도메인 클래스에 대해서 단일 구현으로 인터페이스를 도입하는 이유는 목으로 처리하기 위해서 뿐이다.
프로세스 외부 의존성과 달리 도메인 클래스 간의 상호 작용을 확인해서는 안 된다. 그렇게하면 깨지기 쉬운 테스트로 이어지고 결국 리팩토링 내성이 떨어지게 된다.

5. 통합 테스트 모범 사례

통합 테스트를 활용하게 되는데, 도움이 되는 몇 가지 일반적인 지침이 있다.
도메인 모델 경계 명시하기
어플리케이션 내 계층 줄이기
순환 의존성 제거하기
1.
도메인 모델 경계 명시하기
항상 도메인 모델을 코드에서 명시적이고 잘 알려진 위치에 두도록 하자. 도메인 모델은 프로젝트가 해결할 문제에 대한 도메인 지식의 모음이다.
이 도메인 모델에 대한 명시적인 경계를 잘 지정해두면 코드의 해당 부분을 좀 더 잘 설명하고 이해할 수 있는 강점이 있다.
테스트를 위해 코드를 리팩토링하기 때문에 테스트에도 도움이 된다.
2.
계층 수 줄이기
대부분의 프로그래머는 간접 계층을 늘려 코드를 추상화하고 일반화하려 한다.
애플리케이션에 추상 계층이 너무 많으면 코드를 확인하기 힘들고 간단한 연산이라도 숨은 로직을 이해하기 어려워진다.
간접 계층은 코드는 오히려 각 경계를 모호하게 만든다. 가능한 간접 계층을 적게 사용하는 습관을 들이도록 하자.
3.
순환 의존성 제거
public class CheckOutService { public void CheckOut(int orderId) { var service = new ReportGenerationService(); service.GenerateReport(orderId, this); /* other code */ } } public class ReportGenerationService { public void GenerateReport(int orderId, CheckOutService checkOutService) { /* calls checkOutService when generation is completed */ } }
C#
복사
너무 많은 추상 계층이 문제가 되듯이 순환 의존성은 가독성에 해가 된다.
순환 의존성이 있으면 해결책을 찾기 위한 출발점이 명확하지 않고, 하나의 클래스를 이해하기 위해 주변의 클래스를 한 번 순회해야하기 때문이다.
또한 순환 의존성은 테스트를 방해하는데, 동작을 하나 하나 분리하여 Mock으로 처리하기 위해 인터페이스에 의존해야하는 경우가 빈번해지기 때문이다.
이렇게 인터페이스를 넣으면 단순히 순환 의존성의 문제만 가릴 뿐, 실제 코드의 런타임에는 순환이 존재하는 문제가 있다.
결국 순환 의존성을 해결하려면 이를 제거하는 방법이 최선이다.
예제를 기준으로 보면 ReportGenerationService을 리팩토링하여 CheckOutService나 ICheckOutService에 의존하지 않도록 하고, 작업 결과를 반환하는 방식으로 바꿔야 한다.
좋은 순환 의존성은 존재하지 않는 순환 의존성 뿐이다.
4.
테스트에서 다중 실행 구절 사용
테스트에서 두 개 이상의 arrangeaction, assert 구문을 두는 것은 코드 스멜에 해당한다.
이는 테스트가 단일된 동작 단위를 검증하지 않으므로 유지 보수성을 저해시키고 있다는 신호다.
각 실행을 고유의 테스트로 추출하는 것이 좋다.
이 지침의 예외로 원하는 상태로 만들기 어려운 프로세스 외부 의존성으로
다만 예외적으로 원하는 상태로 만들기가 까다로운 프로세스 외부 의존성인 경우 여러 동작을 하나루 묶어서 처리해야 한다.
이 방법은 프로세스 외부 의존성과의 상호 작용 횟수를 줄이는 효과가 있으므로 어느 정도 타당하다고 볼 수 있다.

7. 결론

식별할 수 있는 동작인지, 구현 세부 사항인지 여부에 대한 관점으로 프로세스 외부 의존성과의 통신을 살펴보자.

요약

통합 테스트는 단위 테스트가 아닌 테스드다. 통합 테스트는 시스템이 프로세스 외부 의존성과 통합해 동작하는 방식을 검증한다.
통합 테스트는 컨트롤러를 다루고 단위 테스트는 알고리즘과 도메인 모델을 다룬다.
통합 테스트는 회귀 방지와 리팩토링 내성이 우수하고 단위 테스트는 유지 보수성과 피드백 속도가 우수하다.
통합 테스트의 기준은 단위 테스트보다 높다.
통합 테스트에서 회귀 방지와 리팩토링 내성 지표에 대한 점수는 단위 테스트보다 높아야 한다.
대부분의 단위 테스트는 빠르면서 비용이 낮아야 하고 시스템이 전체적으로 올바른지 확인하는 통합 테스트는 속도가 느리고 비용이 많이 발생하므로 그 수가 적어야 한다.
단위 테스트를 통해 가능한 많은 비즈니스 시나리오의 예외 상황을 확인해야 한다. 통합 테스트를 사용해서는 하나의 주요 흐름과 단위 테스트로는 확인할 수 없는 예외 상황을 다루도록 하라.
테스트 피라미드의 모양은 프로젝트 복잡도에 따라 달라진다. 간단할 수록 단위 테스트가 적어질 것이다.
빠른 실패 원칙은 버그가 빠르게 나타날 수 있도록 하며 통합 테스트에서 할 수 있는 대안이다.
프로세스 외부 의존성
관리 의존성은 어플리케이션을 통해서만 접근할 수 있는 프로세스 외부 의존성이다.
관리 의존성과의 상호 작용은 외부에서 관찰할 수 없다. 대표적인 예는 어플리케이션 데이터베이스다.
비관리 의존성은 다른 어플리케이션이 접근 가능한 프로스 외부 의존성이다.
비관리 의존성과의 상호 작용은 외부에서 관찰할 수 있다. 대표적인 예는 메시지 버스나 SMTP 서버 등이 있다.
관리 의존성과의 통신은 구현 세부 사항이고 비관리 의존성과의 통신은 식별할 수 있는 동작이다.
통합 테스트에서 관리 의존성은 실제 인스턴스를 사용하고 비관리 의존성은 목으로 대체하라.
때로는 관리 의존성과 비관리 의존성의 성격을 둘다 나타내는 프로세스 외부 의존성도 존재한다.
식별할 수 있는 동작과 구현 세부 사항을 분리해 각각을 목, 인스턴스로 취급해주는 것이 좋다.
통합 테스트는 관리 의존성을 사용하는 모든 계층을 거쳐야 한다.
구현이 하나뿐인 인터페이스는 추상화가 아니다.
이를 사용하기에 타당한 이유는 목을 사용하기 위한것뿐이다. 비관리 의존성에만 이를 적용하고 관리 의존성은 구체 클래스를 두자.
도메인 모델을 코드베이스에 찾기 쉬운 곳에 두라. 도메인 클래스와 컨트롤러 사이의 경계가 명확하다면 단위 테스트와 통합 테스트를 좀 더 쉽게 구분할 수 있다.
간접 계층이 많아지면 코드가 복잡해진다. 가능한 적게 두라, 대부분의 백엔드 시스템은 도메인 모델, 어플리케이션 서비스 계층, 인프라 계층 이 셋만 존재한다.
순환 의존성이 존재하면 뇌가 복잡해진다.
테스트에 여러 실행 구절이 있다는 것은 하나의 독립된 동작 단위가 아님을 의미한다.
이것이 타당하려면 올바른 상태가 되기 어려운 프로세스 외부 의존성으로만 동작하는 경우에만 타당하다.
항상 모든 의존성을 생성자 또는 메소드 인수를 통해 명시적으로 주입하라