Engineering

[테스트 주도 개발 시작하기] 9 ~ 10장

unknownomad 2023. 6. 22. 01:59

<9장> 테스트 범위와 종류

 

테스트 범위

웹 어플리케이션의 일반적인 구성 요소

 

테스트 범위에 따른 테스트 종류

 

테스트 관련 용어

  • 문맥이나 사용자에 따라 의미가 다를 때도 있음
  • 통합 테스트 : 개발 완료 후 진행하는 최종 테스트
  • 인수 테스트 : 고객 입장에서 요구한 기능을 올바르게 구현했는지 수행하는 테스트 or 요건을 완료했는지 정의하기 위해 작성한 테스트

 

기능 테스트

  • Functional Testing
  • 사용자 입장에서 시스템이 제공하는 기능이 올바르게 동작하는지 확인
  • 시스템을 구동 및 사용하는데 필요한 모든 구성 요소 필요
  • e.g. 회원 가입 기능 작동 테스트
    • 웹 서버, 데이터베이스, 웹 브라우저 필요 (+ 문자 발송 기능 필요하다면 외부 문자 발송 서비스도 필요)
  • 사용자가 직접 사용하는 웹 브라우저나 모바일 앱부터 시작해, 데이터베이스나 외부 서비스에 이르기까지 모든 구성 요소를 하나로 엮어 진행
  • 끝(브라우저)에서 끝(데이터베이스)까지 모든 구성 요소를 논리적으로 완전한 하나의 기능으로 다룸
  • E2E (End to end) 테스트
  • QA 조직에서 수행하는 주요한 테스트 = 시스템이 필요로 하는 데이터를 입력 후 결과가 올바른지 확인

 

통합 테스트

  • Integration Testing
  • 시스템의 각 구성 요소가 올바르게 연동되는지 확인
  • 기능 테스트가 사용자 입장에서 테스트하는 데 반해, 통합 테스트는 소프트웨어의 코드를 직접 테스트
  • e.g. 모바일 앱
    • 기능 테스트는 앱을 통해 가입 기능 테스트
    • 통합 테스트는 서버의 회원 가입 코드를 직접 테스트
  • 일반적인 웹 어플리케이션 : 프레임워크, 라이브러리, 데이터베이스, 구현한 코드가 주요 통합 테스트 대상
  • 구현한 기능과 관련한 모든 구성 요소를 합친 부분을 테스트
  • 로직이 들어간 서비스 클래스
  • 스프링 프레임워크나 마이바티스 설정
  • SQL 쿼리나 DB 트랜잭션 등

 

단위 테스트

  • Unit Testing
  • 개별 코드나 컴포넌트가 기대한대로 동작하는지 확인
  • 한 클래스나 한 메서드 같은 작은 범위를 테스트
  • 일부 의존 대상은 스텁이나 모의 객체 등을 이용해 대역으로 대체

 

테스트 범위 간 차이

통합 테스트 기능 테스트 단위 테스트
DB 나 캐시 서버 같은 연동 대상 구성해야

DB 연결, 소켓 통신, 스프링 컨테이너 초기화 같이, 테스트 실행 속도를 느리게 만드는 요인이 많음
웹 서버 구동하거나 모바일 앱을 폰에 설치해야 할 수도

추가로 브라우저나 앱을 구동하고, 화면의 흐름에 따라 알맞은 상호작용 해야
테스트 코드를 빼면 따로 준비할 것 없음
서버를 구동하거나 DB 필요 없음

테스트 대상이 의존하는 기능을 대역으로 처리하면 되기에 실행 속도 빠름
통합 테스트나 기능 테스트로는 상황 준비 및 결과 확인이 어려울 때가 있음
외부 시스템과 연동해야 하는 기능이 특히 그런 경우
➡️ 단위 테스트와 대역을 조합해 상황 만든 후 결과 확인하기

통합 테스트 실행 시 준비할 것도 많고, 단위 테스트에 비해 실행 시간도 길지만,
단위 테스트를 아무리 많이 만들어도 결국 각 구성 요소가 올바르게 연동되는 것을 확인해야 하는데,
이를 자동화하기 좋은 수단이 통합 테스트임

 

테스트 피라미드

  • 단위 테스트가 통합 테스트보다 테스트 속도가 빠르기에, 가능하면 단위 테스트에서 다양한 상황을 다루자
  • 통합 테스트나 기능 테스트는 주요 상황에 초점을 맞추자
  • ➡️ 테스트 실행 시간이 증가해 피드백이 느려지는 상황 방지

 

