본문 바로가기
책/클린코드

9. 단위 테스트

by 히포파타마스 2023. 6. 23.

단위 테스트

 

1. 단위 테스트의 필요성

실제 코드는 여러 가지 요인에 의해서 변하기 마련이다.

실제 코드를 변경하면 상황에 따라 테스트 코드 또한 변경해 줘야 할 필요가 있다

하지만 작성되어 있는 테스트 코드가 가독성이 낮고, 복잡하게 구성되어 있다면 테스트 코드를 변경하기에는 어려움이 따르고

실제 코드를 변경하는데 걸림돌이 될 수 있다.

 

역으로 보기 좋게 작성된 단위 테스트는 코드에 유연성, 유지 보수성, 재사용성을 제공한다.

앞서 언급된 것처럼 가독성이 낮고 복잡한 테스트 코드는 실제 코드 변경에 어려움을 겪게 한다.

하지만 잘 짜인 단위 테스트는 실제 코드의 변경에 따른 변경사항과 사이드 이펙트를 쉽게 파악할 수 있게 해 준다.

또한 실제 코드의 변경에 따라 단위 테스트 역시 유연하게 수정될 수 있다.

이 때문에 잘 작성된 단위 테스트는 실제 코드의 변경에 대한 부담을 줄여주고 유연성과 유지보수성, 재사용성을 제공한다.

 

때문에 우리는 깨끗한 단위 테스트를 작성할 필요가 있다.

 

 

 

2. 깨끗한 테스트 코드

깨끗한 테스트 코드를 위해서는 "가독성"이 중요하다.

 

여기 가독성을 높이기 위한 몇 가지 방법들이 있다.

 

 

2.1 메서드

테스트에서는 테스트 대상이 되는 특정 변숫값만 변하고 "행위" 자체는 중복되는 경우가 대부분이다.

때문에 이렇게 중복되는 "행위"를 메서드화 하면 중복을 방지하고 가독성이 증가하며, 코드의 유지보수에 용이해진다.

 

[잘못된 테스트 예]

@Test
@DisplayName("회원 생성_검증 실패")
public void accountAdd_ValidationException() throws Exception {

    //given
    String email = "test@test.com";
    String password = "12345678";
    String nickname = "testNickname";
    MockMultipartFile profile = new MockMultipartFile("profile", "profile.jpeg", "image/jpeg",
            "(file data)".getBytes());

    //when
    ResultActions emailValidationEx = mockMvc.perform(
            multipart("/accounts")
                    .file(profile)
                    .param("email", "exceptionEmail")
                    .param("password", password)
                    .param("nickname", nickname)
    );
    ResultActions passwordValidationEx = mockMvc.perform(
            multipart("/accounts")
                    .file(profile)
                    .param("email", email)
                    .param("password", "1234")
                    .param("nickname", nickname)
    );
    ResultActions nicknameValidationEx = mockMvc.perform(
            multipart("/accounts")
                    .file(profile)
                    .param("email", email)
                    .param("password", password)
                    .param("nickname", "e")
    );
    ResultActions profileValidationEx = mockMvc.perform(
            multipart("/accounts")
                    .param("email", email)
                    .param("password", password)
                    .param("nickname", "e")
    );

    //then
    emailValidationEx
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.exception").value(BindException.class.getSimpleName()))
            .andExpect(jsonPath("$.message").value("잘못된 입력값입니다."));
    passwordValidationEx
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.exception").value(BindException.class.getSimpleName()))
            .andExpect(jsonPath("$.message").value("잘못된 입력값입니다."));
    nicknameValidationEx
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.exception").value(BindException.class.getSimpleName()))
            .andExpect(jsonPath("$.message").value("잘못된 입력값입니다."));
    profileValidationEx
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.exception").value(BindException.class.getSimpleName()))
            .andExpect(jsonPath("$.message").value("잘못된 입력값입니다."));
}

 

Account를 생성하는 API에 대해 파라미터값을 검증하는 테스트이다.

 

when 부분을 보면 검증될 파라미터값만 변할 뿐, 값을 넣고 API를 호출하는 작업은 단순 반복되고 있다.

만약 Account를 생성할 때 검증해야 되는 파라미터가 하나 더 추가되었다고 하자.

그러면 API를 호출하는 모든 부분에 파라미터값을 추가하는 작업을 해주어야 한다.

 

[테스트 코드 메서드화]

