본문 바로가기
독서/자바,스프링 개발자를 위한 실용주의 프로그래밍

14. 테스트 대역

by Thinking 2024. 11. 17.

테스트 대역에서 말하는 대역은 대역폭 같은 것이 아닙니다. 말 그대로 영화에 나오는 스턴트맨, '대역' 그 자체를 의미하죠. 즉, 오롯이 테스트를 위해 만들어진 가짜 객체나 컴포넌트를 가리키는 용어입니다. 실제 객체를 대신해서 행동하고 실제 객체가 하지 못하는 일을 대신합니다.

 

예로 메서드를 호출한 결과로 제대로 된 상태 값이 반영됐는지만 확인하고 싶습니다. 테스트를 실행할 때마다 메일이 실제로 발송되지 않았으면 한다고 가정하죠. 이럴 때 테스트 대역을 사용할 수 있습니다. 메일을 발송하는 컴포넌트인 VerificationEmailSender 대역을 만들고 테스트할 때 UserService가 이를 사용하게 만든다고 생각해 봅시다.

 

DummyVerificationEmailSender 컴포넌트가 대역처럼 동작하기 위해 VerificationEmailSender 인터페이스를 상속합니다. 그리고 여기서 Dummy 컴포넌트의 send 메서드 안에는 아무런 동작이 없다고 했을 때 이렇게 만들어진 대역은 어떻게 사용이 가능할까요?

 

UserService를 생성할 때 메일을 보내는 컴포넌트 객체인 verificationEmailSender 자리에 방금 생성한 dummyverificationEmailSender 클래스의 객체를 주입합니다. 이렇게 주입하는 객체에 테스트 대역을 넣어 사용할 수 있습니다. 이 효과는 매우 훌륭한데, 이제 UserService.register 메서드를 실행해도 메일을 보내지 않습니다. dummyverificationEmailSender 클래스의 send 메서드는 아무런 동작도 하지 않기 때문이죠.

 

그래서 이때 실제 구현체가 들어가야 할 verificationEmailSender 자리에 대신 들어간 dummyverificationEmailSender 객체를 테스트 대역이라고 합니다. 즉, 테스트 대역은 말 그대로 실제 객체를 대신하는 객체를 뜻합니다.

 

API 호출을 해야 테스트할 수 있을 것처럼 보였던 코드가 API 호출 없이도 테스트할 수 있게 되었고, 앞선 테스트 피라미드를 떠올리면 대형 테스트로 작성돼야 할 것처럼 보였던 테스트가 소형 테스트로 작성할 수 있게 됐다는 뜻이기 때문입니다. 하지만 여기서 질문, '이렇게 작성한 테스트가 실효성이 있나'라는 생각을 했을 텐데, 실제 코드를 대변하지 않기 때문입니다.

 

맞는 말입니다. 실제 UserService 컴포넌트는 메일을 발송하겠지만 우리의 테스트 코드는 메일을 발송하지 않죠. 게다가 실제 컴포넌트보다 간단한 코드로 작성돼 있습니다. 실제 상황을 테스트했다고 볼 수 없죠. 하지만 의미가 없지는 않아요. 메일을 발송하는 부분 외 나머지 UserService.register 메서드의 동작을 잘 검사하기 때문이죠.

 

테스트 대역의 장점은 이뿐만이 아닙니다. 개발자가 테스트를 위한 격리되고 고정된 환경을 만들 수 있습니다. 즉, 테스트 대역을 이용하면 복잡한 시스템의 테스트 환경을 예측 가능하게 만들 수 있다는 의미입니다. 테스트 대역을 활용해 외부 세계를 정상적인 상황, 장애 상황, 타임아웃 상황 등으로 연출할 수 있죠. 각 상황에서 시스템이 어떻게 동작해야 할지도 검증할 수 있게 되고, 이는 매우 큰 장점입니다.

 

테스트 대역을 남용하면 테스트가 점점 실제 구현과 거리가 멀어지는 경향도 있습니다. 실제 구현과 테스트에 거리감이 생겨 테스트가 시스템을 제대로 검증하지 못하는 상황은 원하지 않기에, 밸런스가 가장 중요합니다. 대역의 종류가 다양한데, '어떻게 동작하느냐'에 따라 구분됩니다.