외부 연동이 필요한 테스트 예

  • 대부분 웹 어플리케이션은 DB 와의 연동 필요
  • HTTP 이용한 통신도 증가
  • 카프카 이요한 메시지 송수신도 증가 추세

 

통합 테스트

@SpringBootTest
public class UserRegisterIntTest {

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

    @Test
    void 동일ID가_이미_존재하면_익셉션() {
        // 상황
        // ON DUPLICATE KEY 사용해 insert 쿼리 오류 발생 방지
        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")
        );
    }

    @Test
    void 존재하지_않으면_저장함() {
        // 상황
        jdbcTemplate.update("delete from user where id = ?", "cbk");
        // 실행
        register.register("cbk", "strongpw", "email@email.com");
        // 결과 확인
        SqlRowSet rs = jdbcTemplate.queryForRowSet("select * from user where id = ?", "cbk");
        rs.next();
        assertEquals("email@email.com", rs.getString("email"));
    }
}

 

단위 테스트

  • 상황을 만들기 위해 대역 사용
public class UserRegisterTest {

    private UserRegister userRegister;
    private MemoryUserRepository fakeRepository = new MemoryUserRepository();
    
    ...
    
    @DisplayName("이미 같은 ID가 존재하면 가입 실패")
    @Test
    void dupIdExists() {
        // 이미 같은 ID 존재하는 상황 만들기
        fakeRepository.save(new User("id", "pw1", "email@email.com"));

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

 

테스트 실행 시간

  • 스프링 부트를 이용한 통합 테스트는 테스트 메서드를 실행하기 전에 스프링 컨테이너를 생성하는 과정 필요
  • 단위 테스트는 이런 과정 없기에 테스트 실행 시간 매우 짧음

 

외부의 HTTP 서버를 이용하는 코드

public class CardNumberValidator {

    private String server;

    // 생성자 이용해 서버 주소 받기
    public CardNumberValidator(String server) {
        this.server = server;
    }

    public CardValidity validate(String cardNumber) {
        HttpClient httpClient = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(server + "/card"))
                .header("Content-Type", "text/plain")
                .POST(BodyPublishers.ofString(cardNumber))
                .timeout(Duration.ofSeconds(3)) // 타임아웃 3초
                .build();
        try {
            HttpResponse<String> response = httpClient.send(request, BodyHandlers.ofString());
            switch (response.body()) {
                case "ok": return CardValidity.VALID;
                case "bad": return CardValidity.INVALID;
                case "expired": return CardValidity.EXPIRED;
                case "theft": return CardValidity.THEFT;
                default: return CardValidity.UNKNOWN;
            }
        } catch (HttpTimeoutException e) {
            return CardValidity.TIMEOUT; // 타임아웃 관련 익셉션 별도 처리
        } catch (IOException | InterruptedException e) {
            return CardValidity.ERROR;
        }
    }
}

 

WireMock 이용한 REST 클라이언트 테스트

  • 통합 테스트 하기 어려운 대상 = 외부 서버
  • WireMock 통해 서버 API 를 스텁으로 대체 가능
  • 응답 시간 지연도 가능 ➡️ 응답 타임아웃 테스트 진행 가능
  • JSON/XML 응답, HTTPS 지원, 단독 실행 등 다양한 기능 제공
public class CardNumberValidatorTest {

    private WireMockServer wireMockServer;

    @BeforeEach
    void setUp() {
        wireMockServer = new WireMockServer(options().port(8089));
        wireMockServer.start();
    }

    @AfterEach
    void tearDown() {
        wireMockServer.stop();
    }

    @Test
    void valid() {
        wireMockServer.stubFor(post(urlEqualTo("/card"))
                .withRequestBody(equalTo("1234567890"))
                .willReturn(aResponse()
                        .withHeader("Content-Type", "text/plain")
                        .withBody("ok"))
        );

        CardNumberValidator validator =
                new CardNumberValidator("http://localhost:8089");
        CardValidity validity = validator.validate("1234567890");
        assertEquals(CardValidity.VALID, validity);
    }

    @Test
    void timeout() {
        wireMockServer.stubFor(post(urlEqualTo("/card"))
                .willReturn(aResponse()
                        .withFixedDelay(5000)) // 5000밀리 초(5초) 뒤 응답 전송하게 설정
        );

        CardNumberValidator validator =
                new CardNumberValidator("http://localhost:8089");
        CardValidity validity = validator.validate("1234567890");
        assertEquals(CardValidity.TIMEOUT, validity);
    }
}

 

스프링 부트의 내장 서버를 이용한 API 기능 테스트

