Closed 2jigoo closed 9 months ago
@Sql
어노테이션 등으로 데이터를 초기화하는 것은 셋업 메서드를 이용한 상황 설정과 같이 쿼리 파일을 조금만 변경해도 많은 테스트가 깨질 수 있다.
통합 테스트 작성 시 고려해야 할 초기화 데이터
각 테스트 메서드에서 상황을 직접 구성하면 분석하기는 쉬울 수 있으나, 상황을 만들기 위한 코드가 중복됨 → 보조 클래스 활용
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
);
}
}
private final JdbcTemplate jdbcTemplate;
private UserGivenHelper given;
@BeforeEach
void setUp {
given = new UserGivenHelper(jdbcTemplate);
}
@Test
void duplicatedId() {
// 데이터는 각 테스트 메서드에서 작성하고, 데이터 초기화 로직은 공통화
given.givenUser("cbk", "pw", "cbk@cbk.com");
// 실행 결과 확인
assertThrows(DuplicatedIdException.class,
() -> register.register("cbk", "strongpw", "email@email.com")
);
}
검증을 위한 데이터 조회하는 로직도 마찬가지로 보조 클래스를 만들어 유지보수성을 높일 수 있음
@EnabledOnOs
, @DisabledOnOs
를 활용public class Member {
private LocalDateTime expiryDate;
public boolean isExpired() {
return expiryDate.isBefore(LocalDateTime.now);
}
}
@Test
void notExpired() {
LocalDateTime expiry = LocalDateTime.of(2019, 12, 31, 0, 0, 0);
Member member = Member.builder().expiryDate(expiry).build();
assertFalse(member.isExpired());
}
public class Member {
private LocalDateTime expiryDate;
public boolean isExpired(LocalDateTime time) {
return expiryDate.isBefore(time);
}
}
@Test
void notExpired() {
LocalDateTime expiry = LocalDateTime.of(2019, 12, 31, 0, 0, 0);
Member member = Member.builder().expiryDate(expiry).build();
assertFalse(member.isExpired(LocalDateTime.of(2019, 12, 31, 0, 0, 0, 1000000)));
}
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();
}
}
public class Member {
private LocalDateTime expiryDate;
public boolean isExpired() {
return expiryDate.isBefore(BizClock.now());
}
}
BizClock#setInstance
를 호출하여 instance를 교체하면, BizClock#now
가 원하는 시간을 제공하도록 변경할 수 있다.class TestBizClock extends 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();
}
}
TestBizClock testClock = new TestBizClock();
@AfterEach() {
testClock.reset();
}
@Test
void notExpired() {
// TestClock#now를 원하는 시간으로 설정
testClock.setNow(LocalDateTime.of(2019, 1, 1, 13, 0, 0));
LocalDateTime expiry = LocalDateTime.of(2019, 12, 31, 0, 0, 0);
Member member = Member.builder().expiryDate(expiry).build();
assertFalse(member.isExpired(LocalDateTime.of(2019, 12, 31, 0, 0, 0, 1000000)));
}
랜덤 값 사용 역시 실행 시점에 따라 테스트가 실패함
public class Game {
private int[] nums;
public Game {
Random random = new Random();
int firstNum = random.nextInt(10);
// ...
this.nums = new int[] { firstNum, secondNum, thirdNum };
}
public Score guess(int ...answers) {
// ...
}
}
@test
void noMatch() {
Game game = new Game();
Score s = g.guess(?, ?, ?); // 테스트를 통과할 수 있는 값이 매번 바뀜
assertEquals(0, s.strikes());
assertEquals(0, s.balls());
}
public class Game {
private int[] nums;
public Game(int[] nums) {
//넘겨받은 nums 확인 하는 로직
// ...
this.nums = nums;
}
/* 생략 */
}
public class GameNumGen {
public int[] generate() {
// 랜덤하게 값 생성
// ...
}
}
@Test
void noMatch() {
// 랜덤 값 생성을 별도 클래스로 분리하여 대역으로 대체
GameNumGen gen = mock(GameNumGen.class);
given(gen.generate()).willReturn(new int[] { 1, 2, 3 });
Game game = new Game(gen);
Score score = g.guess(4, 5, 6);
assertEquals(0, s.strikes());
assertEquals(0, s.balls());
}
테스트의 목적에 맞는 내용까지만 값을 설정하고 검증한다
예) 동일 ID가 존재할 때 예외 던지기
검증에 필요한 값만 설정
@Test
void duplicatedIdExists_ThenException() {
memoryRepository.save(
User.builder()
.id("duplicatedId")
// .name("홍길동")
// .email("abc@abc.com")
// .password("abcd")
// .registerDate(LocalDateTime.now())
.build()
);
RegisterRequest request = RegisterRequest.builder()
.id("duplicatedId")
// .name("남길동")
// .email("def@abc.com")
// .password("abcde")
.build();
assertThrows(DuplicatedIdException.class,
() -> userRegisterService.register(request)
);
}
상황 구성에 필요한 데이터가 복잡한 경우, 테스트를 위한 객체 생성 클래스를 따로 만들어 직관적으로 작성한다.
예) 설문 답변 기능: 아래 조건의 설문이 존재하는 상황 가정
@Test
void answer() {
memorySurveyRepository.save(
Survey.builder()
.id(1L)
.status(SurveyStatus.OPEN)
.endOfPeriod(LocalDateTime.now().plusDay(5))
.questions(asList(
new Question(1, "질문1", asList(Item.of(1, "보기1"), Item.of(2, "보기2"))),
new Question(1, "질문2", asList(Item.of(1, "답1"), Item.of(2, "답2"))),
))
.build();
)
answerService.answer( /* 생략 */);
// ...
}
public class TestSurveyFactory {
public static Survey createAnswerableSurvey(Long id) {
return Survey.builder()
.id(1L)
.status(SurveyStatus.OPEN)
.endOfPeriod(LocalDateTime.now().plusDay(5))
.questions(asList(
new Question(1, "질문1", asList(Item.of(1, "보기1"), Item.of(2, "보기2"))),
new Question(1, "질문2", asList(Item.of(1, "답1"), Item.of(2, "답2")))
))
.build();
}
}
@Test
void answer() {
memorySurveyRepository.save(TestSurveyFactory.createAnswerableSurvey(1L));
answerService.answer(/* 생략 */);
// ...
}
public class TestSurveyBuilder() {
private Long id = 1L;
private String title = "제목";
private LocalDateTime endOfPeriod = LocalDateTime.now().plusDay(5);
private List<Question> questions = asList(
new Question(1, "질문1", asList(Item.of(1, "보기1"), Item.of(2, "보기2"))),
new Question(1, "질문2", asList(Item.of(1, "답1"), Item.of(2, "답2")))
);
private SurveyStatus status = SurveyStatus.READY;
// ... 필수 속성에 대한 기본 값 ...
public TestSurveyBuilder id(Long id) {
this.id = id;
return this;
}
public TestSurveyBuilder title(String title) {
this.title = title;
return this;
}
public TestSurveyBuilder open() {
this.status = SureveyStatus.OPEN;
return this;
}
/* ... questions(), endOfPeriod(), ... */
public Survey build() {
return Survey.builder()
.id(id)
.title(title)
.status(status)
.endOfPeriod(endOfPeriod)
.questions(questions)
// ...
.build(0;)
}
}
memorySurveyRepository.save(
new TestSurveyBuilder()
.title("새 제목")
.open()
.build()
);
Builder 패턴을 이용해 변경하고 싶은 값만 설정하여 유연하게 변경
코틀린은 파라미터의 기본값을 지정할 수 있고, 파라미터명을 이용해 값을 전달할 수 있다.
이러한 특징으로 빌더와 팩토리 메서드를 하나로 합칠 수 있다.
@Test
void canTranslateBasicWord() {
Translator translator = new Translator();
// ! 해당 조건이 false면, 단언하지 않아 실패하지도 성공하지도 않는 테스트가 된다 !
if (translator.contains("cat")) {
assertEquals("고양이", translator.translate("cat"));
}
}
@Test
void canTranslateBasicWord() {
Translator translator = new Translator();
assertTranslationOfBasicWord(translator, "고양이", "cat");
}
private void assertTranslationOfBasicWord(Translator translator, String expectedWord, String wordToTranslate) {
assertTrue(translator.contains(wordToTranslate));
assertEquals(expectedWord, translator.translate(wordToTranslate));
}
MemberDao
를 테스트 하는데 @SpringBootTest
어노테이션을 사용해서 모든 스프링 빈을 초기화
@JdbcTest
사용DataSource
와 JdbcTemplate
을 테스트 코드에서 직접 생성테스트 커버리지(Test Coverage)
테스트하는 동안 실행하는 코드가 얼마나 되는지 설명하기 위한 지표
회귀 테스트 (Regression Test)
- 개발하고 테스트를 한 소프트웨어가, 이후에 코드를 수정해도 기존 코드가 올바르게 동작하는지 확인하기 위한 테스트
- 결함을 발견하면 결함을 수정하고 이를 확인할 테스트를 만들어, 소프트웨어가 바뀔 때마다 실행해서 결함이 재발하지 않는지 확인
Chapter 10. 테스트 코드와 유지보수 (2/2)
목표