1) Dummy : 아무런 동작을 하지 않습니다.

2) Stub : 지정한 값만 반환합니다.

3) Fake : 자체적인 로직이 있습니다.

4) Mock : 아무런 동작을 하지 않습니다. 대신 어떤 행동이 호출됐는지를 기록합니다.

5) Spy : 실제 객체와 똑같이 행동합니다.

 

 

14.1 Dummy

가장 간단하고 뚜렷한 목적을 지닌 대역입니다. Dummy의 역할을 아무런 동작도 하지 않는 것이죠. Dummy 객체는 오롯이 코드가 정상적으로 돌아가게 하기 위한 역할만 합니다. 그리고 특정 행동이 일어나지 않게 만드는 데 사용됩니다. 앞서 보여준 예시 중 send 메서드 같은 경우이죠. 사용하는 경우로는 멤버 변수에 주입될 때, 필수 매개변수가 포함된 메서드를 호출해야 하는 경우에도 사용할 수 있습니다.

 

예로 SomethingFilter 클래스는 요청의 "giveMe" 어트리뷰트가 "text"이면 응답의 ContentType을 "text/plain"으로 만듭니다. 해당 doFilter 메서드가 제대로 동작하는지 테스트하고 싶을 때 어떻게 해야 할까요?

 

SomethingFilter 클래스를 직접 인스턴스화하고 doFilter 메서드를 실행하기 위해 doFilter 메서드가 어떤 방식으로 메시지를 받고 있는지 생김새를 보죠. doFilter 메서드는 servletRequest, servletResponse, filterChain을 매개변수로 받습니다. 그리고 servletRequest, servletResponse는 SomethingFilter.doFilter 메서드를 실행하는 데 필요한 매개변수입니다.

 

filterChain은 살짝 논외인데, SomethingFilter 클래스가 책임 연쇄 책임을 따르고 있다는 이유 하나만으로 매개변수에 포함되었기 때문이죠. 즉, filterChain은 doFilter 메서드의 맨 마지막 filterChain.doFliter 코드를 실행하기 위해 존재할 뿐입니다. 관심사 밖이죠.

 

실제로 관심 있는 것은 filterChain.doFilter 코드 이전의 동작이지만, SomethingFilter 클래스의 doFilter 메서드를 실행하기 위해서 filterChain의 값이 매개변수로 의무적으로 들어가야 합니다. 바로 이 상황에서 Dummy를 사용할 수 있죠. 메서드 호출 시 사용되는 매개변수에도 사용이 가능하고, 도메인 객체를 사용하는 어디서든 사용이 가능합니다.

 

 

14.2 Stub

흔히 이야기하는 원본, 사본 같은 문서의 종류 중 하나이죠. 사본과 거의 같은 개념으로 봐도 됩니다. Stub은 원본을 따라한 부분과 마찬가지로 실제 객체의 응답을 최대한 비슷하게 따라 하는 대역입니다. 응답을 원본과 똑같이 반환하는 데만 집중합니다. 즉, 원본의 응답을 복제해 똑같은 응답으로 미리 준비하고 이를 바로 반환합니다.

 

Dummy처럼 실제 구현체의 코드를 실행하지 않는다는 점에서 유사하지만 Dummy보다는 조금 더 발전된 형태입니다. Dummy는 아무런 동작도 하지 않지만, Stub은 개발자가 의도한 미리 준비된 값을 반환하기에 테스트가 원하는 방향으로 동작할 수 있게 합니다.

 

테스트를 작성하다 보면 어떤 객체의 메서드 호출 결과가 뻔한 것에 비해 동작이 지나치게 복잡한 경우가 있습니다. 예로, 외부 서버에 API 요청을 보내는 작업을 생각해 봅시다. API 요청은 네트워크 자원을 사용하는 엄청난 고연산 작업입니다. 실제로 API 요청을 하는 것이 아니라 응답이 {"content" : "pong"}으로 온다고 가정하고 테스트를 작성하는 편이 나을 때 사용하면 좋죠.

 

