BackEnd/TDD

[TDD] 테스트 코드 작성 팁 (1/2)

샤아이인 2022. 1. 28.

해당 글은 "테스트 주도 개발 시작하기 - 최범균 저" 의 10장 내용을 공부, 기록겸 요약한 글 입니다.

유지보수하기 좋은 코드를 만들기 위해 필요한 좋은 패턴과 원칙이 존재하는 것처럼 좋은 테스트 코드를 만들려면 몇가지 주의해야할 사항이 있다.

1. 두 개 이상을 검증하지 않기

처음 테스트코드를 작성하면 한 테스트 메소드에 가능한 많은 단언을 하려고 시도한다. 그 과정에서 서로 다른 검증을 섞는 경우가 있다.

@DisplayName("같은 ID가 없으면 가입에 성공하고 메일을 전송함")
@Test
void registerAndSendMail(){
    userRegister.register("id","pw","email");

    //검증1: 회원 데이터가 올바르게 저장되엇는지 검증
    User savedUser = fakeRepository.findById("id");
    assertEquals("id",savedUser.getId());
    assertEquals("email",savedUser.getEmail());

    //검증2: 이메일 발송을 요청했는지 검증
    ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
    BDDMockito.then(mockEmailNotifier)
        .should().snedRegisterEmail(captor.capture());

    String realEmail = captor.getValue();
    assertEquals("email@email.com",realEmail);
}

이 테스트는 두 가지를 검증한다. 

1) 회원 가입 이후에 데이터가 올바르게 저장되는지 검증하기

2) 이메일 발송을 올바르게 요청하는지 검증하기

 

테스트가 잘못된 것은 아니지만 한 테스트에서 검증하는 내용이 두 개 이상이면 테스트 결과를 확인할 때 집중도가 떨어진다.

만약 첫번째 검증이 실패하면 테스트는 거기서 멈춘다. 이렇게 되면 두번째 테스트의 성공 여부는 알수가 없다.

 

한 테스트 메소드에서 서로 다른 내용을 검증한다면 각 검증 대상을 별도로 분리해서 테스트의 집중도를 높일 수 있다.

@DisplayName("같은 ID가 없으면 가입 성공함")
@Test
void noDupId_RegisterSuccess(){
    userRegister.register("id","pw","email");

    User savedUser = fakeRepository.findById("id");
    assertEquals("id",savedUser.getId());
    assertEquals("email",savedUser.getEmail());
}

@DisplayName("가입하면 메일을 전송함")
@Test
void whenRegisterThenSendMail(){
    userRegister.register("id","pw","email");

    ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
    BDDMockito.then(mockEmailNotifier)
        .should().snedRegisterEmail(captor.capture());

    String realEmail = captor.getValue();
    assertEquals("email@email.com",realEmail);
}

만약 첫 번째 메소드에서 테스트에 실패하면 구체적인 실패 위치를 몰라도 저장한 데이터를 저장하는 것이 잘못되었다는 것을 알 수 있다.

마찬가지로 두 번째 메소드에서 테스트에 실패하면 메일 전송 과정이 잘못되었다는 것을 알 수 있다.

 

검증 대상이 명확하게 구분된다면 테스트 메소드도 구분하는 것이 유지보수에 유리하다.

 

2. 변수나 필드를 사용해서 기댓값 표현하지 않기

@Test
void dateFormat(){
  LocalDate date=LocalDate.of(1945,8,15);
  String dateStr=formatDate(date);
  assertEquals(date.getYear()+"년 "+
              date.getMonthValue()+"월 "+
              date.getDayOfMonth()+"일",dateStr);
}

위 assertEquals는 논리적으로 맞지만, 문자열 연결이 있어서 코드가 복잡하고, 실수로 date.getMonthValue()대신 date.getMonth()를 호출하게 되면 테스트를 실행해서 테스트가 깨지기 전까지 뭘 실수한지 모를수도 있다.

 

따라서 다음과 같이 복잡하지 않고 기대하는 값도 명확하게 표현해야한다. 테스트가 깨진다면 formatDate()메소드만 확인하면 된다.

@Test
void dateFormat(){
    LocalDate date=LocalDate.of(1945,8,15);
    String dateStr= formatDate(date);
    assertEquals("1945년 8월 15일",dateStr);
}

 