  • 테스트에서 웹 환경을 구동할 수 있는 기능 제공
  • 내장 서버 구동 ➡️ 스프링 웹 어플리케이션 실행
  • TestRestTemplate
    • 스프링 부트가 테스트 목적으로 제공하는 것
    • 내장 서버에 연결하는 RestTemplate
@SpringBootTest(
    // 임의 포트를 사용해 내장 서버 구동하도록 설정
    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UserApiE2ETest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void weakPwResponse() {
        String reqBody = "{\"id\": \"id\", \"pw\": \"123\", \"email\": \"a@a.com\" }";
        RequestEntity<String> request =
                RequestEntity.post(URI.create("/users"))
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .body(reqBody);

        ResponseEntity<String> response = restTemplate.exchange(
                request,
                String.class);

        assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
        assertTrue(response.getBody().contains("WeakPasswordException"));
    }
}

 


 

<10장> 테스트 코드와 유지보수

 

테스트 코드와 유지보수

CI

  • Continuous Integration; 지속적 통합

CD

  • Continuous Delivery / Deployment; 지속적 전달 / 배포

 

  • 지속적 코드 통합 및 출시 가능 상태로 만들고 배포하려면 새로 추가한 코드가 기존 기능을 망가뜨리지 않는지 확인 필요
  • ➡️ 자동화 테스트는 CI / CD 의 필수 요건 중 하나

 

테스트 코드

  • 코드 변경 시 기존 기능이 올바르게 동작하는지 확인하는 회귀 테스트(regrassion test)를 자동화하는 수단으로 사용

 

깨진 유리창 이론

  • Broken Windows Theory, BWT
  • Fixing Broken Windows: Restoring Order and Reducing Crime in Our Communities
  • 깨진 유리창 하나를 방치하면, 그 지점을 중심으로 범죄가 확산되기 시작한다는 이론
  • 테스트 코드도 한두 개의 실패한 테스트를 방치하기 시작하면 점점 실패하는 테스트가 증가해, 테스트의 목적을 잃고, 설상가상으로 소프트웨어 품질 저하까지 연결됨

 

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

e.g.1. 복잡하지 않게, 기대하는 값 명확하게 표현하기

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

e.g.2.

  • 객체 생성할 때 사용한 값이 무엇인지 알아보기 위해 필드와 변수를 참조하지 않아도 됨
  • 단언할 때 사용한 값이 무엇인지 알기 위해 필드와 변수를 오갈 필요도 없음
@DisplayName("답변에 성공하면 결과 저장함")
@Test
public void saveAnswerSuccessfully() {
    
    // 답변할 설문이 존재
    Survey survey = SurveyFactory.createApprovedSurvey(1L);
    surveyRepository.save(survey);
    
    // 설문 답변
    // 객체 생성 시 변수와 필드 대신 값 자체 사용
    SurveyAnswerRequest surveyAnswer = SurveyAnswerRequest.builder()
        .surveyId(1L)
        .respondentId(100L)
        .answers(Arrays.asList(1, 2, 3, 4))
        .build();
    svn.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.getAnswers().get(1)),
        () -> assertEquals(3, savedAnswer.getAnswers().get(2)),
        () -> assertEquals(4, savedAnswer.getAnswers().get(3))
    );
}

 

두 개 이상을 검증하지 않기

  • 테스트 메서드가 반드시 한 가지만 검증해야 하는 것은 아니나,
  • 검증 대상이 명확하게 구분되면 테스트 메서드도 구분하는 것이 유지보수에 유리함
@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");

    assertTrue(spyEmailNotifier.isCalled());
    assertEquals(
            "email@email.com",
            spyEmailNotifier.getEmail());
}

 

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

  • 모의 객체는 "pw" 가 아니라 임의의 문자열에 대해 true 를 리턴해도 아래 테스트의 의도에 전혀 문제되지 않게 해야 함