JPA 같은 저장소에서 findById를 이용해 값을 불러올 때, 이는 디스크 I/O와 네트워크 호출이 발생할 수 있는 고연산 작업입니다. 아예 이를 발생시키지 않고 처음부터 제대로 값을 불러온 것을 가정한 채로 테스트를 작성하는 편이 더 나을 수 있습니다. 또는 이메일 인증을 받고 있으니 이메일 중복 체크 로직을 넣었다고 가정해 보죠.

 

회원가입 시 이메일과 일치하는 사용자가 이미 저장소에 저장돼 있지는 않은지 findByEmail 메서드를 호출해서 확인합니다. 2가지 테스트를 만들 수 있죠. 하나는 사용자가 이미 있는 상황, 하나는 없는 상황입니다.

1) findByEmail 메서드 호출 결과, 이메일이 일치하는 사용자를 찾았다면 UserService.register 메서드를 호출했을 때 DuplicatedEmailException이 발생한다.

2) findByEmail 메서드 호출 결과, 이메일이 일치하는 사용자를 못 찾으면 UserService.register 메서드를 호출했을 때 사용자를 저장하고, 저장된 사용자 정보를 반환한다.

 

그러면 앞서 말한 상황이 생기죠. 각 테스트 상황에서 userRepository.findEmail 메서드에 대해 기대하는 응답이 너무나 명확하죠. 데이터가 있다면 사용자 정보가 들어간 Optional 값을 반환, 데이터가 없다면 빈 Optional 값을 반환해야 합니다. Stub은 객체를 대체하기 위한 대역임에도 객체를 대신하기보다 메서드의 동작을 바꾸는 것으로 여겨질 때가 많습니다. 그래서 메서드 Stub이라는 용어로 불리기도 하죠.

 

 

14.3 Fake

Dummy가 아무런 동작도 하지 않고, Stub은 미리 준비된 값을 반환하고 끝이라면, Fake는 한 단계 더 발전된 유형의 테스트 대역입니다. 테스트를 위한 자체적인 논리를 갖고 있죠. 앞에서 UserRepository 인터페이스를 대상으로 Stub을 생성했습니다. 테스트를 작성하며 이처럼 모든 테스트에 Stub을 사용하는 코드를 넣기란 너무나 힘든 일입니다. 테스트는 최대한 간결하고 보자마자 이해 가능한 형태로 작성해야 합니다.

 

UserRepository 역할의 대역으로 사용될 객체인데, 데이터 저장을 위해 간단한 메모리 변수를 갖고 있게 하는 것입니다. UserRepository 인터페이스에 읽기/쓰기 요청이 왔을 때 이 요청을 메모리 변수에 쓰고 불러오게 합니다. 그렇게 한다면 데이터베이스의 동작을 메모리 수준에서 흉내 낼 수 있을 것입니다.

 

잘 만들어진 Fake는 로컬 환경의 서버 구성도 변화시킬 수 있을 만큼 강력합니다. 로컬 환경에서 데이터베이스와 연동하지 않고도 서버를 구동시킬 수 있는 것이죠.

 

위처럼 테스트 대역을 사용했더니 중형, 대형 테스트처럼 보였던 테스트가 소형 테스트로 바뀌었습니다. 애플리케이션 서비스를 테스트하는 코드임에도 소형 테스트입니다. 다시 강조하지만 대형, 중형, 소형 테스트는 테스트하려는 대상에 따라 결정되는 것이 아닙니다. 테스트 환경을 어떻게 구성하느냐에 따라 결정됩니다.

1) 소형 테스트가 중요합니다.

2) 소형 테스트를 위해서는 도메인이 비즈니스 로직을 처리하는 것이 좋습니다.

3) 테스트 대역을 이용하면 중형/대형 테스트도 소형 테스트로 만들 수 있습니다.

 

 

14.4 Mock

주로 메서드 호출이 발생됐는지 여부를 검증하는 역할을 합니다. Mockito라는 테스트 프레임워크에 관해 들어봤을 것입니다. 자바로 작성된 오픈소스 테스팅 프레임워크로서, 테스트 대역을 쉽게 만들 수 있게 지원하는 역할을 합니다. 구현이 없는 객체인 Dummy를 만들 수 있고, 특정 객체의 메서드 호출을 시뮬레이션하는 객체(Stub)를 만들 수도 있습니다.

 