다음 코드를 살펴보자. 다음 코드는 기대하는 값을 기술할 때 로컬변수와 필드를 사용하고 있다.

private List<Integer> answers = Arrays.asList(1,2,3,4);
private Long respondentId = 100L;

@DisplayName("답변에 성공하면 결과 저장함")
@Test
puvlic void saveAnswerSuccessfully(){
    //답변할 설문이 존재
    Survey survey= SurveyFactory.createApprovedSurvey(1L);
    surveyRepository.save(survey);

    //설문 답변
    SurveyAnswerRequest surveyAnswer = SurveyAnswerRequest.builder()
                    .surveyId(survey.getId())
                    .respondentId(respondentId)
                    .answers(answers)
                    .build();

    svc.answerSurvey(surveyAnswer);

    //저장 결과 확인
    SurveyAnswer savedAnswer = memoryRepository.findBySurveyAndRespondent(
            survey.getId(),respondentId);

    assertAll(
    ()-> assertEquals(respondentId,savedAnswer.getRespondentId()),
    ()-> assertEquals(answers.size(),savedAnswer.getAnswers().size()),
    ()-> assertEquals(answers.get(0),savedAnswer.getAnswers().get(0)),
    ()-> assertEquals(answers.get(1),savedAnswer.gerAnswers().get(1)));
}

설문 답변만 해도 인자로 repondentId, answers 와 같이 변수를 인자로 계속 넘겨주고 있다.

()-> assertEquals(respondentId,savedAnswer.getRespondentId())

만약 위 부분에서 NPE가 발생하면 어떻게 될까? survey변수와 respondentId필드의 값을 확인해야 한다.

테스트에 성공하더라도 테스트 코드를 처음보는 사람은 변수와 필드를 오가며 테스트코드를 이해해야 한다.

 

따라서 다음과 같이 변경하는것이 가독성에 훨 좋다.

//설문 답변
SurveyAnswerRequest surveyAnswer = SurveyAnswerRequest.builder()
                .surveyId(1L)
                .respondentId(100L)
                .answers(Arrays.asList(1,2,3,4))
                .build();

svc.answerSurvey(surveyAnswer);

//저장 결과 확인
SurveyAnswer savedAnswer=
    memoryRepository.findBySurveyAndRespondent(1L,100L);
assertAll(
()-> assertEquals(100L,savedAnswer.getRespondentId()),
()-> assertEquals(4,savedAnswer.getAnswers().size()),
()-> assertEquals(1,savedAnswer.getAnswers().get(0)),
()-> assertEquals(2,savedAnswer.gerAnswers().get(1)));

위와 같이 변경하면, 값을 확인하기 위해 필드부분을 봤다, 테스트 부분을 봤다, 왔다갔다 하지 않아도 된다.

 

3. 정확하게 일치하는 값으로 모의 객체 설정하지 않기

다음 코드를 보자 모의 객체를 이용해서 "pw" 문자열은 약한 암호로 처리하도록 지정하고 있다.

@DisplayName("약한 암호면 실패")
@Test
void weakPassword(){
    BDDMockito.given(mockPasswordChecker.checkPasswordWeak("pw")).willReturn(true);

    assertThrows(WeakPasswordException.class, () -> {
    	userRegister.register("id","pw","email");
    });
}

이 테스트는 작은 변화에도 실패한다. 예를들어 다음과 같이 코드를 변경했다고 하자

userRegister.register("id","pw7","email");

모의 객체는 "pw"인 경우에만 true를 리턴하도록 지정했기 때문에 "pw7"를 지정하면 false를 리턴해서 테스트에 실패하게 된다.

 

이 코드는 약한 암호인 경우 UserRegister가 원하는대로 동작하는지 확인하기 위한 테스트이지 "pw"나 "pw7"이 약한 암호인지 확인하는 테스트 코드가 아니다.

 

따라서 다음과 같이 변경해도 원하는 테스트가 수행 가능하다.

@DisplayName("약한 암호면 가입 실패")
@Test
void weakPassword(){
    BDDMockito
    .given(mockPasswordChecker.checkPasswordWeak(Mockito.anyString()))
    .willReturn(true);

    assertThrows(WeakPasswordException.class, () -> {
    userRegister.register("id","pw","email");
    });
}