@DisplayName("약한 암호면 가입 실패")
@Test
void weakPassword() {

    // 모의 객체가 임의의 String 값에 대해 true 를 리턴하도록 설정
    // Mockito.anyString(): 범용적인 값을 사용해, 약간의 코드 수정 때문에 테스트 깨지는 현상 방지
    BDDMockito
        .given(mockPasswordChecker.checkPasswordWeak(Mockito.anyString()))
        .willReturn(true);

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

 

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

  • 내부 구현은 언제든 바뀔 수 있기에, 테스트 코드는 내부 구현보다 실행 결과를 검증해야 함
  • 이미 존재하는 코드에 단위 테스트 추가 시, 어쩔 수 없이 내부 구현을 검증해야 할 때도 있음

Before

public void changeEmail(String id, String email) {
    int cnt = userDao.countById(id);
    if(cnt == 0) {
        throw new NoUserException();
    }
    userDao.updateEmail(id, email);
}

After

@Test
void changeEmailSuccessfully() {
    given(mockDao.countById(Mockito.anyString())).willReturn(1);
    
    emailService.changeEmail("id", "new@somehost.com");
    
    // 이메일 수정 확인 위해, 모의 객체의 updateEmail() 메서드 호출 여부 확인
    // 모의 객체 호출 여부 확인 = 구현 검증
    // But, 이메일 변경 확인 수단은 이것 뿐임
    then(mockDao).should()
        .updateEmail(Mockito.anyString(), Mockito.matches("new@somehost.com"));
}
  • 기능이 정상적으로 동작하는지 확인할 수단이 구현 검증 밖에 없다면
  • 모의 객체를 사용해 테스트 코드를 작성해야 하나,
  • 이후 점진적으로 코드 리팩토링을 통해 구현이 아닌 결과를 검증하도록 시도해야 함
  • ➡️ 향후 사소한 구현 변경으로 인한 테스트 깨짐을 방지할 수 있고, 테스트 가능성 높일 수 있음

 

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

  • 동일한 상황 필요할 때 중복 코드 제거하기

e.g. @BeforeEach 이용해 상황 구성

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

@Test
...
  • 테스트 실패 사유를 알기 위해 어떤 상황인지 확인해야 하는데, setUp() 메서드를 다시 봐야 함
  • 즉 코드를 위아래로 이동하며 실패 원인을 분석해야 함
  • 또 setUp() 메서드를 이용한 상황 설정은 깨지기 쉬운 테스트 구조를 만듦
  • 그렇기에 상황 설정 코드를 테스트 메서드에서 직접 하도록 변경 필요
@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", "name", "pw", new Address("서울", "북부"))
    );
    
    changeService.changeAddress("id", new Address("경기", "남부"));
    
    User user = memoryRepository.findById("id");
    assertEquals("경기", user.getAddress().getCity());
}

...
  • 코드가 다소 길어지더라도 테스트 메서드 자체가 스스로를 더 잘 설명하는 방향으로 개선해야
  • 테스트에 실패해도 코드를 위아래 오가며 보지 않아도 됨
  • 실패한 테스트 메서드 위주로 코드 확인하면 됨
  • 각 테스트에 맞게 상황 설정도 쉬움, why? 한 메서드의 상황을 변경해도 다른 테스트에 영향 주지 않기에

통합 테스트에서 데이터 공유 주의하기

e.g. 스프링 프레임워크의 @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("cbk", "strongpw", "email@email.com")
        );
    }
    
    ...
}
-- init-data.sql
truncate table user;
insert into user values('cbk', 'pw', 'cbk@cbk.com');
insert into user values('tddhit', 'pw1', 'tddhit@ilovetdd.com');
  • 통합 테스트 메서드가 데이터 초기화를 위한 코드를 작성하지 않아도 되게 만들어줌
  • 그러나 셋업 메서드를 이용한 상황 설정처럼, 초기화 위한 쿼리 파일을 조금만 변경해도 많은 테스트가 깨질 수 있고, 관련 쿼리 파일도 함께 확인해야 함
  • 통합 테스트 코드 만들 때 두 가지로 초기화 데이터 나눠 생각해야 함
    • 모든 테스트가 같은 값을 사용하는 데이터 : e.g. 코드값 데이터(거의 바뀌지 않음)
    • 테스트 메서드에서만 필요한 데이터 : e.g. 중복 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")
    );
}

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

  • 위 사례처럼 각 테스트 메서드에서 상황 직접 구성 시, 테스트 메서드를 분석하기는 좋으나,
  • 상황을 만들기 위한 코드가 여러 테스트 코드에서 중복됨
  • 테이블 이름이나 컬럼 이름 변경 시 테스트 코드를 수정해야 하기에 유지보수에 좋지 않음
  • 테스트 메서드에서 직접 상황 구성 + 코드 중복 없애는 방법 = 상황 설정 위한 보조 클래스 사용
  • 결과 검증 시에도 직접 쿼리 실행하고 값 비교해야 하는데, 이를 위해 결과 검증을 위한 보조 클래스를 만들어 활용하면 됨