엄밀하게 말하면, 테스트 대역과 Mock은 다릅니다. Mock이 테스트 대역의 부분집합일 뿐이죠. 더 자세히 Mock은 '메서드 호출 및 상호작용을 기록하고, 실제로 상호 작용이 일어났는지, 어떻게 상호 작용이 일어났는지를 확인하는 데 사용되는 객체'를 말합니다.

1) Mock은 메서드 호출 및 상호 작용을 기록한다.

2) Mock은 어떤 객체와 상호 작용이 일어났는지 기록한다.

3) Mock은 어떻게 상호 작용이 일어났는지 기록한다.

 

'상호 작용'이라는 말은 무엇일까요? 테스트를 검증하는 데 사용할 수 있는 2가지 테스트 접근 방식이 있습니다. 바로 상태 기반 검증과 행위 기반 검증이라는 개념이죠. 이를 먼저 살펴보죠.

 

 

14.4.1 상태 기반 검증

테스트의 검증 동작에 상태를 사용하는 것을 의미합니다. 즉, 상태 기반 검증으로 동작하는 테스트에서는 테스트를 실행한 후 테스트 대상의 상태거 어떻게 변화됐는지를 보고 테스트 실행 결과를 판단합니다. 테스트 이후 객체의 상태 변화에 주목하죠.

 

 

14.4.2 행위 기반 검증

테스트의 검증 동작에 메서드 호출 여부를 보게 하는 것을 의미합니다. 즉, 행위 기반 검증으로 동작하는 테스트에서는 테스트 대상이나 협력 객체, 협력 시스템의 메서드 호출 여부를 봅니다. 이를 협력 객체와 상호 작용했는지 확인한다고 해서 '상호 작용 테스트'라고도 합니다. 결국 테스트에서 말하는 상호 작용이란 객체 간의 협력이며, 이는 곧 협력 객체의 메서드 호출을 의미합니다.

 

 

14.4.3 상태 기반 vs 행위 기반

상호 작용 테스트란 행위 기반 검증을 하는 테스트라는 것도 이해했습니다. 상태 기반 검증시스템의 내부 데이터 상태를 검증하는 테스트할 수 있습니다. 행위 기반 검증은 주로 시스템의 내/외부 동작을 검증하는 테스트라 할 수 있죠. 두 접근 방식은 시스템을 서로 다른 측면서에 시스템 품질을 보장하는 데 사용되기에 잘 사용해야 합니다.

 

하지만 행위 기반 검증은 사실상 알고리즘을 테스트하는 것과 같기 때문이죠. 때문에 상호 작용 테스트가 많아지면 시스템 코드가 전체적으로 경직될 수 있습니다. 테스트가 논리를 검증하고 있으므로, 테스트 대상이 현재 코드 외에 다른 방법으로 개발하는 것이 불가능하기 때문이죠. 그러므로 가급적 테스트는 상태 기반 검증으로 작성하는 편이 좋습니다. 그렇게 해야 테스트를 책임 단위로 바라볼 수 있게 됩니다.

 

아키텍트 입장에서 관심 있게 봐야 하는 것은 객체에 어떤 지령을 내렸을 때 '객체가 이 목표를 제대로 달성했는가?'입니다. 반면 상호 작용 테스트는 '어떻게 목표를 달성해 왔는가?'에 집중합니다. 다시 Mock으로 돌아와서 Mock은 개념적으로 메서드 호출 및 상호 작용을 기록하고, 실제로 상호작용이 일어났는지, 어떻게 상호작용이 일어났는지 확인하는 데 사용되는 객체라고 했습니다.

 

예로, 이번에는 '실제로 메일을 발송하는 메서드가 실행되는가?' 궁급합니다. VertificationEmail.Sender.send() 메서드가 실제로 호출되는지 확인하고 싶습니다. MockVertificationEmailSender 클래스의 send 메서드 구현을 봅시다. 해당 메서드가 호출되면 멤버 변수 isSendCalled를 true로 변경합니다. 그리고 isSendCalled가 public으로 선언된 것에 주목합니다. 덕분에 우리는 테스트 검증 단계에서 이 값이 어떻게 변했는지 확인할 수 있죠. 이렇게 작성된 MockVertificationEmailSender를 가리켜 Mock이라고 부릅니다. 말 그대로 메서드 호출을 기록하고 상호작용이 일어났는지 판단하는 대역입니다.

 

 