Mockito.anyString() 을 사용하였는데 이 코드는 임의의 String값에 일치한다는 것을 의미한다.

이제 "pw"가 아닌 다른 문자열을 인자로 전달해도 테스트는 깨지지 않는다.

 

모의 객체는 가능한 범용적인 값을 사용해서 기술해야 한다.

한정된 값에 일치하도록 모의객체를 사용하면 약간의 코드 수정만으로도 테스트는 실패하게 된다.

이경우 테스트 코드의 일부 값을 수정하면 모의 객체 관련 코드도 함께 수정하는 번거로움이 있다.

 

테스트의 의도를 해치지 않는 범위에서 "pw"와 같은 특정한 값보다는 Mockito.anyString() 과 같은 범용적인 값을 사용해야한다.

이렇게 해야 약간의 코드 수정때문에 테스트가 실패하는 것을 방지할 수 있다.

 

4. 과도하게 구현 검증하지 않기

테스트 코드를 작성할 때 주의할점은 테스트 대상의 내부 구현을 검증하는 것이다. 모의 객체를 처음 사용할 때 특히 이런 유혹에 빠지기 쉽다. 다음 코드를 살펴보자.

@DisplayName("회원 가입시 암호 검사 수행함")
@Test
void checkPassword(){
    userRegister.register("id","pw","email");

    // PasswordChecker#checkPasswordWeak() 메소드 호출 여부 검사
    BDDMockito.then(mockPasswordChecker)
                .should()
                .checkPasswordWeak(Mockito.anyString());

    // UserRepository#findById() 메소드를 호출하지 않는 것을 검사
    BDDMockito.then(mockRepository)
                .should(Mockito.never())
                .findById(Mockito.anyString());
}

 

위 코드는 userRegister.register 를 호출할때 내부적으로 checkPasswordWeak, findById 가 호출되는지를 검사한다.

 

내부 구현을 검증하는 것이 나쁜 것은 아니지만 한가지 단점이 있다. 

그것은 바로 구현을 조금만 변경해도 테스트가 깨질 가능성이 커진다는 것이다.

 

내부 구현은 언제든지 바뀔 수 있기 때문에 테스트 코드는 내부 구현보다 실행 결과를 검증해야 한다. 

 

메소드를 호출하는지 검증하는 것보다 약한 암호일 때 예상한 결과값이 올바른지 검증해야한다. 그렇게 함으로써 내부 구현을 일부 바꿔도 테스트가 깨지지 않게 유지할 수 있다.

 

예제 코드의 경우 register() 메소드가 PasswordChecker#checkPasswordWeak() 메소드를 호출하는지 검증하는 것보다 약한 암호일 때 register() 의 결과가 올바른지 검증해야한다.

그렇게 함으로써 내부 구현을 일부 바꿔도 테스트가 깨지지 않게 유지할 수 있다.

 

5. 셋업을 이용해서 중복된 상황을 설정하지 않기

테스트 코드를 작성하다 보면 각 테스트 코드에서 동일한 상황이 필요할 때가 있다. 이경우 중복 코드를 제거하기 위해 @BeforeEach 메소드를 이용해서 상황을 구성할 수 있다.

@BeforeEach
void setUp(){
    changeService = new ChangeUserService(memoryRepository);
    memoryRepository.save(new User("id","pw",new Address("서울","북부")));
}

@Test
void noUser(){
    assertThrows(
    UserNotFoundException.class , ()-> changeService.changeAddress("id2",new Address("서울","남부")));
}

@Test
void changeAddress(){
    changeService.changeAddress("id",new Address("서울","남부"));

    User user=memoryRepository.findById("id");
    assertEquals("서울",user.getAddress().getCity());
}

@Test
void changePw(){
    changeService.changePw("id","pw","newpw");

    User user=memoryRepository.findById("id");
    assertTrue(user.matchPassword("newpw"));
}

@Test
void pwNotMatch(){
    assertThrows(IdPwNotMatchException.class, () -> changeService.changePw("id","pw2","newpw"));
}

이렇게 중복을 제거하고 코드 길이도 짧아져서 코드 품질이 좋아졌다고 생각할 수 있지만, 테스트 코드에서는 상황이 달라진다.