@Test
@DisplayName("회원 생성_검증 실패")
public void accountAdd_ValidationException() throws Exception {
    //given & when
    ResultActions emailValidationEx = executionAccountAdd("email", "exceptionEmail");
    ResultActions passwordValidationEx = executionAccountAdd("password", "1234");
    ResultActions nicknameValidationEx = executionAccountAdd("nickname", "e");
    ResultActions profileValidationEx = executionAccountAdd("profile", null);

    //then
    emailValidationEx
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.exception").value(BindException.class.getSimpleName()))
            .andExpect(jsonPath("$.message").value("잘못된 입력값입니다."));
    passwordValidationEx
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.exception").value(BindException.class.getSimpleName()))
            .andExpect(jsonPath("$.message").value("잘못된 입력값입니다."));
    nicknameValidationEx
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.exception").value(BindException.class.getSimpleName()))
            .andExpect(jsonPath("$.message").value("잘못된 입력값입니다."));
    profileValidationEx
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.exception").value(BindException.class.getSimpleName()))
            .andExpect(jsonPath("$.message").value("잘못된 입력값입니다."));
}

 

API를 호출하는 부분을 메서드화 하였다.

변경되는 파라미터가 무엇인지 확실히 파악할 수 있으며, 코드가 간결해지고 가독성이 증가하였다.

 

이제 검증해야 할 파라미터가 추가되어도 executionAccountAdd() 메서드만 변경하면 되기 때문에 생산성 또한 증가하였다.

 

 

2.2 테스트 DSL

테스트는 정형화되고 반복되는 작업이다.

테스트라는 작업은 어떻게 보면 given-when-then이라는 틀에서 검증을 계속해서 해나가는 것이라고 볼 수 있다.

이런 테스트의 특성상 정형화된 작업을 DSL로 구현할 수 있다면 높은 가독성이 보장될 수 있다.

 

다만 DSL은 설계에 적지 않은 시간을 할애해야 하기 때문에 상황에 따라 적절한 타협이 필요할 수 있다.

 

 

2.3 테스트당 결과는 하나

너무 많은 비교와 결과는 테스트의 가독성을 낮추는 요인이 될 수 있다.

 

[다수의 비교 결과]

@Test
@DisplayName("회원 수정_성공")
public void accountModify_Success() throws Exception {

    //given
    .
    .
    //when
    .
    .
    //then
    .
    .
    assertThat(passwordEncoder.matches(accountModifyReq.getPassword(), modifiedAccount.getPassword())).isTrue();
    assertThat(modifiedAccount.getNickname()).isEqualTo(accountModifyReq.getNickname());
    assertThat(modifiedAccount.getIntro()).isEqualTo(accountModifyReq.getIntro());
    assertThat(modifiedAccount.getProfile()).isEqualTo(accountModifyReq.getProfile());
}

 

Account를 수정하고 검증하는 테스트이다.

결과 부분이 그렇게 많은 것은 아니지만 대부분 중복되는 부분이 존재한다.

또한 password 같은 경우 메서드가 중복되고 다른 결과들과 형식이 약간 달라서 가독성이 조금 떨어진다.

 

게다가 앞의 메서드화와 같이 만약 Accont에서 검증해야 할 필드가 추가되었다고 하자.

그렇다면 Account를 수정하고 검증하는 결과 부분의 모든 곳에 해당 파라미터를 검증해야 되는 코드를 추가해야 한다.

 

따라서 하나의 결과만 확인할 수 있도록 테스트를 쪼개던지 결과를 압축해서 메서드화 할 필요가 있다.

 

[하나의 결과로 메서드 화]

@Test
@DisplayName("회원 수정_성공")
public void accountModify_Success() throws Exception {

    //given
    .
    .
    //when
    .
    .
    //then
    .
    .
    assertModyfiedAccount(modifiedAccount).isEqualTo(AccountModifyReq)
}

 

여러 개의 결과를 하나의 메서드로 축약하였다.

수정된 Account(modifiedAccount)와 Account를 수정한 정보(AccountModifyReq)를 비교함을 한눈에 확인할 수 있다.

 

Account를 수정하는 테스트 코드를 보는 사람은 수정된 Account의 필드값들을 일일이 비교하는 것에는 그다지 관심이 없을 것이다.

 

때문에 우리는 수정된 Account를 어떻게 검증할지만 잘 표현해 주면 된다.

 

 

2.4 테스트당 하나의 개념

테스트의 가독성을 높이기 위해서는 당연 테스트당 하나의 개념이 들어가야 할 것이다.

 

[여러 개의 개념이 들어간 테스트]

@Test
@DisplayName("회원 생성_검증 실패")
public void accountAdd_ValidationException() throws Exception {
    //given & when
    ResultActions emailValidationEx = executionAccountAdd("email", "exceptionEmail");
    ResultActions passwordValidationEx = executionAccountAdd("password", "1234");
    ResultActions nicknameValidationEx = executionAccountAdd("nickname", "e");
    ResultActions profileValidationEx = executionAccountAdd("profile", null);

    //then
    emailValidationEx
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.exception").value(BindException.class.getSimpleName()))
            .andExpect(jsonPath("$.message").value("잘못된 입력값입니다."));
    passwordValidationEx
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.exception").value(BindException.class.getSimpleName()))
            .andExpect(jsonPath("$.message").value("잘못된 입력값입니다."));
    nicknameValidationEx
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.exception").value(BindException.class.getSimpleName()))
            .andExpect(jsonPath("$.message").value("잘못된 입력값입니다."));
    profileValidationEx
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.exception").value(BindException.class.getSimpleName()))
            .andExpect(jsonPath("$.message").value("잘못된 입력값입니다."));
}

 

