2jigoo / BookStudy-StartTdd

'테스트 주도 개발 시작하기' 스터디
2 stars 0 forks source link

[7주차-2] 테스트 코드와 유지보수 (2/2) - 2jigoo #31

Closed 2jigoo closed 9 months ago

2jigoo commented 1 year ago

Chapter 10. 테스트 코드와 유지보수 (2/2)

스터디 일시 2023.10.17

목표

2jigoo commented 1 year 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")
    );
}

검증을 위한 데이터 조회하는 로직도 마찬가지로 보조 클래스를 만들어 유지보수성을 높일 수 있음


실행 환경이 다르다고 실패하지 않기


실행 시점이 다르다고 실패하지 않기

Problem: 테스트하는 시점에 따라 테스트가 실패할 수 있음

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());
}

Solution 1: 파라미터로 기준 시간 전달 받기

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)));
}

Solution 2: 별도의 시간 클래스 작성

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());
    }
}
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)));
}


랜덤하게 실패하지 않기

랜덤 값 사용 역시 실행 시점에 따라 테스트가 실패함

Problem: nums가 랜덤하게 생성

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());
}

Solution 1: 생성자를 통해 값을 받도록 수정

public class Game {
    private int[] nums;

    public Game(int[] nums) {
        //넘겨받은 nums 확인 하는 로직
        // ... 
        this.nums = nums;
    }

    /* 생략 */
}

Solution 2: 랜덤 값 생성 로직을 클래스로 분리

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가 존재할 때 예외 던지기


단위 테스트를 위한 객체 생성 보조 클래스

상황 구성에 필요한 데이터가 복잡한 경우, 테스트를 위한 객체 생성 클래스를 따로 만들어 직관적으로 작성한다.

예) 설문 답변 기능: 아래 조건의 설문이 존재하는 상황 가정

Problem: 복잡한 상황 구성

@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( /* 생략 */);
    // ...
}

Solution 1: 테스트용 객체 생성 클래스 분리

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(/* 생략 */);
    // ...
}

Solution 2: 테스트용 객체를 생성하는 빌더

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 패턴을 이용해 변경하고 싶은 값만 설정하여 유연하게 변경

코틀린은 파라미터의 기본값을 지정할 수 있고, 파라미터명을 이용해 값을 전달할 수 있다.
이러한 특징으로 빌더와 팩토리 메서드를 하나로 합칠 수 있다.


조건부로 검증하지 않기

Problem

@Test
void canTranslateBasicWord() {
    Translator translator = new Translator();

    // ! 해당 조건이 false면, 단언하지 않아 실패하지도 성공하지도 않는 테스트가 된다 !
    if (translator.contains("cat")) {
        assertEquals("고양이", translator.translate("cat"));
    }
}

Solution

@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));
}


통합 테스트는 필요하지 않은 범위까지 연동하지 않기


더 이상 쓸모 없는 테스트 코드 삭제하기

테스트 커버리지(Test Coverage)
테스트하는 동안 실행하는 코드가 얼마나 되는지 설명하기 위한 지표

2jigoo commented 1 year ago

마치며

테스트 우선과 스트레스

회귀 테스트 (Regression Test)

  • 개발하고 테스트를 한 소프트웨어가, 이후에 코드를 수정해도 기존 코드가 올바르게 동작하는지 확인하기 위한 테스트
  • 결함을 발견하면 결함을 수정하고 이를 확인할 테스트를 만들어, 소프트웨어가 바뀔 때마다 실행해서 결함이 재발하지 않는지 확인