TEST, 고민의 막을 내리다
그토록 배우고 싶었던 Test Code를 코드숨에서 처음 배우고, 정말 이런 저런 생각과 고찰을 많이 했었다.
Mock 객체를 통한 테스트는 코드숨을 한창 진행하고 있던 저 때부터 영 신뢰가 안 갔나 보다.
지금도 테스트에 대해 완전히 마스터한 것은 아니지만 NEXTSTEP 과정을 통해 복잡했던 머릿속이 대부분 정리되면서 한결 편해졌다.
사실 내가 생각하고 기대했던 테스트 코드라는 역할(?)은 Mock 객체를 사용하는 방식보다는, 블랙박스 테스트(인수 테스트)가 더 신뢰할 수 있고 내가 원했던 테스트의 성격이었다.
찝찝하고 간지러웠던 부분이 긁히니, 더욱 더 각각의 테스트 방식이 어떨 때 사용되면 좋은지와 각각의 장단점이 더 눈에 잘 들어왔다.
따라서 나는 나만의 테스트 전략 방식을 아래와 같이 정리해보았다.
- 외부 장벽 : 인수 테스트
- 비즈니스 로직(도메인) 테스트 : 단위 테스트
- 유스케이스(Service) 테스트 : 통합 테스트
- Presentation Layer 관련 테스트 : 슬라이스 테스트(ex.
@WebMvcTest
)
- 외부 환경에 의존하는 테스트 : Mock 테스트
- 문서화 테스트 : Mock 테스트
일반적인 신규 개발 프로세스(with. ATDD) 싸이클은 다음과 같이 진행하는 것이 익숙해졌다.
- 인수 테스트 작성 -> Presentation Layer TDD (Mock or 슬라이스 테스트) -> 도메인 TDD (단위 테스트) -> 애플리케이션 TDD (통합 테스트)(optional)
내가 이해한 TDD의 싸이클은 아래와 같다.
- 테스트 작성 -> 테스트 실패 -> 프러덕션 코드 작성 -> 테스트 성공 -> 프러덕션 코드 리팩토링 -> 테스트 코드 리팩토링
물론 실무에서 운영할 때는 TDD를 사용하긴 어려울 수도 있고, 생산성이 저하될 수도 있다.
하지만 굳이 TDD를 하진 않아도 배포가 나가기 전에는 주요 비즈니스 로직에 대한 테스트와 버그가 났던 부분에 대한 테스트는 계속해서 쌓아나가는 것이 소프트웨어의 품질을 높이고, 비교적 야근을 덜 하는 방법(?)이지 않을까 싶다.
(물론 QA 분들이 귀신같이 버그를 사전에 잡아주시지만...)
자! 이제 1주차에 인수 테스트에 대해 정리해보았으니, 아래에서는 각 부품들이 자기의 기능대로 잘 돌아가는지 확인해주는 단위 테스트에 대해서 정리해보겠다.
단위 테스트
- 작은 코드 단위(조각)를 검증
- 빠르게 수행할 수 있다
- 격리된 방식으로 처리된다
Test Double (가짜 객체)
실제 객체 대신 사용되는 모든 종류의 가짜 버전으로 대체되는 객체를 통칭
(ex. 클래스, 함수)
Stub
- 실제 의존 객체의 동작은 정상적으로 잘 된다고 가정하여 기대하는 응답값을 세팅한 후, 실제 테스트하고 있는 객체가 정상적으로 돌아가는지 테스트하는 방식 (Mocking 라이브러리 사용)
- 즉 의존하는 부분을 제외한 본래 테스트할 객체의 기능이 제대로 동작하는지만 집중해서 검증
- 다른 의존 객체는 잘 동작했을 때의 기대하는 값을 직접 정의해줌
- 상태를 검증한다 → 어떤 메서드를 호출할 때 어떤 리턴이 돌아오는지를 정해주는 것
Fake
- 테스트용 가짜 객체를 새로 생성하는 방식 (코드로 직접)
- 테스트 환경에서 본래 프러덕션 객체가 아닌 Fake 객체를 주입해서 원래 동작처럼 사용할 수 있도록 Fake를 구현함
- ex) 보통 MemberDAO는 DB를 직접 바라보지만 FakeMemberDAO는 HashMap을 통해 데이터를 I/O함
Mock
- 어떤 객체가 몇번 호출됐는지, 또는 호출됐는지 안됐는지를 확인하는 방식
- Mock 객체는 어떤 메서드가 몇번 호출됐는지에 대한 정보를 가지고 있음
- Mock과 Stub의 개념은 분리해서 생각하자!
Mockito 라이브러리
- Mockito에서 제공하는
mock(xxxx.class)
메서드는 mocking을 하는 것이 아닌 Test Double을 사용하기 위한 객체(프록시 느낌)를 만드는 느낌
- 따라서
mock(xxxx.class)
으로 선언된 객체는 Stubbing(의존 객체 행위 정의)과 Mocking(호출 횟수 검증)을 둘 다 사용할 수 있다
@ExtendWith(MockitoExtension.class)
- 스프링이랑 관련이 없음
- Mockito 자체적으로 컨테이너를 만드는 느낌
@Mock private MemberRepository memberRepository;
- 위 애노테이션이 설정된 클래스에서 생성된 컨테이너 안에
@Mock
으로 설정된 MemberRepository
를 주입해주는 느낌
@Mock
vs @*InjectMocks*
@Mock
은 모의 객체를 생성
@InjectMocks
는 테스트 해야되는 클래스에 선언
Sociable vs Solitary
- 통합(Sociable) : 협력 객체를 실제 객체를 사용하여 테스트
- 실제 객체 사용 시 협력 객체의 행위는 협력 객체가 스스로 정의함
- 따라서 협력 객체의 상세 구현을 알 필요가 없지만, 협력 객체의 정상 동작 여부에 영향을 받아 협력 객체의 오류 발생 시 해당 테스트도 같이 깨지는 단점
- 근데 어쩔 수 없이 의존해야 하는 객체라면 테스트가 깨지는게 오히려 맞는 거 아닐까 라는 생각
- 고립(Solitary) : 가짜(Mock) 협력 객체를 사용하여 테스트
- 가짜 객체 사용 시 협력 객체의 행위는 테스트에서 직접 정의해주어야함
- 따라서 가짜 객체를 사용하면 테스트 대상을 검증할 때는 철저하게 외부 요인으로부터 격리되지만… 테스트에서 협력 객체의 상세 구현을 알아야 한다
- 이 말은 즉 프러덕션 코드의 상세 구현이 변경된다면 테스트도 바로 깨진다..
- 프러덕션 코드 수정 시 테스트 코드도 수정이 일어나야 함 (최대 단점)
Classist vs Mockist
- Classist : 실제 객체 사용 (It's me!)
- 테스트를 격리해야되는 대상은 코드가 아닌 또 다른 테스트
- 따라서 Inside-Out 방식을 사용 → 의존하는 협력 객체가 실제 존재해야 함으로
- Mockist : 가짜 객체 사용
- 테스트 대상을 협력 객체로부터 완벽한 격리
- 따라서 Outside In 방식을 사용 → 상위 레벨 테스트부터 시작하여 테스트 더블을 통해 협력 객체의 예상 결과를 정의하면서 점차 내려감
각 장단점
- Outside In : TDD가 익숙하지 않거나 도메인에 대한 이해도가 높지 않는다면 추천
- 단 프러덕션 코드에 의존적인 테스트일 수 밖에 없음 → 깨지기 쉬운 테스트
- Inside Out : 도메인 설계가 충분히 이루어진 다음 진행 가능
- TDD 사이클을 이어나가기 꽤 어렵지만, 프러덕션 코드에 덜 의존적인 테스트가 작성됨
- 굳이 한 가지를 고집하지 말고 상황에 맞게 적절히 섞어 쓰자
- 캔트백 형님 : TDD는 아는 것에서 모르는 것으로의 방향을 따르는 것이 합리적이다
결론
- Top-Down 으로 방향 잡고 Bottom-Up으로 구현
- 인수 테스트를 먼저 작성하여 요구사항과 기능 전반에 대한 이해를 먼저 진행한 후 내부 구현에 대한 설계 흐름을 구상한다
- 설계가 끝나면 도메인부터 차근차근 Inside-Out 방식으로 TDD를 통해 기능을 구현한다
- 만약 도메인이 이해가 안되거나 복잡하다면 이해하고 있는 부분 부터 기능 구현해라
- 감이 안잡힌다면 그냥 API쪽부터 설계해도 된다
인수테스트와 Presentaion 테스트
- Interceptor나 Handler ArgumentResolver를 사용할 때는 인수 테스트만으로는 부족할 수 있다
- 이럴 때는 Presentation Layer의 단위테스트를 진행하여 테스트의 구멍을 보완하는 것도 좋은 방법이다
- 모든 Presentation 단위테스트를 하지는 않지만, API 문서화를 위한 테스트를 Presentation Layer 테스트로 활용하기도 함
- 하지만 굳이 내가 잘 알고 있고, 검증이 필요 없는 테스트는 뛰어 넘어야 좋다 (실용성)
테스트에서만 사용되는 프러덕션 코드
- 테스트만을 위한 프러덕션 코드는 최대한 지양해야겠지만, 효율성을 위해 조금은 제한적으로 사용할 수도 있음
- 예를 들어 테스트 코드에서 검증을 위해 너무 많이 돌아가야되는 상황이라면 해당 검증에 필요한 메서드 하나를 생성함으로써 효율성을 챙김
- 하지만 개인적으로는 테스트를 위한 프러덕션 코드는 최대한 지양할 것 같으며, fixture에서 id 생성 같은 일들은
ReflectionTestUtils
를 활용하면 될 듯함
각 테스트 전략의 특징
- 인수 테스트 : 요구 사항에 만족하는지 확인하는 테스트
- 전반적인 시나리오를 이해하고 큰 그림을 그릴 수 있음
- 단위 테스트 : 특정 로직들이 잘 구현되어있는 확인하는 테스트
- 세부적인 그림을 그려나갈 수 있음
- 단, 외부 환경(ex. DB)과 유기적으로 잘 동작하는지 테스트는 불가능
- E2E 테스트 : 실제 동작을 전체적으로 테스트 할 수 있음
- 단, 실제 외부 환경과 서비스를 띄워서 테스트를 실행하면 너무 오래 걸림
- 테스트 비용이 높음
- 통합 테스트 : 단위와 단위가 잘 어우러져 잘 동작하는지 검증하는 테스트 (ex. DB)
- 서로 독립된 단위들이 유기적으로 잘 돌아가는지 테스트
차이점
- 인수 테스트: 다른 기능을 구현하면서, 기존의 기능에 대한 사이드 이펙트를 검증하는 회귀 테스트를 진행할 수 있는 장점
(시나리오가 바뀌지 않는 이상은 구현이 바뀌더라도 테스트 코드는 바뀌지 않음)
- 단위 테스트 : 세부 구현이 바뀌면 테스트 코드도 바뀌어야 함
- 따라서 중복된 테스트 코드를 작성할 수는 있어도 테스트의 목적 자체는 다르니까 중복된 테스트가 발생할 수 있음 (구현이 같을수도 있지만, 나중에 해당 부분에 대해 변경이 생긴다면 테스트 코드가 서로 달라짐)