테스트 코드와 유지보수
지속적으로 코드를 통합하고 배포하려면 새로 추가한 코드가 기존 기능을 망치지 않는 지 확인할 수 있어야 하며 이런 이유로 자동화 테스트는 CI/CD의 필수 요건 중 하나이다.
테스트 코드는 그 자체로 코드이기 때문에 제품 코드와 동일하게 유지보수 대상이 된다.
테스트 코드를 유지보수하는 데 시간이 많이 들면 테스트를 방치할 수 있고 다음 문제를 야기할 수 있다.
- 실패한 테스트가 새로 발생해도 무감각해진다. 테스트 실패 여부에 상관없이 빌드하고 배포하기 시작한다.
- 빌드를 통과시키기 위해 실패한 테스트를 주석 처리하고 실패한 테스트는 고치지 않는다.
테스트 코드는 코드를 변경했을 때 기존 기능이 올바르게 동작하는 지 확인하는 회귀 테스트를 자동화하는 수단으로 사용되는데 깨진 테스트를 방치하면 회귀 테스트가 검증하는 범위가 줄어든다. 이는 코드에 버그 발생 시 이를 놓칠 가능성이 커지는 것을 의미한다.
이런 반복적인 악순환이 발생하지 않으려면 테스트 코드 자체의 유지보수성이 좋아야 한다.
그리고 유지보수하기 좋은 코드를 만들기 위해 필요한 좋은 패턴과 원칙이 있는것처럼 좋은 테스트 코드를 만들기위한 주의사항이 몇가지 있다.
변수나 필드를 사용해서 기댓값 표현하지 않기
@Test
void dateFormat() {
LocalDate date = LocalDate.of(1945,8,15);
String dataStr = formatDate(date);
assertEquals(date.getYear()+"년 "+
date.getMonthValue()+"월 "+
date.getDayOfMonth()+"일 ", dateStr);
}
3행에서 선언한 변수를 5~7행의 단언에서 사용하고 있다.
이 단언은 논리적으로 맞지만 문자열 연결에 있어 코드가 복잡하다.
또한, 실수로 date.getMonthValue() 대신 date.getMonth()를 사용하면 테스트를 실행해서 테스트가 깨져야 실수를 알아채기도 한다.
@Test
void dateFormat() {
LocalDate date = LocalDate.of(1945,8,15);
String dataStr = formatDate(date);
assertEquals("1945년 8월 15일", dateStr);
}
같은 코드지만 복잡하지도 않고 기대하는 값도 명확히 표현하고 있다.
변수를 사용할 때처럼 메서드를 잘못 사용할 일도 없다. 테스트가 깨지면 formatDate() 메서드만 확인하면 된다.
private List<Integer> answers = Arrays.asList(1,2,3,4);
private Long respondentId = 100L;
@DisplayName("답변에 성공하면 결과 저장함")
@Test
public void saveAnswerSuccessfully() {
// 답변할 설문이 존재
Survey survey = SurveyFactory.createApprovedSurvey(1);
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(answer.size(), savedAnswer.getAnswers().size()),
()->assertEquals(answer.get(0), savedAnswer.getAnswers().get(0)),
()->assertEquals(answer.get(1), savedAnswer.getAnswers().get(1)),
()->assertEquals(answer.get(2), savedAnswer.getAnswers().get(2)),
()->assertEquals(answer.get(3), savedAnswer.getAnswers().get(3))
);
}
이 코드를 보면 기대하는 값을 기술할 때 로컬 변수와 필드를 사용하고 있다.
만약 30행에서 테스트에 통과하지 못하면 실패 메시지는 다음과 유사할 것이다.
org.opentest4j.AssertionFailedError:
Expected :3
actual :4
그리고 만약 25행에서 NullPointException이 발생한다면 23행에서 사용한 survey 변수와 respondentId 필드의 값을 확인해야 한다.
테스트에 성공하더라도 테스트 코드를 처음 보는 사람은 변수와 필드를 오가며 테스트 코드를 이해해야 한다.
@DisplayName("답변에 성공하면 결과 저장함")
@Test
public void saveAnswerSuccessfully() {
// 답변할 설문이 존재
Survey survey = SurveyFactory.createApprovedSurvey(1);
surveyRepository.save(survey);
// 설문 답변
SurveyAnswerRequest surveyAnswer = SurveyAnswerRequest.builder()
.surveyId(1L)
.respondentId(100L)
.answers(Arrays.asList(1,2,3,4))
.build();
svc.answerSurvey(surveyAnswer);
// 저장 결과 확인
SurveyAnswer savedAnswer =
memoryRepository.findBySurveyAndRespondent(
survey.getId(), respondentId);
assertAll(
()->assertEquals(100L, savedAnswer.getRespondentId()),
()->assertEquals(4, savedAnswer.getAnswers().size()),
()->assertEquals(1, savedAnswer.getAnswers().get(0)),
()->assertEquals(2, savedAnswer.getAnswers().get(1)),
()->assertEquals(3, savedAnswer.getAnswers().get(2)),
()->assertEquals(4, savedAnswer.getAnswers().get(3))
);
}
다시 코드를 수정하여 실제 값을 사용하게 되면 코드의 가독성이 좋아져서 테스트 코드를 더욱 쉽게 파악할 수 있다.
두 개 이상을 검증하지 않기
처음 테스트 코드를 작성하면 한 테스트 메서드에 가능한 많은 단언을 하려고 시도한다.
그 과정에서 서로 다른 검증을 섞는 경우가 있다.
@DisplayName("같은 ID가 없으면 가입에 성공하고 메일을 전송함")
@Test
void registerAndSendEmail() {
userRegister.register("id", "pw", "email");
// 검증1 : 회원 데이터가 올바르게 저장되었는지 검증
User savedUser = userRepository.findById("id");
assertEquals("id", savedUser.getId());
assertEquals("email", savedUser.getEmail());
// 검증2 : 이메일 발송을 요청했는지 검증
ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
BDDMockito.then(mockEmailNotifier)
.should().sendRegisterEmail(captor.capture());
String realEmail = captor.getValue();
assertEquals("email@email.com", realEmail);
}
테스트가 잘못 된것은 아니지만 한테스트에서 검증하는 내용이 두 개 이상이면
테스트 결과를 확인할때 집중도가 떨어진다.
또한 실패한다면 둘중 무엇이 실패했는지도 확인하여야 한다.
@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@email.com");
ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
then(mockEmailNotifier).should().sendRegisterEmail(captor.capture());
String realEmail = captor.getValue();
assertEquals("email@email.com", realEmail);
}
각 테스트를 분리하여 검증 대상이 명확하게 구분되도록 하자.
정확하게 일치하는 값으로 모의 객체 설정하지 않기
@DisplayName("약한 암호면 가입 실패")
@Test
void weakPassword() {
BDDMockito.given(mockPasswordChecker.checkPasswordWeak("pw"))
.willReturn(true);
assertThrows(WeakPasswordException.class, ()->{
userRegister.register("id", "pw", "email");
});
}
4~5행은 모의 객체를 이요하여 pw 문자열은 약한 암호로 처리하도록 지정하고 있다.
이테스트는 작은 변화에도 실패한다.
예를 들어 8행의 pw를 pwa로 수정하였을때 지정한 모의 객체는 인자가 pw 인경우에만 true를 리턴하도록 지정했기 때문에 false 를 리턴해서 테스트에 실패하게 된다.
이코드는 약한 암호인 경우 UserRegister 가 원하는대로 동작하는지 확인하기 위한 테스트 이지 pw나 pwa 가 약한 암호인지 확인하는 테스트가 아니다.
@DisplayName("약한 암호면 가입 실패")
@Test
void weakPassword() {
BDDMockito.given(mockPasswordChecker.checkPasswordWeak(Mockito.anyString()))
.willReturn(true);
assertThrows(WeakPasswordException.class, ()->{
userRegister.register("id", "pw", "email");
});
}
이렇게 어떤 문자열을 전달해도 약한 암호인 경우에 대한 테스트를 진행할수 있다.
이처럼 모의 객체는 가능한 범용적인 값을 사용해서 기술해야한다.
과도하게 구현 검증하지 않기
테스트 코드를 작성할 때 주의할 점은 테스트 대상의 내부 구현을 검증하는 것이다.
//불필요한 검증 구현
@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());
}
이 코드는 register 메서드가 내부적으로 어떤 메서드를 호출하는지 그 여부를 검증하고 있다.
내부 구현을 검증하는 것이 나쁜 것은 아니지만 한 가지 단점이 있다.
그것은 바로 구현을 조금만 변경해도 테스트가 깨질 가능성이 커진다는 것이다.
내부 구현은 언제든지 바뀔 수 있기 때문에 테스트 코드는 내부 구현보다는 실행 결과를 검증해야 한다.
위 코드에서는 register() 의 결과가 올바른지 검증해야 한다.
이미 존재하는 코드에 단위 테스트를 추가하면 어쩔 수 없이 내부 구현을 검증해야할 때도 있다.
public void changeEmail(String id, String email) {
int cnt = userDao.countById(id);
if(cnt == 0) {
throw new NoUserException();
}
userDao.updateEmail(id, email);
}
위와 같은 레거시 코드가 있을때 DAO는 다양한 메서드를 정의하고 있는 경우가 많기 때문에
가짜 구현으로 대체하기가 쉽지 않다. 그래서 레거시 코드에 대한 테스트 코드를 작성할 때는 모의 객체를 많이 활용한다.
@Test
void changeEmailSuccessfully() {
given(mockDao.countById(Mockito.anyString())).willReturn();
emailService.changeEmail("id", "new@somehost.com");
then(mockDao).should()
.updateEmail(Mockito.anyString(), Mockito.matches("new@somehost.com");
}
셋업을 이용해서 중복된 상황을 설정하지 않기
테스트 코드를 작성하다 보면 각 테스트 코드에서 동일한 상황이 필요할 때가 있다.
이 경우 중복 코드를 제거하기 위해 @BeforeEach 메서드를 이용해 상황을 구성할 수 있다.
@BeforeEach
void setUp() {
changeService = new ChangeUserService(memoryRepository);
memoryRepository.save(
new User("id", "name", "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")
);
}
setUp() 메서드를 이용해 ID 가 id인 회원이 존재하는 상황을 기준으로 만들어 주어서 코드의 중복을 없엤다.
중복을 제거하고 코드 길이도 짧아져 좋아졌다고 생각할 수 있지만, 테스트 코드에서는 다르다. pwNoMatch 가 실패하였을 때 이유를 알려면 어떤 상황인지를 확인하기 위해 setUp 을 보아야 한다.
이로 인해 확인 작업이 번거로워 진다.
한편, 모든 테스트 메서드가 동일 상황을 공유하기 때문에 조금만 내용을 변경해도 테스트가 깨질 수 있다.
테스트가 깨지는 것을 방지하려면 셋업 메서드의 상황 설정 코드 변경을 위해 영향 받는 테스트 메서드를 확인해야 한다.
테스트 메서드는 검증을 목표로 하는 하나의 온전한 프로그램이어야 한다.
이를 위해 상황 구성 코드가 테스트 메서드 내에 위치해야 한다. 그래야 테스트 메서드 스스로가 온전히 테스트 내용을 설명할 수 있다. 셋업 메서드를 이용한 동일 상황 적용이 처음엔 편리하지만 시간이 지나 코드를 이해하고 유지 보수하는데는 오히려 방해가 된다.
테스트 메서드는 자체적으로 검증하는 내용을 완전히 기술하고 있어야 테스트 코드를 유지보수 하는 노력을 줄일수 있다.
통합 테스트에서 데이터 공유 주의하기
셋업 메서드를 이용한 상황 설정과 비슷한 것으로 통합 테스트의 DB 데이터 초기화가 있다.
이를 위한 방법은 테스트 실행 시마다 DB 데이터를 초기화하는 쿼리를 실행하는 것이다.
@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("cbk", "strongpw", "email@email.com")
);
}
@Test
void 존재하지_않으면_저장함() {
// 실행
register.register("cbk2", "strongpw", "email@email.com");
// ...
}
}
@Sql 애노테이션으로 지정한 sql 파일은 다음과 같이 테스트에 필요한 데이터를 초기화한다.
truncate table user;
insert into user values ('cbk', 'pw', 'cbk@cbk.com');
insert into user values ('tddhit', 'pw1', 'tddhit@ilovetdd.com')
이 쿼리는 여러 테스트가 동일한 데이터를 사용할 수 있게 만들어준다.
이 방식은 편리하지만 테스트 메서드와 마찬가지로 쿼리 파일을 조금만 변경해도
테스트 코드의 유지보수를 어렵게 만든다.
통합 테스트 코드를 만들 때는 다음 두 가지로 초기화 데이터를 나눠서 생각해야 한다.
- 모든 테스트가 같은 값을 사용하는 데이터 : 예) 코드값 데이터
- 테스트 메서드에서만 필요한 데이터 : 예) 중복 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")
);
}
통합 테스트의 상황 설정을 위한 보조 클래스 사용하기
테스트 메서드에서 직접 상황을 구성하면서 코드 중복을 없애는 방법이 바로 상황 설정을 위한 보조클래스를 사용하는 것이다.
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")
);
}
이런식으로 givenUser()라는 메서드 이름을 사용하므로 어떤 상황을 구성하느지 이해할 수 있고 각 테스트 메서드에서 상황을 구성하기 위해 코드가 중복되는 것도 방지할수 있다.
실행 환경이 다르다고 실패하지 않기
같은 테스트 메서드가 실행 환경에 따라 성공하거나 실패하면 안 된다.
실행 환경에 따라 문제가 되는 전형적인 예가 파일 경로이다.
public class BulkLoaderTest {
private String bulkFilePath = "d:\\mywork\\temp\\bulk.txt";
@Test
void load() {
BulkLoader loader = new BulkLoader();
loader.load(bulkFilePath);
// ...
}
}
파일경로는 항상 D드라이브를 포함하는데 만약 D드라이브가 없는 맥이나 같은 os라도 D드라이브가 없는 환경이면 실패하게 된다.
이렇게 테스트에서 사용하는 파일은 프로젝트 폴더를 기준으로
private String bulkFilePath = "src/test/resources/bulk.txt";
상대경로를 사용해야한다.
테스트 코드에서 파일을 생성하는 경우에도 특정 개발 환경에서만 올바르게 동작하지 않도록 구현해야 한다.
아래는 임시 폴더를 사용해서 테스트 코드를 작성한 예시다.
public class ExporterTest {
@Test
void export() {
String folder = System.getProperty("java.io.tempdir");
Exporter exporter = new Exporter(folder);
exporter.export("file.txt");
Path path = Paths.get(folder, "file.txt");
assertTrue(Files.exists(path));
}
}
이코드는 실행 환경에 알맞은 임시폴더 경로를 구해서 동작하기 때문에환경이 달라 테스트가 실패하는 상황은 벌어지지 않는다.
하지만 간혹 특정 OS 환경에서만 실행해야 하는 테스트도 있다. 이런경우 JUnit5가 제공하는 @EnabledOnOs 나 @DisabledOnOs 를 사용해서 테스트 실행 여부를 결정하면 된다.
실행 시점이 다르다고 실패하지 않기
다음 코드는 회원의 만료 여부를 확인하는 기능을 제공한다.
public class Member {
private LocalDateTime expiryDate;
public boolean isExpired() {
return expiryDate.isBefore(LocalDateTime.now());
}
}
이 기능을 검사하기 위한 테스트 코드는 다음과 같다.
@Test
void notExpired() {
// 테스트 코드 작성 시점 2019.1.1
LocalDateTime expiry = LocalDateTime.of(2019,12,31,0,0,0);
Member m = Member.builder().expiryDate(expiry).build();
assertFalse(m.isExpired());
}
이 코드를 작성한 시점에는 테스트가 실패하지 않지만 12월 31일을 넘어가면 테스트가 깨지게 된다.
이를위해 만료일을 먼 미래로 설정할수는 있지만 이보다는 테스트 코드에서 시간을 명시적으로 제어 할수 있는 방법을 선택하는 것이 좋다.
public class Member {
private LocalDateTime expiryDate;
public boolean passedExpiryDate(LocalDateTime time) {
return expiryDate.isBefore(time);
}
}
@Test
void notExpired() {
LocalDateTime expiry = LocalDateTime.of(2019,12,31,0,0,0);
Member m = Member.builder().expiryDate(expiry).build();
assertFalse(m.passedExpiryDate(LocalDateTime.of(2019,12,30,0,0,0));
}
이 코드는 실행 시점에 상관 없이 항상 통과한다.
시점을 제어하는 또 다른 방법은 별도의 시간 클래스를 작성하는 것이다.
public class BizClock {
private static BizClock DEFAULT = new BizClock();
private static BizClock instance = DEFAULT;
public static void reset() {
instance = DEFAULT;
}
public static LocalDateTime now() {
return instance.timeNow();
}
protected void setInstance(BizClock bizClock) {
BizClock.instance = bizClock;
}
public LocalDateTime timeNow() {
return LocalDateTime.now();
}
}
Member 에 isExpired()는 다음과 같이 BizClock 클래스를 이용해서 현재 시간을 구할수 있다.
public class Member {
private LocalDateTime expiryDate;
public boolean isExpired() {
return expiryDate.isBefore(BizClock.now());
}
BizClock 클래스의 setInstance() 메서드를 사용하면 인스턴스 정적필드를 교체할 수 있으므로 BizClock을 상속받은 하위 클래스를 이용하면 now()가 원하는 시간을 제공하도록 만들수 있다.
class TestBizClock extends BizClock { // 시간 값을 제어하기 위해 BizClock 클래스를 상속해서 구현한 클래스
private LocalDateTime now;
public TestBizClock() {
setInstance(this);
}
public void setNow(LocalDateTime now) {
this.now = now;
}
@Override
public LocalDateTime timeNow() {
return now != null ? now : super.now();
}
}
public class MemberTest {
TestBizClock testClock = new TestBizClock();
@AfterEach
void resetClock() {
BizClock.reset();
}
@Test
void notExpired() {
testClock.setNow(LocalDateTime.of(2019,1,1,13,0,0));
LocalDateTime expiry = LocalDateTime.of(2019,12,31,0,0,0);
Member m = Member.builder().expiryDate(expiry).build();
assertFalse(m.isExpired());
}
테스트 코드 활용이다.
랜덤하게 실패하지 않기
랜덤값을 이용하면 실행 시점에 따라 테스트가 실패할수 있다.
public class Game {
private int[] nums;
public Game() {
Random random = new Random();
int firstNo = random.nextInt(10);
// ...
this.nums = new int[] {firstNo, secondNo, thirdNo};
}
public score guess(int... answer) { ... }
정답이 랜덤으로 생성되기에 아무것도 일치하지 않는 경우조차 테스트가 불가능하다.
@Test
void notMatch() {
Game g =new Game();
Score s = g.guess(?,?,?); // 테스트를 통과시킬 수 있는 값이 매번 바뀜
assertEquals(0, s.strikes());
assertEquals(0, s.balls());
}
랜덤 결과 값이 검증에 영향을 준다면 구조를 변경해야 테스트가 가능하다.
Game 의 경우 생성자를 통해 값을 받도록 수정하면 테스트가 가능해진다.
public class Game {
private int[] nums;
public Game(int[] nums) {
// ...
this.nums = nums;
}
또는 게임 숫자 생성을 위한 클래스를 별도로 만드는 식으로 다른 객체에 위임하게 바꿔도 된다.
이렇게 위임한 객체의 대역을 사용하여 원하는 값을 갖고 클래스를 테스트 할수 있다.
필요하지 않은 값은 설정하지 않기
@Test
void dupIdExists_Then_Exception() {
// 동일 ID가 존재하는 상황
memberRepository.save(
User.builder().id("dupid").name("이름")
.email("abc@abc.com")
.password("abcd")
.regDate(LocalDateTime.now())
.build()
);
Register Req = RegisterReq.builder()
.id("dupid").name("다른이름")
.email("dupid@abc.com")
.password("abcde")
.build();
assertThrows(DupIdException.class,
()->userRegisterSvc.register(req)
);
}
검증할 내용에 비해 필요하지 않은 값까지 설정하고 있다.
테스트의 목적은 동일 ID가 존재하는 경우에 가입 가능한지를 검증하는것이 목적이기 때문에
동일 ID가 존재하는 경우 필요하지 않은 값을은 빼는것이 좋다.
@Test
void dupIdExists_Then_Exception() {
// 동일 ID가 존재하는 상황
memberRepository.save(
User.builder().id("dupid").build()
);
Register Req = RegisterReq.builder()
.id("dupid")
.build();
assertThrows(DupIdException.class,
()->userRegisterSvc.register(req)
);
}
필요한 값만 설정하게 되면 훨씬 보기 좋은 코드가 나타난다.
단위 테스트를 위한 객체 생성 보조 클래스
단위 테스트를 작성하다 보면 상황 구성을 위해 필요한 데이터가 다소 복잡할 때가 있다.
설문에 답변하는 기능을 구현하기 위해 다음에 해당하는 설문이 존재하는 상황이 필요하다고 하자.
- 설문이 공개 상태
- 설문 조사 기간이 끝나지 않음
- 설문 객관식 문항이 두 개
- 각 객관식 문항의 보기가 두 개
@Test
void answer() {
memberRepository.save(
Survey.builder().id(1L)
.status(SurveyStatus.OPEN)
.endOfPeriod(LocalDateTime.now().plusDay(5))
.quetionsList(asList(
new Question(...),
new Question(...)
)
).build()
);
anwserService.answer(...);
//...
}
null 이면 안되는 필수 속성이 많다면 상황 구성 코드는더 복잡해질 것이다.
이럴때 테스트를 위한 객체 생성 클래스를 따로 만들면 복잡도를 줄일수가 있다.
조건부로 검증하지 않기
만약 조건에 따라서 단언을 실행하지 않으면 그 테스트는 성공하지도 실패하지도 않은 테스트가 된다.
@Test
void canTranslateBasicWord() {
Transalator tr = new Transalator();
if (tr.contains("cat")) {
assertEquals("고양이", tr.transalte("cat"));
}
}
조건에 따라 단언을 하지 않는 테스트 이다.
이코드의 목적이 cat 정도의 기본 단어는 번역을 할수 있어야 한다는것을 테스트하는 목적이라면 이는 문제가 된다.
왜냐면 tr.contains("cat")가 false를 리턴하면 테스트가 실패하지 않기 때문이다.
@Test
void canTranslateBasicWord() {
Transalator tr = new Transalator();
assertTranslationOfBasicWord(tr,"cat");
}
private void assertTranslationOfBasicWord(Translator tr, String word) {
assertTrue(tr.contains("cat"));
assertEquals("고양이",tr.translate("cat"));
}
조건을 추가하여 수정해준 코드이다.
통합 테스트는 필요하지 않은 범위까지 연동하지 않기
@Component
public class MemberDao {
private JdbcTemplate jdbcTemplate;
public MemberDao(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public List<Member> selectAll() { ... }
}
@SpringBootTest
public class MemberDaoIntTest {
@Autowired
MemberDao dao;
@Test
void findAll() {
List<Member> members = dao.selectAll();
assertTrue(members.size() > 0);
}
}
잘못 만든 테스트 코드는 아니지만 한가지 단점이 잇다.
테스트 하는 대상은 DB와 연동을 처리하는 MemberDao 인데 @SpringBootTest 어노테이션을 사용하면 서비스, 컨트롤러 등 모든 빈을 초기화 한다는것이다.
이럴때에는 @JdbcTest 어노테이션을 이용하면 DataSource, JdbcTemplate 등 DB 연동과 관련된 설정만 초기화한다.
@JdbcTest
@AUtoConfigureTestDataBase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class MemberDaoJdbcTest {
@Autowired
JdbcTemplate jdbcTemplate;
private MemberDao dao;
@BeforeEach
void setUp() {
dao = new MemberDao(jdbcTemplate);
}
// ...
}
더 이상 쓸모없는 테스트 코드
LocalDateTime 을 문자열로 변환하는 코드가 필요한데 해당 클래스를 사용한 경험이 없다고 하자. 테스트 코드를 사용해서 해당 클래스의 포맷팅 방법을 익힐 수 있다.
@Test
void format() {
LocalDateTime dt = LocalDateTime.of(2019, 8, 15, 12, 0, 0);
assertEquals(
"2019-08-15 12:00:00",
dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
);
}
이 테스트 코드는 사용법을 익힌 후 더 이상 필요가 없다.
단지 테스트 커버리지를 높이기 위한 목적으로 작성한 테스트 코드도 유지할 필요가 없다.
@Test
void testGetter() {
User user = new User(1L, "이름");
assertEquals(1L, user.getId());
assertEquals("이름", user.getName());
}
이런 코드또한 유지보수에 도움이 되지 않기때문에 삭제하는것이 좋다
'책 리뷰 > 테스트 주도 개발 시작하기' 카테고리의 다른 글
Chapter 9 : 테스트 범위와 종류 (0) | 2024.12.06 |
---|---|
Chapter 8 : 테스트 가능한 설계 (0) | 2024.12.04 |
Chapter 7 : 대역 (0) | 2024.12.03 |
Chapter 6 : 테스트 코드의 구성 (0) | 2024.12.01 |
Chapter 5 : JUnit 5 기초 (0) | 2024.11.30 |