-
인프런, 실용적인 테스트 가이드 강의 정리Backend/TEST 2023. 7. 16. 00:41
이번 글은 강의를 보고 내용을 정리한 것인데, 한 3주 전에 강의를 다 듣고 이제야 블로그에 정리를 올리게 되었습니다...
인프런에서 Spring Boot 관련된 테스트 강의는 처음 본것 같은데, 개인적으로 업무에서 테스트 코드를 작성하는 분위기가 아니다 보니 따로 물어볼 사람은 없었고 블로그 등을 통해 알게 된 지식으로 테스트 코드를 작성했었는데, 이번 강의를 계기로 평소에 궁금했던 부분 뿐만 아니라 새로 알게된 내용들도 좀 있어서 저처럼 테스트 코드에 대한 궁금증이나 갈증이 있으신 분들이라면 한번 들어보면 좋을 것 같습니다.
Practical Testing: 실용적인 테스트 가이드 - 인프런 | 강의
이 강의를 통해 실무에서 개발하는 방식 그대로, 깔끔하고 명료한 테스트 코드를 작성할 수 있게 됩니다. 테스트 코드가 왜 필요한지, 좋은 테스트 코드란 무엇인지 궁금하신 모든 분을 위한 강
www.inflearn.com
1. 테스트 코드를 작성해야 하는 이유
테스트 코드를 작성하지 않는다면
- 변화가 생기는 매순간마다 발생할 수 있는 모든 Case를 고려해야 한다.
- 변화가 생기는 매순간마다 모든 팀원이 동일한 고민을 해야 한다.
- 빠르게 변화하는 소프트웨어의 안정성을 보장할 수 없다.
테스트 코드가 병목이 된다면
- 프로덕션 코드의 안정성을 제공하기 힘들어진다.
- 테스트 코드 자체가 유지보수하기 어려운, 새로운 짐이 된다.
- 잘못된 검증이 이루어질 가능성이 생긴다.
올바른 테스트 코드는
- 자동화 테스트로 비교적 빠른 시간 안에 버그를 발견할 수 있고, 수동 테스트에 드는 비용을 크게 절약할 수 있다.
- 소프트웨어의 빠른 변화를 지원한다.
- 팀원들의 집단 지성을 팀 차원의 이익으로 승격시킨다.
- 가까이 보면 느리지만, 멀리 보면 가장 빠르다.
테스트는 귀찮다, 귀찮지만 해야한다.
2. 단위 테스트 (Unit test)
작은 코드 단위를 독립적으로 검증하는 테스트
- 작은 코드란 클래스 or 메서드가 될 수 있다.
- 검증 속도가 빠르고, 안정적이다.
Junit5 : 단위 테스트를 위한 테스트 프레임워크
AssertJ : 테스트 코드 작성을 원활하게 돕는 테스트 라이브러리 → 풍부한 API, 메서드 체이닝 지원 → 가독성 올라감
[ 테스트하기 어려운 영역을 구분하고 분리하기 ]
테스트 하기 어려운 영역이란
→ 관측할 때마다 다른 값에 의존하는 코드
ex) 현재 날짜, 시간, 랜덤 값, 전역 변수, 함수, 사용자 입력…
→ 외부 세계에 영향을 주는 코드
ex) 표준 출력, 메시지 발송, 데이터베이스 기록…
[ 순수함수 ]
- 같은 입력에는 항상 같은 결과
- 외부 세상과 단절된 형태
- 테스트하기 쉬운 코드
아래 예시를 통해 테스트하기 쉬운 코드로 변하는 과정을 볼 수 있다.
- 주문을 생성하는 코드 예시이다.
- 현재 시간을 통해 주문 가능 시간인지 확인하는 비즈니스 로직을 볼 수 있는데
- 이때 첫 번째 메서드의 경우는 내부에서 현재 시간을 생성하고
- 두 번째 메서드의 경우 외부에서 현재 시간을 파라미터로 받고 있는 것을 알 수 있다.
public Order createOrder() { LocalDateTime currentDateTime = LocalDateTime.now(); LocalTime currentTime = currentDateTime.toLocalTime(); if (currentTime.isBefore(SHOP_OPEN_TIME) || currentTime.isAfter(SHOP_CLOSE_TIME)) { throw new IllegalArgumentException("주문 시간이 아닙니다. 관리자에게 문의하세요."); } return new Order(currentDateTime, beverages); } public Order createOrder(LocalDateTime currentDateTime) { LocalTime currentTime = currentDateTime.toLocalTime(); if (currentTime.isBefore(SHOP_OPEN_TIME) || currentTime.isAfter(SHOP_CLOSE_TIME)) { throw new IllegalArgumentException("주문 시간이 아닙니다. 관리자에게 문의하세요."); } return new Order(currentDateTime, beverages); }
- 위 메서드를 테스트한다고 생각해보자
- 이때 createOrder() 메서드에서 현재 시간을 어떻게 처리할 것인가… 이는 테스트를 하는 시간에 따라 테스트의 결과가 바뀔것임을 알 수 있다.
- 따라서 이렇게 테스트하기 어려운 코드가 아닌 현재 시간을 파라미터를 통해 외부에서 받도록 수정함으로써 createOrder(LocalDateTime currentDateTime) 메서드는 테스트하기 쉬운 코드가 되었고, 같은 입력에 항상 같은 결과를 내보내는 순수함수의 역할로 바뀐 것을 알 수 있다.
물론 createOrder 메서드를 사용하는 서비스 레이어에서 currentDateTime을 전달하게 된다면 마찬가지로 해당 서비스 레이어를 테스트하기 어렵게 된다.
이때는 위에서와 동일한 방법으로 컨트롤러 레이어에서 currentDateTime을 주입하도록 전달하게 수정하면 된다.
그렇다면 또 컨트롤러에서 동일한 문제가 발생하는데…
→ 이를 어느정도 선까지 외부에서 주입받도록 코드를 작성할지는 잘 판단해서 정해야 한다
- 아래 코드를 통해 createOrder 메서드를 원하는 시간을 주입하여 테스트 하는 모습을 확인할 수 있다.
- 이처럼 테스트하기 좋은 코드가 결과적으로 좋은 구조를 갖는 코드가 될 확률이 높다
@Test void createOrderWithCurrentTime() { // given CafeKiosk cafeKiosk = new CafeKiosk(); Americano americano = new Americano(); cafeKiosk.add(americano); // when Order order = cafeKiosk.createOrder(LocalDateTime.of(2023, 1, 17, 10, 0)); // then assertThat(order.getBeverages()).hasSize(1); assertThat(order.getBeverages().get(0).getName()).isEqualTo("아메리카노"); } @Test void createOrderOutsideOpenTime() { CafeKiosk cafeKiosk = new CafeKiosk(); Americano americano = new Americano(); cafeKiosk.add(americano); assertThatThrownBy(() -> cafeKiosk.createOrder(LocalDateTime.of(2023, 1, 17, 9, 59))) .isInstanceOf(IllegalArgumentException.class) .hasMessage("주문 시간이 아닙니다. 관리자에게 문의하세요."); }
3. TDD (Test Driven Development)
- 프로덕션 코드보다 테스트 코드를 먼저 작성하여 테스트가 구현 과정을 주도하도록 하는 방법론
- RED → GREEN - REFACTOR 과정을 거치며 프로덕션 코드를 구현한다.
1. RED
실패하는 테스트 작성
2. GREEN
테스트를 통과하는 최소한의 코딩을 진행한다.
이때, 제대로 프로덕션 코드를 구현하는게 목적이 아니다. 초록불을 보는것이 목적이기 때문에
정말 단순히 리턴 값을 하드코딩 해서 구현하는 것도 의미상은 맞는 일이다.
3. REFACTOR
위에서 테스트를 통과하도록 구현한 코드를 개선하며 지속해서 테스트를 통과하도록 유지시키는 과정
- 선 기능 구현, 후 테스트 작성
- 테스트 자체의 누락 가능성
- 특정 테스트(해피 케이스) 케이스만 검증할 가능성
- 잘못된 구현을 다소 늦게 발견할 가능성
- 선 테스트 작성, 후 기능 구현
- 복잡도가 낮은(유연하며 유지보수가 쉬운), 테스트 가능한 코드로 구현할 수 있게 한다.
- 쉽게 발견하기 어려운 엣지 케이스를 놓치지 않게 도와준다.
- 구현에 대한 빠른 피드백을 받을 수 있다.
- 과감한 리팩토링이 가능하다.
TDD는 꾸준한 연습이 핵심인 것 같다. 실제 개인 프로젝트에서 TDD를 도입해서 중간에 프로젝트를 진행했는데, 시간이 정말 너무 오래 걸렸다… 꾸준한 연습만히 제대로 된 TDD를 하게 도와줄 수 있다고 느꼈다.
추가로 테스트 코드를 작성할 때 에러 케이스를 먼저 작성하는 것이 좀 더 취지에 맞다고 느꼈다. 에러 케이스를 먼저 작성해야 자칫 놓칠 수 있는 케이스를 놓치지 않도록 도와준다고 느꼈기 때문에 에러 케이스 부터 테스트 코드를 작성하고 정상 동작 테스트 케이스를 작성하는 방법으로 진행하는 것이 좋아보인다. 클린 코드 책에서도 에러 케이스를 먼저 작성하는 것을 추천했던 것으로 기억한다.
물론 이것은 경험에 의한 생각이고 실제 TDD를 더 자세히 공부해 보면 생각이 달라질 수도 있다
4. 테스트는 문서다.
- 프로덕션 기능을 설명하는 테스트 코드 문서
- 다양한 테스트 케이스를 통해 프로덕션 코드를 이해하는 시각과 관점을 보완
- 어느 한 사람이 과거에 경험했던 고민의 결과물을 팀 차원으로 승격시켜서, 모두의 자산으로 공유할 수 있다.
DisplayName을 섬세하게
- 명사의 나열 보다는 문장으로
- 깔끔하게 작성하기 보다는 조금은 길게 가더라도 이해를 쉽게 할 수 있도록 작성하는 것이 좋아 보인다.
- 테스트 행위에 대한 결과까지 기술하기
- 음료를 1개 추가하면 주문 목록에 담긴다.
- 특정 시간 이전에 주문을 생성하면 실패한다(x) → 영업 시작 시간 이전에는 주문을 생성할 수 없다.
- 도메인 용어를 사용하여 한층 추상화된 내용을 담기 → 메서드 자체의 관점보다 도메인 정책 관점으로
BDD 스타일로 작성하기
- TDD에서 파생된 개발 방법
- 함수 단위의 테스트에 집중하기보다, 시나리오에 기반한 테스트케이스 자체에 집중하여 테스트한다.
- 개발자가 아닌 사람이 봐도 이해할 수 있을 정도의 추상화 수준을 권장
Given : 시나리오 진행에 필요한 모든 준비 과정 (객체, 값, 상태 …)
When : 시나리오 행동 진행
Then : 시나리오 진행에 대한 결과 명시, 검증
5. Spring & JPA 기반 테스트
통합 테스트(Integration test)
- 여러 모듈이 협력하는 기능을 통합적으로 검증하는 테스트
- 일반적으로 작은 범위의 단위 테스트만으로는 기능 전체의 신뢰성을 보장할 수 없다.
- 풍부한 단위 테스트 & 큰 기능 단위를 검증하는 통합 테스트
Persistence Layer 테스트
- @SpringBootTest vs @DataJpaTest
- 두 가지 어노테이션을 이용할 수 있는데 강사분은 @SpringBootTest 어노테이션을 선호한다고 했다.
- 개인적으로 Persistence Layer에는 DataJpaTest를 이용하고 BusinessLayer에는 SpringBootTest 어노테이션을 사용하는게 명확하게 구분할 수 있어서 좋아보이는데
- 만약 나중에 Persistence Layer에서 JPA가 아닌 다른 라이브러리를 사용하게 된다면 과연 DataJpaTest를 쓰는게 맞는것인지에 대한 의문이 조금 생기는것 같다.
- Persistence Layer를 테스트 할 때는 Data Access의 역할만 테스트 한다.
- 비즈니스 가공 로직이 포함되어서는 안 된다. Data에 대한 CRUD에만 집중해서 테스트한다.
- 또한 JPA에서 제공하는 메서드들 까지 테스트할 필요는 없다고 느끼며 개발자가 직접 만들어서 사용하는 메서드들에 대한 테스트만 제공해도 충분하다고 생각한다.
Business Layer 테스트
- 비즈니스 로직을 구현하는 역할
- Persistence Layer와의 상호작용을 통해 비즈니스 로직을 전개시킨다.
- 트랜잭션을 보장해야 한다.
Mock
- Business Layer를 테스트할 때 과연 Persistence Layer를 어떻게 처리할까에 대한 고민을 하게 될 수 있다.
- 우선 강사분은 SpringBootTest 어노테이션을 이용해서 통합 테스트로 진행하기 때문에 Persistence Layer까지 상호작용하도록 테스트 코드를 작성하는 스타일이었다.
- 개인적으로 프로젝트를 진행하면서 이 부분을 전부 mock 처리하여 테스트 코드를 작성하였는데 이유로는 SpringBootTest가 Spring 컨테이너를 띄우는 과정에서 여러번 띄워지면서 테스트가 오래 걸릴 것이라고 생각했기 때문이었다.
- 하지만 이후에 얘기할 테스트 환경을 최대한 맞춰서 Spring 컨테이너를 최소화 시켜서 띄우게 되는 방법을 이용한다면 mock을 통한 테스트보다는 Business Layer까지 통합해서 테스트하는 것이 좀 더 믿을 수 있는 테스트 방법이 아닐까 라는 생각을 하게 되었다.
Presentation Layer
- 강사님은 Presentation Layer를 테스트할 때 Business Layer 이하 부분을 전부 mocking 처리해서 테스트를 진행했다.
- Presentation Layer에서는 실제 전달받는 Body 값이나 parameter 값들을 검증하는 정도의 테스트를 진행하기 때문에 Business Layer 부분을 mock 처리하더라도 크게 상관없다고 생각하고 있었는데 다행히 이 부분은 강사님과 생각이 비슷했던것 같다.
- 이때 한가지 만약 주문한 상품의 수량에 대한 검증을 한다고 생각해보자, 우선 기본적으로 수량은 0보다 작을 수 없기 때문에 이런 값에 대한 테스트는 Presentation Layer에서 테스트하는 것이 맞아보인다.
- 이때는 Bean Validation 을 이용하여 테스트한다.
- 근데 만약 한 물품을 10개 이상 주문할 수 없다는 비즈니스 로직이 있다고 가정한다면 과연 이러한 로직을 Presentation Layer에서 검증하는 것이 좋을까 아니면 Business Layer에서 검증하는 것이 좋을까.
- 이 부분도 강사님과 생각이 동일했던게 Presentation Layer에서는 정말 값 자체에 대해서 예를 들어, 숫자가 들어와야 하는데 문자가 들어오거나 필수값이 들어오지 않는 등의 상황에 대한 검증만 진행하고
- 비즈니스와 관련된 검증은 Business Layer에서 검증하는 것이 맞다고 생각했기 때문에
- Presentation Layer에서는 Bean Validation으로 체크하는 정도까지만 테스트를 진행하면 충분하다고 생각이 들었다.
- 이때 @WebMvcTest를 이용하였고 이는 Spring boot에서 제공해주는 어노테이션인데 예전 블로그에 적었던 것처럼 WebMvcTest를 이용하여 테스트를 진행하는 것 보다 Mockito를 이용해서 테스트하는게 훨씬 빠르다.
- 하지만 이것도 Business Layer와 비슷하게 컨테이너를 여러번 띄우는 것이 아닌 최소화 시켜서 띄워서 테스트한다면 훨씬 코드 작성도 간편한 WebMvcTest를 이용하는것도 좋지 않을까 라는 생각이 들었고
- 이런 부분은 앞으로 정말 무수히 많은 테스트 코드가 생겼을 때 속도를 비교해보며 그때 그때 맞는 방법을 선택하는 것이 좋아보인다.
6. Mock을 마주하는 자세
Test Double
- Dummy : 아무 것도 하지 않는 깡통 객체
- Fake : 단순한 형태로 동일한 기능은 수행하나, 프로덕션에서 쓰기에는 부족한 객체 (ex, FakeRepository → 실제 DB가 아닌 메모리를 이용하여 간단하게 구현한 객체)
- Stub : 테스트에서 요청한 것에 대해 미리 준비한 결과를 제공하는 객체, 그 외에는 응답하지 않는다.
- Spy : Stub 이면서 호출된 내용을 기록하여 보여줄 수 있는 객체, 일부는 실제 객체처럼 동작시키고 일부만 Stubbing 할 수 있다.
- Mock : 행위에 대한 기대를 명세하고, 그에 따라 동작하도록 만들어진 객체
Stub 은 상태 검증 (State Verification) 이고 Mock 은 행위 검증 (Behavior Verification) 이라는데 아직 정확하게 차이를 이해하지는 못했다…
BDDMockito
일반적으로 BDD를 이용해 테스트를 given - when - then 의 순서로 진행하게 되는데
mock을 이용해 Stubbing을 진행하다보면 메서드 이름이 when이 들어가다보니 실제 정황상은 given 절에 들어가는게 맞는데 메서드 이름이 when이다보니 헷갈려할 수 있기 때문에 이를 대체해 나온 것이 BDDMockito 이다.
개인적으로는 그렇게 헷갈린다는 생각이 들지 않아서 이 부분은 나중에 사용하게 될 때 문서 보고 적용하면 크게 문제될 것 같지 않다.
Classicist vs Mockist
강사님은 Classicist 쪽이라고 말씀하셨다. 정답은 없다.
생각해보면 나는 지금까지 Mockist 처럼 프로젝트 테스트 코드를 작성했는데 이번에 통합테스트에서 SpringBootTest 어노테이션을 바로 사용할거면 Classicist 처럼 테스트 코드를 작성하는게 뭔가 더 편할것 같다는 느낌이 들긴 했다
두 진영의 차이는 좀 더 찾아보도록 해야겠다.
실제 프로덕션 코드에서 런타임 시점에 일어날 일을 정확하게 Stubbing 했다고 단언할 수 있는가??? → Classicist
7. 더 나은 테스트를 작성하기 위한 구체적인 조언
[ Test Fixture 클렌징 ]
- 강의에서는 deleteAllInBatch 메서드를 사용해서 클렌징을 진행했다.
- 이유로는 해당 쿼리가 단순 delete 메서드보다 훨씬 빠르기 때문이다.
- 단순 delete 쿼리는 모든 데이터들을 select 한 이후에 where 조건을 주면서 삭제를 하는 반면 deleteAllInBatch 는 select 과정 없이 한번에 해당 테이블의 데이터들을 삭제하기 때문에 강사분은 deleteAllInBatch를 사용한다고 한다.
- 물론 @Transactional 어노테이션을 활용하는 방법도 있기 때문에 상황에 맞춰 사용하면 될 것 같다.
[ 테스트 수행도 비용이다. 환경 통합하기 ]
- @SpringBootTest 어노테이션을 이용한 테스트 환경에서는 결국 스프링 컨테이너를 띄우게 되는데 이때 테스트 실행 환경이 다르다면 스프링 컨테이너를 여러번 띄우게 된다.
- 결국 최대한 환경을 통합해서 스프링 컨테이너를 최소로 띄우게 하는것이 핵심이다.
1. Business Layer 테스트 환경 통합
@ActiveProfiles("test") @SpringBootTest public abstract class IntegrationTestSupport { @MockBean protected MailSendClient mailSendClient; }
우선 추상 클래스를 하나 만들어서 테스트 환경을 통합해준다.
이후 해당 추상 클래스를 상속받아 Business Layer를 통합하면 된다.
이때 만약 Mock 처리를 해야하는 클래스가 있는데 이를 상속받은 클래스에서 정의하게 되면 해당 테스트는 다른 테스트 환경으로 인식하여 새로운 컨테이너를 띄우게 되기때문에 Mock 처리할 클래스들을 전부 추상클래스에 선언하여 하나의 환경으로 통합하는 것이 좋다.
//@SpringBootTest class OrderStatisticsServiceTest extends IntegrationTestSupport { @Autowired private OrderStatisticsService orderStatisticsService; @Autowired private OrderProductRepository orderProductRepository; @Autowired private OrderRepository orderRepository; @Autowired private ProductRepository productRepository; @Autowired private MailSendHistoryRepository mailSendHistoryRepository; // @MockBean // private MailSendClient mailSendClient; @AfterEach void tearDown() { orderProductRepository.deleteAllInBatch(); orderRepository.deleteAllInBatch(); productRepository.deleteAllInBatch(); mailSendHistoryRepository.deleteAllInBatch(); } }
위 코드에서 본 것처럼 상속받은 클래스에서 테스트에 필요한 클래스만 주입받아서 사용하면 된다.
이렇게 테스트 환경을 통합하여 최소한의 스프링 컨테이너를 띄우는 것이 중요하다.
강사분은 DataJpaTest 어노테이션 보다 SpringBootTest 어노테이션을 Persistence Layer에서 사용하는 것을 선호한다고 했는데 그 이유가 통합 테스트 환경을 통일하기 위함이었다. 이 부분은 개인 선호에 따라 다르게 진행하면 될 것 같다.
2. Presentation Layer 환경 통합
일반적으로 Presenstation Layer의 테스트는 전체 레이어를 가져와서 하기보다는 Mock 처리한 후에 Controller부분의 Validation 정도만 테스트를 진행한다
@WebMvcTest(controllers = { OrderController.class, ProductController.class }) public abstract class ControllerTestSupport { @Autowired protected MockMvc mockMvc; @MockBean protected OrderService orderService; @Autowired protected ObjectMapper objectMapper; @MockBean protected ProductService productService; }
위 코드처럼 추상 클래스를 선언한 후 상속받아서 컨트롤러 테스트를 진행하면 된다. 이때, @WebMvcTest 어노테이션에 여러 컨트롤러를 등록하여 진행하면 된다.
//@WebMvcTest(OrderController.class) class OrderControllerTest extends ControllerTestSupport { // @Autowired // private MockMvc mockMvc; // // @MockBean // private OrderService orderService; // // @Autowired // private ObjectMapper objectMapper; @DisplayName("신규 주문을 등록한다.") @Test void createOrder() throws Exception { // given OrderCreateRequest request = OrderCreateRequest.builder() .productNumbers(List.of("001")) .build(); // when ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/orders/new") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) ); // then resultActions.andExpect(status().isOk()) .andExpect(jsonPath("$.code").value("200")) .andExpect(jsonPath("$.status").value("OK")) .andExpect(jsonPath("$.message").value("OK")) .andDo(print()); } }
결론은 통합 테스트 환경을 구축해서 스프링 컨테이너를 띄우는 과정을 최소화 시키는 것이 중요하다
8. REST Docs vs Swagger
[ REST Docs ]
- 장점
- 테스트를 통과해야 문서가 만들어진다. (신뢰도가 높다)
- 프로덕션 코드에 비침투적이다.
- 단점
- 코드 양이 많다.
- 설정이 어렵다.
[ Swagger ]
- 장점
- 적용이 쉽다.
- 문서에서 바로 API 호출을 수행해볼 수 있다.
- 단점
- 프로덕션 코드에 침투적이다.
- 테스트와 무관하기 때문에 신뢰도가 떨어질 수 있다.
나는 양이 많더라도 프로덕션 코드에 침투하지 않는 REST Docs가 더 좋아보이긴 한다.
참고로 예전에 스터디원분 중 한분이 REST Docs랑 Swagger를 결합해서 문서에서 바로 API 호출을 수행해볼 수 있는 방법도 있다고 했는데 나중에 시간되면 한번 프로젝트에 적용해봐야겠다.