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
- 테스트하는 동안 실행하는 코드가 얼마나 되는지 설명하기 위해 사용하는 지표
- 보통 비율 사용