Account의 파라미터를 검증하는 테스트이다.

해당 테스트에는 email, password, nickname, profile에 대한 검증이 전부 포함되어 있다.

 

이런 식으로 작성된 코드는 보는 입장에서 어떤 테스트를 하는지 한눈에 파악하기 어려울뿐더러 유지보수에도 치명적인 결함이 있다.

위의 예시 코드는 여러 개념이 하나의 코드에 담겨있어 정확히 어떤 부분을 검증하는지 파악하기 어렵다.

만약 nickname에 대한 검증 방식이 변경되어 테스트코드를 변경해야 된다면?

해당 테스트를 하는 이 코드를 찾기도 어려울뿐더러 여러 개의 개념이 들어있는 코드 중에서 nickname부분을 또 찾아야 한다.

때문에 해당 테스트는 개념에 따라 분리될 필요가 있다.

 

[테스트당 하나의 개념]

@Test
@DisplayName("회원 생성_email 검증 실패")
public void accountAdd_emailValidationException() throws Exception {
    //given & when
    ResultActions emailValidationEx = executionAccountAdd("email", "exceptionEmail");

    //then
    emailValidationEx
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.exception").value(BindException.class.getSimpleName()))
            .andExpect(jsonPath("$.message").value("잘못된 입력값입니다."));
}


@Test
@DisplayName("회원 생성_password 검증 실패")
public void accountAdd_passwordValidationException() throws Exception {
    //given & when
    ResultActions passwordValidationEx = executionAccountAdd("password", "1234");

    //then
    passwordValidationEx
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.exception").value(BindException.class.getSimpleName()))
            .andExpect(jsonPath("$.message").value("잘못된 입력값입니다."));
}

@Test
@DisplayName("회원 생성_nickname 검증 실패")
public void accountAdd_nicknameValidationException() throws Exception {
    //given & when
    ResultActions nicknameValidationEx = executionAccountAdd("nickname", "e");

    //then
    nicknameValidationEx
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.exception").value(BindException.class.getSimpleName()))
            .andExpect(jsonPath("$.message").value("잘못된 입력값입니다."));
}

@Test
@DisplayName("회원 생성_profile 검증 실패")
public void accountAdd_profileValidationException() throws Exception {
    //given & when
    ResultActions profileValidationEx = executionAccountAdd("profile", null);

    //then
    profileValidationEx
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.exception").value(BindException.class.getSimpleName()))
            .andExpect(jsonPath("$.message").value("잘못된 입력값입니다."));
}

 

각 검증 부분에 따라 테스트 코드를 분리하였다.

어떤 부분을 테스트하는지 확실히 파악할 수 있기에 가독성이 높아졌다.

특정 파라미터에 대한 추가적인 검증이 필요하다면 해당 테스트 코드를 확장하면 되기 때문에 테스트 코드에 대한 확장성 또한 높아졌다.

 

 

2.5 F.I.R.S.T

깨끗한 테스트는 다음 다섯 가지 규칙을 따른다.

 

● 빠르게

테스트가 빠를수록 테스트를 더 자주 돌릴 수 있게 된다.

테스트를 자주 돌리지 못하면 문제를 제 때 찾지 못할 수 있으며, 실제 코드를 정리할 때도 부담이 된다.

 

● 독립적으로

다른 테스트에 의해 특정 테스트 결과가 변경될 수 있기 때문에 각 테스트는 서로 의존하면 안 된다.

 

● 반복가능하게

테스트는 어떤 환경에서도 반복가능해야 한다.

테스트가 돌아가지 않는 환경이 있다면 그에 따른 예외 상황을 처리할 수 없고

제 때 테스트 하지 못하는 상황이 발생할 수 있다.

 

● 자가검증하는

테스트는 스스로 성공과 실패 여부를 판단해서 도출해야 한다.

테스트 결과를 사람의 눈으로 직접 확인하고 판단하도록 하면 객관적이지 못한뿐더러 놓치는 부분이 발생할 수밖에 없다.

 

● 적시에

테스트는 적시에 작성해야 한다.

실제 코드를 작성하고 한참뒤에 테스트를 작성하게 되면 테스트 코드의 의미가 퇴색될뿐더러,

상황에 따라서는 테스트 코드를 작성하기 어렵게 실제 코드를 작성해서 애를 먹을 수 있다.

 

 

 

' > 클린코드' 카테고리의 다른 글

6. 객체와 자료 구조  (0) 2023.06.18
5. 형식 맞추기  (0) 2023.06.18
4. 주석  (0) 2023.06.18

댓글