TDD 스터디에서 배운 점: 가독성 높은 테스트 코드 작성법과 코드 리뷰의 중요성

2024. 5. 12. 15:21About Me/회고

728x90

안녕하세요, 저는 현재 TDD 스터디를 진행하고 있는 코랩입니다.
이번 주 TDD 스터디를 하면서 배운 내용들을 여러분과 공유하고자 합니다.

이번 주 작업 내용

저희 팀은 이번 주에 6, 7장 강의를 수강하고,
controller 테스트 코드를 작성하며, 서로의 코드를 리뷰하는 시간을 가졌습니다.
이번 주에는 강의와 코드 리뷰를 통해 많은 것을 배울 수 있었습니다.

강의에서 배운 내용

1. 각 테스트 별 given, fixture를 @BeforeEach로 빼지 말자!

각 테스트는 독립적으로 실행되어야 하므로, given과 fixture를 @BeforeEach로 분리하는 것은 좋지 않습니다.

테스트 코드를 작성할 때, 특히 학습용으로 할 때는 비즈니스를 간단하게 가져가다 보니,
given 절에서 반복이 자주 일어납니다. 그래서 아래와 같은 코드를 자주 작성합니다

    private CreateCategoryControllerRequest req;

    @BeforeEach
    void setup() {
        req = new CreateCategoryControllerRequest(
                "test name",
                "test desc",
                1L
        );
    }
    
    @Test
    void 관리자가_새로운_카테고리를_생성할_수_있다() throws Exception {
        // 없어진 given
        
        // when - 동작
        ResultActions perform = mockMvc.perform(post("/category")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(createCategoryRequest)));

        // then - 검증
        perform
                .andExpect(jsonPath("$.code", is(HttpStatus.CREATED.value())))
                .andExpect(jsonPath("$.status", is(HttpStatus.CREATED)))
                .andExpect(jsonPath("$.data", is(null)))
                .andExpect(jsonPath("$.message", is("카테고리가 성공적으로 생성 되었습니다.")));
    }


하지만, 이는 오히려 가독성이 더 떨어지는 결과를 초래한다고 생각합니다.

왜냐하면 해당 테스트 코드를 읽는 다른 사람의 입장에서는,
해당 테스트가 어떤 조건, 환경에서 이뤄지기 알기 위해서는 계속 코드 위를 갖다 와야 하기 때문입니다.
예전에 해축갤 프로젝트 에서 이렇게 한 적이 있었는 데, 뼈저리게 반성하게 되었습니다.

2. test 환경은 독립적으로 유지하자.

테스트 환경은 독립적으로 유지해야 합니다.
이게 무슨 말인가 하면 코드에서 한번 보겠습니다.

@Test
void 재고가_부족한_상품이_있습니다() {
	// given
    Stock stock1 = new Stock("001", 2); (id, 재고 갯수)
    Stock stock2 = new Stock("002", 2);
    stock.deductQuantity(3); // stock의 재고 감수 메서드
    
    // when
    OrderCreateServiceRequest req = ...
    
    // then
    assertThatThrownBy(() -> orderService.createOrder(req))
    	.isInstanceOf(...)
        .hasMessage(...);
}

@Entity
class Stock {
    // ...
    
    public void deductQuantity(int val) {
        if(val > this.cnt) throw new IllegalArgumentException("재고가 부족합니다.")
    	this.cnt -= val;
    }
    ...
}

위 코드가 별로인 이유는 크게 2가지입니다.
1. deduct 라는 행위를 한번 더 생각해야 한다.
2. 에러가 when, then 절에서 일어나지 않는다.

테스트 코드는 문서이기에, 가독성이 좋아야 합니다.
가독성이 좋다! 라는 말은 주관적인 영역이다만 제가 생각했을 때
별 생각없이 읽어도 뇌에 잘 들어온다! 라고 생각합니다.

그런 의미에서 deduct 행위를 사용하는 것보단, 애초에 부족한 재고 수량을 만드는 게 더 좋다고 생각합니다.
또한 when, then 절에서 일어나지 않기 때문에, 테스트를 하고자 하는 행위에 대한 검증이 되지 않습니다.
그래서 위 코드를 바꾼다면 아래와 같이 바꾸는 게 더 가독성이 좋은 테스트입니다.

@Test
void 재고가_부족한_상품이_있습니다() {
	// given
    Stock stock1 = new Stock("001", 2); (id, 재고 갯수)
    Stock stock2 = new Stock("002", 2);
    
    // when
    OrderCreateServiceRequest req = new OrderCreateServiceRequest(5); // param : 재고 갯수
    
    // then
    assertThatThrownBy(() -> orderService.createOrder(req))
    	.isInstanceOf(...)
        .hasMessage(...);
}

3. private method는 테스트 할 필요가 없다.

private method는 클래스 내부 구현에 해당하므로 직접 테스트할 필요가 없습니다.
private 으로 선언한 것부터, 외부에 공개될 method 가 아니기에 그닥 할 필요는 없는 거죠.
만약 한다고 해도, 간접적으로 테스트가 가능합니다.

예를 들어서, 엔티티의 필드들로부터 비즈니스 로직을 체크하는
Service 내 private 메서드가 있다고 해보겠습니다.