@Autowired JdbcTemplate jdbcTemplate;
private UserGivenHelper given;

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

@Test
void dupId() {
    // givenUser(): 어떤 상황을 구성하는지 이해 가능
    // + 각 테스트 메서드에서 상황 구성하기 위해 코드가 중복되는 것도 방지 가능
    given.givenUser("cbk", "pw", "cbk@cbk.com");
    
    // 실행, 결과 확인
    assertThrows(DupIdException.class,
        () -> register.register("cbk", "strongpw", "email@email.com")
}

 

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

e.g.1. 프로젝트 폴더 기준, 상대 경로 활용하기

  • 절대 경로 사용 시, OS 에 따라 실패할 수 있음
public class BulkLoaderTest {
    
    private String bulkFilePath = "src/test/resources/bulk.txt";
    
    @Test
    void load() {
        BulkLoader loader = new BulkLoader();
        loader.load(bulkFilePath);
    }
}

e.g.2. 시스템이 제공하는 임시 폴더 경로 사용하기

@Test
@EnabledOnOs({ OS.LINUX, OS.MAC })
void callBash() {
    // ...
}

@Test
@DisabledOnOs(OS.WINDOWS)
void changeMode() {
    // ...
}

 

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

e.g.1.

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

@Test
void expired_Only_1ms() {
    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, 1000000));
}

e.g.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();
    }
    
    // instance 정적 필드 교체 가능
    // BizClock 상속받은 하위 클래스 이용 시, BizClock#now() 가 원하는 시간 제공 가능
    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();
    }
}
public class MemberTest {
    
    TestBizClock testClock = new TestBizClock();
    
    @AfterEach
    void resetClock() {
        BizClock.reset();
    }
    
    @Test
    void notExpired() {
        // TestBizClock 통해 테스트 코드의 시간을 원하는 시점으로 제어 가능
        testClock.setNow(LocalDateTime.now(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());
    }
}

랜덤하게 실패하지 않기

  • e.g.1. 직접 랜덤 값 생성하지 말고, 생성자 통해 값 받게 하기
public class Game {
    
    private int[] nums;
    
    public Game(int[] nums) {
        ... 값 확인 코드
        this.nums = nums;
    }
}
  • e.g.2. 랜덤 값 생성을 다른 객체에 위임하기
public class GameNumGen {
    public int[] generate() {
        ... 랜덤하게 값 생성
    }
}
public class Game {
    
    private int[] num;s
    
    public Game(GameNumGen gen) {
        nums = gen.generate();
    }
}
@Test
void noMatch() {
    
    // 랜덤 값 생성을 별도 타입으로 분리
    // /이를 대역으로 대체해서 대체
    GameNumGen gen = mock(GameNumGen.class);
    given(gen.generate()).willReturn(new int[] {1, 2, 3});
    
    Game g = new Game(gen);
    Score s = g.guess(4, 5, 6);
    assertEquals(0, s.strikes());
    assertEquals(0, s.balls());
}

 

필요하지 않은 값은 설정하지 않기

  • 검증에 필요한 값만 세팅하기
@Test
void dupIdExists_Then_Exception() {

    // 동일 ID 가 존재하는 상황
    memoryRepository.save(User.builder().id("dupid").build());
    
    RegisterReq req = RegisterReq.builder()
        .id("dupid")
        .build();
        
    assertThrows(DupIdException.class,
        () -> userRegisterSvc.register(req));
}

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

  • 상황 구성에 필요한 데이터가 복잡할 때
  • null 이면 안 되는 필수 속성이 많을 때

e.g.1.

  • 테스트 위한 객체 생성용 팩토리 클래스를 따로 만들면 복잡함을 줄일 수 있음
public class TestSurveyFactory {