예를들어 pwNotMatch() 테스트 메소드가 실패해서 몇달만에 테스트 코드를 다시 볼 일이 생겼다고 하자.

 

테스트에 실패한 이유를 알려면 어떤 상황인지 확인해야 한다. 처음 테스트 코드를 작성할 때는 셋업 메소드로 상황을 구성해도 내용 분석에 어려움이 없지만 몇달만에 다시 코드를 보면 기억이 잘 나지 않기 때문에 셋업 메소드를 확인해야한다.

 

즉 코드를 위아래로 이동하면서 실패한 원인을 분석해야 한다.

 

setup메소드를 이용한 상황 설정으로 인해 발생할 수 있는 또 다른 문제는 테스트가 깨지기 쉬운 구조가 된다는 것이다.

모든 테스트 메소드가 동일한 상황 코드를 공유하기 때문이다.

 

셋업 메소드에서 User객체를 생성할때 사용한 "pw"를 다음과 같이 "blogShine" 으로 바꿨다고 하자

@BeforeEach
void setUp(){
    changeService = new ChangeUserService(memoryRepository);
    memoryRepository.save(new User("id","blogShine",new Address("서울","북부")));
}

이러한 변경으로 인해 changePw( )테스트와 pwNotMatch() 테스트가 실패한다.

이렇게 테스트가 깨지는 것을 방지하려면 셋업 메소드의 상황 설정 코드를 변경하기 전에 영향을 받는 테스트 메소드가 있는지 확인해야 한다.

 

테스트 메소드는 검증을 목표로 하는 하나의 완전한 프로그램이어야 한다.

메소드는 별도 프로그램으로서 검증 내용을 스스로 잘 설명할 수 있어야 한다.

그러기 위해서는 상황 구성 코드가 테스트 메소드 안에 위치해야 한다. 다음과 같이 말이다.

@BeforeEach
void setUp(){
    changeService = new ChangeUserService(memoryRepository);
}

@Test
void noUser(){
    assertThrows(UserNotFoundException.class , ()-> changeService.changeAddress("id",new Address("서울","남부")));
}

@Test
void changeAddress(){
    memoryRepository.save(new User("id","pw",new Address("서울","북부"))); //상황 설정

    changeService.changeAddress("id",new Address("서울","남부"));

    User user=memoryRepository.findById("id");
    assertEquals("서울",user.getAddress().getCity());
}

@Test
void changePw(){
    memoryRepository.save(new User("id","pw",new Address("서울","북부"))); //상황 설정

    changeService.changePw("id","pw","newpw");

    User user=memoryRepository.findById("id");
    assertTrue(user.matchPassword("newpw"));
}

@Test
void pwNotMatch(){
    memoryRepository.save(new User("id","pw",new Address("서울","북부"))); //상황 설정

    assertThrows(
    IdPwNotMatchException.class, () -> changeService.changePw("id","pw2","newpw"));w
}

코드는 다소 길어졌지만 테스트메소드 자체는 스스로를 더 잘 설명하고 있다.

테스트에 실패해도 코드를 이리저리 왔다 갔다 하면서 보지 않아도 된다. 실패한 테스트 메소드 위주로 코드를 보면 된다.

 

1) 통합 테스트에서 데이터 고유를 주의하자

직전 글에서 봤던 셋업을 이용한 상황 설정과 비슷한 것으로 통합 테스트의 DB데이터 초기화가 있다.

 

DB연동을 포함한 통합 테스트를 실행하려면 DB데이터를 알맞게 구성해야 한다. 이를 위한 방법은 테스트를 실행할 때마다 DB데이터를 초기화하는 쿼리를 실행하는 것이다.

 

스프링 프레임워크를 사용하면 @Sql 애노테이션을 사용해서 테스트를 실행하기 전에 특정 쿼리를 실행할 수 있다.

@SpringBootTest
@Sql("classpath:init-data.sql")
public class UserRegisterIntTestUsingSql{

    @Autowired private UserRegister register;
    @Autowired private JdbcTemplate jdbcTemplate;

    @Test
    void 동일ID가_이미_존재하면_익셉션(){
    	//실행, 결과 확인
    	assertThrows(DupIdException.class, ()->register.register("Shine","strongpw","email@email.com"));
    }