public class Service {

    public void someMethod(Entity entity) {
        if(!checkIsEntityOkay(entity))
            throw new IllegalArgumentException(..)
            
        // 비즈니스 로직 전개...
    }
    
    private boolean checkIsEntityOkay(Entity entity) {...}
}

위에 대한 테스트 코드는 아래와 같이 작성해주면 간접적인 테스트가 가능해집니다.

@Test
void testSomeMethod() {
    // given
    Entity wrongEntity = new Entity(...);
    
    // when, then
    AssertThatThrownBy(() -> Service.someMethod(wrongEntity))
        .isInstanceOf(IllegalArgumentException.class);
}

그럼에도 불구하고 private method를 테스트하고 싶다면,
책임 분리 할 때가 되었는 지 확인해 보는 것이 좋겠습니다.

4. test 실행 환경 통합시키기

remote 에 push 전, 모든 테스트를 돌릴 때 시간이 보통 많이 걸리게 됩니다.
실행할 때마다 시간이 오래 걸리는 것은 결국 비용입니다.
시간을 줄이기 위해서는 Spring 컨텍스트가 덜 올라가도록 하는 것이 좋습니다.

이게 도대체 왜 오래 걸릴까? 라는 생각이 들 수 있습니다.
이에 대해서는 JUnit5 의 실행, 작동 원리를 알아야 합니다.

JUnit5 의 LifeCycle

1. 테스트 클래스 인스턴스 생성 (ex. MemberControllerTest 인스턴스)
--------------------이때 1번에서 이전 환경과 동일하게 실행해도 되는 지 판단

2. Spring TestContext Framework 초기화
3. Spring Application Context 초기화
4. 의존성 주입 
--------------------
5. 테스트 메서드 실행 전 설정 (JUnit5 기준 @BeforeEach 가 달린 함수, JUnit4 기준 @Before 가 달린 함수)
6. 테스트 메서드 실행
7. 테스트 메서드 실행 후 설정 (JUnit5 기준 @AfterEach 가 달린 함수, JUnit4 기준 @After 가 달린 함수)
-------------------- 

8 Spring TestContext Framework 정리

출처

한번 테스트를 하기 위해서는 위와 같은 일련의 과정들을 거쳐야 합니다.
여러 테스트를 돌릴 때, 동일한 환경에서 실행해도 되는 테스트 들은 같은 Spring 환경,
같은 2,3번의 맥락에서 실행을 하게 됩니다.

하지만 다른 환경에서 실행해야 하는 테스트 클래스의 인스턴스를 실행해야 하면,
이때 다시 Spring 을 올리게 되고, 이때 가장 많은 시간을 잡아먹게 됩니다.

예를 들면 @DataJpaTest는 JPA 관련 bean들만 Load하고,
@SpringBootTest는 모든 bean 을 Load하기 때문에 @DataJpaTest 가 더 빠르다고 알려져있습니다.

하지만 @SpringBootTest 가 달린 테스트 클래스와,
@DataJpaTest 가 달린 테스트 클래스를 실행시키게 되면 JUnit 은 이를 다른 환경이라고 인식,
또 다시 Spring Container 를 올리게 됩니다. 그리고 이게 바로 시간을 잡아먹는 지점입니다.


그러면 무조건 다 @SpringBootTest 로 하라는 거?

는 절대 틀린 말입니다. 많은 강의, 질의응답에서 들어볼 만한 답이 정답이죠.

언제나 비즈니스, 팀에 맞게~


그렇습니다. 언제나 그렇듯 비즈니스 by 비즈니스, 팀 by 팀이다~
다만 시간이 오래 걸린다면, 위 방법을 고려할 수 있다! 입니다.

코드 리뷰를 통해 배운 점

저희 팀은 서로의 작업물을 PR로 올리고, 최소 2명에게 코드 리뷰를 받는 방식으로 진행했습니다.
처음 해보는 거라 어떨지 궁금했는데, 생각보다 배울 점도 많고 스스로를 돌아보는 계기도 되었습니다.
아래는 제가 팀원과 나눈 PR 리뷰입니다!

 

🟢 Test-Green: GrantRole, ModifyMember, RegisterMember, RemoveMember, RevertPassword Controller Test 작성 완료 by khmgobe

확인 부탁드리며 고칠점이 있다면 기탄없이 말씀 부탁드립니다.

github.com

  1. test code에서 given 절에 fixture를 쓸 때는 parameter를 구체적으로 명시하기
  2. mockMvc.perform은 when 절로, resultActions.andExcept(...)은 then 절로 나누기
  3. 전역 예외 핸들러에 대해, 팀원의 것을 가져다 쓰니, 타인이 내 코드를 쓸 때 어떤 점이 좋구나!
  4. 그리고 제가 지적한 팀원의 실수를 저도 할 수 있겠구나 하는 생각이 들었습니다. 

사실 기대를 그렇게 하지 않은, 조금은 귀찮다고 생각을 했는데
막상 해보니 배울 점이 정말 많았던 코드 리뷰 였습니다!

마무리

끗~

이번 스터디를 통해 TDD와 더불러, 더 읽기 좋은 코드는 무엇인가를 잘 배워갈 수 있었던 한 주였습니다!

728x90