    public static Survey createAnswerableSurvey(Long id) {
        return Survey.builder()
            .id(id).status(SurveyStatus.OPEN)
            .endOfPeriod(LocalDateTime.now().plusDays(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: 답변 가능 상태인 Survey 객체 생성
        TestSurveyFactory.createAnswerableSurvey(1L)
    );
    
    answerService.answer(...생략);
    ...
}

e.g.2.

  • 빌더 패턴으로 유연함을 더할 수도 있음
public class TestSurveyBuilder {

    private Long id = 1L;
    private String title = "제목";
    private LocalDateTime endOfPeriod = LocalDateTime.now().plusDays(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 value) {
        this.title = value;
        return this;
    }
    
    public TestSurveyBuilder open() {
        this.status = SurveyStatus.OPEN;
        return this;
    }
    
    // ... 생략(questions(), endOfPeriod() 등 메서드
    
    public Survey build() {
        return Survey.builder()
            .id(id).title(title).status(status)
            .endOfPeriod(endOfPeriod)
            .questions(questions)
            // ... 생략
            .build();
    }
}
// TestSurveyBuilder 사용해 기본 값 대신 변경하고 싶은 속성만 설정 가능
memorySurveyRepo.save(new TestSurveyBuilder.title("새로운 제목").open().build());

파라미터의 기본 값을 지원하는 언어와 팩토리 메서드

e.g. 코틀린 : 빌더 + 팩토리 메서드 구현 가능

fun createTestSurvey(
        id: Long = 1L, title: String = "제목", status = SurveyStatus.READY,
        endOfPeriod: LocalDateTime = LocalDateTime.now().plusDays(5),
        questions: List<Question> = listOf(
            Question(1, "질문1", listOf(Item(1, "보기1"), Item(1, "보기2"))),
            Question(2, "질문2", listOf(Item(1, "답1"), Item(1, "답2")))
            )
        ): Survey {
    return Survey.builder()
        .id(id).title(title).status(status)
        .endOfPeriod(endOfPeriod)
        .questions(questions)
        // ... 생략
        .build()
}

...

@Test
fun answer() {
    memorySurveyRepository.save(
        createTestSurvey(id = 10L, status = SurveyStatus.OPEN))
}

 

조건부로 검증하지 않기

  • 테스트는 성공하거나 실패해야 하는데, 그럼 반드시 단언을 실행해야 함
  • 만약 조건에 따라 단언하지 않으면 그 테스트는 성공도 실패도 하지 않은 결과물이 됨

Before

@Test
void canTranslateBasicWord() {
    Translator tr = new Translator();
    if(tr.contains("cat")) {
        assertEquals("고양이", tr.translate("cat"));
    }
}

After

@Test
void canTranslateBasicWord() {
    Translator tr = new Translator();
    assertTranslationOfBasicWord(tr, "cat");
}

private void assertTranslationOfBasicWord(Translator tr, String word) {
    // tr.translate("cat") 를 단언하기에 앞서, tr.contains("cat") 이 true 인지 검사
    assertTrue(tr.contains("cat"));
    assertEquals("고양이", tr.translate("cat"));
}

 

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

e.g.1. 스프링의 JdbcTemplate 사용해 데이터 연동하는 경우

@Component
public class MemberDao {
    
    private JdbcTemplate jdbcTemplate;
    
    public MemberDao(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
    
    public List<Member> selectAll() {
        // ... 생략
    }
}

e.g.1-1. ➡️ 스프링 부트 프로젝트 사용 시 @SpringBootTest 통해 DB 연동 테스트 진행 가능

  • 근데 @SpringBootTest 사용 시, 서비스, 컨트롤러 등 모든 스프링 빈을 초기화함
  • DB 관련 설정 외에 나머지 설정도 처리하기에 스프링 초기화 시간 길어질 수 있음
@SpringBootTest
public class MemberDaoIntTest {
    
    @Autowired
    MemberDao dao;
    
    @Test
    void findAll() {
        List<Member> members = dao.selectAll();
        assertTrue(member.size() > 0);
    }
}

e.g.1-2. ➡️ @JdbcTest 통해 DataSource, JdbcTemplate 등 DB 연동 관련 설정만 초기화하기

  • 다른 빈 생성하지 않으므로 스프링 초기화 시간 짧아짐
  • DataSource 와 JdbcTemplate 을 테스트 코드에 직접 생성 시, 스프링 초기화 과정이 빠지기에 테스트 시간 더 짧아짐
@JdbcTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class MemberDaoJdbcTest {
    
    @Autowired
    JdbcTemplate jdbcTemplate;
    
    private MemberDao dao;
    
    @BeforeEach
    void setUp() {
        dao = new MemberDao(jdbcTemplate);
    }
    
    @Test
    void findAll() {
        // ... 생략
    }
}

 

더 이상 쓸모 없는 테스트 코드

  • 특정 클래스 사용법을 익히기 위해 작성한 테스트 코드
  • 테스트 커버리지를 높이기 위해 존재하는 코드
  • 위의 경우들처럼 의미 있는 테스트가 아니라면 삭제

 

테스트 커버리지

  • Test coverage
  • 테스트하는 동안 실행하는 코드가 얼마나 되는지 설명하기 위해 사용하는 지표
  • 보통 비율 사용