    @Test
    void 존재하지_않으면_저장함(){
    	//실행
    	register.register("Shine2","strongpw","email@email.com");
        
    	..생략
    }
}

@Sql 으로 지정한 sql파일은 다음과 같이 테스트에 필요한 데이터를 초기화한다.

통합 테스트 메소드는 데이터 초기화를 위한 코드를 작성하지 않아도 된다.

 

이 방식은 편리하지만 셋업 메소드를 이용한 상황 설정과 마찬가지로 초기화를 위한 쿼리 파일을 조금만 변경해도 많은 테스트가 깨질 수 있고 테스트가 깨지면 관련된 쿼리 파일을 봐야 한다. 이는 테스트 코드의 유지보수를 귀찮고 어렵게 만든다.

 

따라서 통합 테스트 코드를 만들때 데이터는 2가지로 나누어 생각해야만 한다.

1) 모든 테스트가 같은 값을 사용하는 데이터. (ex, 코드값 데이터)

2) 테스트 메소드에서만 필요한 데이터 (ex, 중복 ID 검사를 위한 회원 데이터)

 

코드값 데이터는 (거의) 바뀌지 않는다. 모든 테스트가 동일한 코드값 데이터를 사용해도 문제가 없으며 오히려 서로 다른 코드값 데이터를 사용하면 문제가 발생할 수 있다. => 모든 테스트가 동일한 값을 사용하는 데이터는 공유해도 된다.

 

특정 테스트 메서드에서만 사용하는 데이터는 공유할 필요가 없다. => 특정 테스트 케이스에서만 생성해서 사용하자

 

다음과 같이 테스트 코드가 완전히 하나가 되도록 해야한다.

@Test
void dupId(){
    //상황
    jdbcTemplate.update(
        "insert into user values(?,?,?) "+
        "on duplicate key update password = ?, email = ?",
        "cbk","pw","cbk@cbk.com","pw","cbk@cbk.com");
  
    //실행,결과확인
    assertThrows(DupIdException.class, () -> register.register("cbk","strongpw","email@email.com"));
}

 

2) 통합 테스트의 상황 설정을 위한 보조 클래스 사용하기

위의 코드는 테스트 메소드를 분석하기는 좋아졌는데 반대로 상황을 만들기 위한 코드가 여러 테스트코드에 중복된다.

 

테이블 이름이나 칼럼 이름이 바뀌면 여러 테스트 메소드를 수정해야 하므로 유지보수에 좋지 않다.

테스트 메소드에서 직접 상황을 구성하면서 코드 중복을 없애는 방법이 있는데 그것은 바로 상황 설정을 위한 보조 클래스를 사용하는 것이다.

 

다음과 같이 보조 클래스를 하나 만들어보자!

public class UserGivenHelper{
    private JdbcTemplate jdbcTemplate;

    public UserGivenHelper(JdbcTemplate jdbcTemplate){
    	this.jdbcTemplate=jdbcTemplate;
    }

    public void givenUser(String id, String pw, String email){
        jdbcTemplate.update(
            "insert into user values (?,?,?) "+
            "on duplicate key update password = ?,email=?",
            id,pw,email,pw,email);
    }
}

이제 상황을 구성하기 위한 보조 클래스를 사용하면 테스트 코드는 다음과 같이 변하게 된다.

@Autowired JdbcTemplate jdbcTemplate;
private UserGivenHelper given;

@BeforeEach
void setUp(){
  	given = new UserGivenHelper(jdbcTemplate);
}

@Test
void dupId(){
    given.givenUser("cbk","pw","cbk@cbk.com");
  
    //실행, 결과 확인
    assertThrows(DupIdException.class, () -> register.register("cbk","strongpw","email@email.com"));
}

상황 설정을 위한 보조 도구를 사용하면, 코드 중복방지에 도움이 되며, 메소드의 이름 덕분에 어떤 상황을 구성하는지 이해할 수 있다.

 

다음글 보러가기~

 

[TDD] 테스트 코드 작성 팁 (2/2)

해당 글은 "테스트 주도 개발 시작하기 - 최범균 저" 의 10장 내용을 공부, 기록겸 요약한 글 입니다. 지난 번 글에 이어서, 나머지 5가지 테스트 작성의 팁에 대하여 알아보자. 6. 실행 환경이 다르

blogshine.tistory.com

 

댓글