14.5 Spy

개념적으로 Spy는 상호 작용을 검증하는 데 주로 사용합니다. Mock과 역할이 비슷하다고 볼 수 있죠. 하지만 결정적인 차이점으로 바로 '내부 구현이 진짜 구현체인가, 가짜 구현체인가'의 차이입니다. Mock으로 만들어진 객체는 기본적으로 모든 메서드 호출이 Dummy 또는 Stub처럼 동작합니다. 반면 Spy로 만들어진 객체는 기본적인 동작이 실제 객체의 코드와 같습니다. Spy는 실제 객체와 구분할 수 없죠.

 

Spy는 실제 객체의 메서드 구현에 메서드 호출을 기록하는 부수적인 기능들이 추가되는 것이라 생각하면 좋습니다. (물론 Stub으로 일부 메서드를 만들어 특정 메서드 호출에 대해 고정된 응답만 돌려주도록 만들 수 있습니다.)

 

Spy를 구현하는 가장 간단한 방법은 상속을 이용하는 것입니다. SpyUserRepository 클래스가 인터페이스인 UserRepository를 구현하는 것이 아니라 UserRepositoryImpl 컴포넌트를 상속하는 것에 주목해주세요. 덕분에 UserRepository 인터페이스를 구현하지 않아도, UserRepositoryImpl 컴포넌트에 상응하는 실제 구현 정보를 모두 가집니다.

 

또 다른 방법으로 프록시 패턴을 사용하는 것이 있습니다. 앞처럼 SpyUserRepository 클래스가 상속이 아닌 UserRepository 인터페이스를 구현하고 있습니다.  대신 실제 객체인 UserRepositoryImpl을 멤버 변수로 가지고 있습니다. 내부 구현은 실제 객체의 메서드를 호출하게 해서 테스트 대역의 동작이 실제 객체의 동작과 같게 하고 있습니다. 이것이 귀찮다면? Mockito를 이용해서도 작성이 가능합니다.

 

 

14.6 정리

테스트 대역들이 각각 다른 목적과 동작을 가지고 있으며, 테스트의 목적과 요구사항에 따라 달리 선택될 수 있다는 것도 알게 됐습니다. Mockito를 다루는 능력은 '기술'입니다. 조금만 환경이 변해도 익힌 기술의 이점이 사라지기 때문이죠. 기술을 숙달하기 전에 이러한 기반 지식은 반드시 숙지해야 합니다.

 

테스트 대역을 잘 사용하려면 추상화가 잘 돼 있어야 합니다. 그리고 의존성 역전도 잘 적용돼 있어야 합니다. 만약 UserRepository 인터페이스가 JpaRepository 인터페이스에 의존해 있었다면 어떻게 됐을까요? 첫 번째로 테스트 대역을 순수 자바 코드로 작성하기 어려웠을 것이죠. 왜냐하면 JpaRepository 인터페이스와 그 상위 인터페이스에 구현된 모든 public 메서드를 구현해야 했기 때문이에요.

 

물론 Mocito 같은 프레임워크를 사용해 테스트 대역을 만들었다면, 크게 문제가 안됩니다. 하지만 같은 문제를 해결하더라도 설계가 잘 잡혀 있어 프레임워크의 도움 없이도 문제를 해결할 수 있는 경우와, 설계가 제대로 돼 있지 않아 무조건 프레임워크의 도움을 받아야만 문제를 해결할 수 있는 경우는 분명 다릅니다.

 

둘째로 JPA에 의존하는 형태라면, 테스트마저 JPA에 의존하는 형태로 작성됐을 것입니다. 시스템의 검증 방법조차 특정 라이브러리에 의존하는 방식으로 개발될 경우 시스템이 해당 라이브러리와 더 강하게 결합하는 형태가 됩니다. 이는 옳지 않은 방향이죠.