이전 글들을 모두 읽고 오자 !!
Dependencies
- Spring Boot 3.4.1
- Junit 5.10.5
- WireMock
통합 테스트 예시 - 스프링 부트
- 스프링 부트 통합 테스트는 크게 2가지로 나눠볼 수 있다.
- Controller 통합 테스트
- Controller와 관련된 계층(Controller, Service, Repository 등)을 통합적으로 테스트
- @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) 사용
- Service 통합 테스트 -> 예시에서 보여줄 테스트!
- Service와 관련된 계층(Service, Repository 등)을 통합적으로 테스트
- @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) 사용
- 1은 2에 비해 테스트 범위가 넓다는 장점이 있지만, 테스트 속도가 느리다는 단점이 있다.
0. 테스트에 사용될 객체들
@Entity
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "email")
private String email;
@Column(name = "password")
private String password;
@Column(name = "card_number")
private String cardNumber;
@Builder
public User(String email, String password) {
this.email = email;
this.password = password;
}
public boolean registerCard(String cardNumber) {
this.cardNumber = cardNumber;
}
}
public interface UserRepository extends Repository<User,Long> {
User save(User user);
Optional<User> findByEmail(String email);
Optional<User> findById(Long id);
}
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final CardValidator cardValidator;
@Transactional
public Long signUp(String email, String password) {
userRepository.findByEmail(email)
.ifPresent(user -> throw new ExpectedException(ErrorCode.ALREADY_EXISTED_USER););
User user = User.builder()
.email(email)
.password(password)
.build();
return userRepository.save(user).getId();
}
@Transactional
public boolean registerCard(Long userId, String cardNumber) {
CardValidity cardValidity = cardValidator.validate(cardNumber);
if (cardValidity == CardValidity.TIMEOUT) {
throw new ExpectedException(ErrorCode.EXTERNAL_API_TIMEOUT);
}
if (cardValidity != CardValidity.VALID) {
throw new ExpectedException(ErrorCode.INVALID_CARD_NUMBER);
}
User user = userRepository.findById(userId)
.orElseThrow(() -> new ExpectedException(ErrorCode.USER_NOT_FOUND));
return user.registerCard(cardNumber);
}
}
public enum CardValidity {
INVALID, ERROR, EXPIRED, UNKNOWN, THEFT, TIMEOUT, VALID
}
@Service
public class CardNumberalidator {
private final String server;
public CardNumberValidator(@Value("${card.server.url}") 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))
.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;
}
}
}
@Getter
@RequiredArgsConstructor
public class ExpectedException extends RuntimeException {
private final ErrorCode errorCode;
}
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
ALREADY_EXISTED_USER("ALREADY_EXISTED_USER", "이미 존재하는 유저입니다."),
USER_NOT_FOUND("USER_NOT_FOUND", "존재하지 않는 유저입니다."),
INVALID_CARD_NUMBER("INVALID_CARD_NUMBER", "유효하지 않는 카드번호입니다."),
EXTERNAL_API_TIMEOUT("EXTERNAL_API_TIMEOUT", "외부 API 응답 타임아웃 발생");
private final String errorCode;
private final String errorMessage;
}
1. 스프링 부트 통합 테스트 - 데이터베이스
@ActiveProfiles("test")
@SpringBootTest // 스프링 컨테이너 초기화
public class UserServiceIntegrationTest {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private UserRepository userRepository; // 대역 대신 실제 객체 사용
@Autowired
private UserService userService;
@BeforeEach
void setUp() {
jdbcTemplate.update("truncate table users"); // 테스트 전 데이터를 초기화
}
@Test
@DisplayName("회원 가입 성공")
void signUp_Success() {
// given
User user = User.builder()
.email("joyuri@gmail.com")
.password("yuri123")
.build();
// when
Long userId = userService.signUp("joyuri@gmail.com", "yuri123");
// then
assertThat(userId).isGreaterThan(0);
}
@Test
@DisplayName("회원 가입 시 이미 존재하는 유저이면 예외 발생")
void signUp_AlreadyExistedUser_Then_Exception() {
// given
User existingUser = User.builder()
.email("joyuri@gmail.com")
.password("yuri123")
.build();
userRepository.save(existingUser);
// when & then
assertThatThrownBy(() -> userService.signUp("joyuri@gmail.com", "yuri123"))
.isInstanceOf(ExpectedException.class)
.satisfies(e -> {
assertThat(e.getErrorCode()).isEqualTo(ErrorCode.ALREADY_EXISTED_USER);
}
);
}
}
2. 스프링 부트 통합 테스트 - 외부 API
- 통합 테스트하기 어려운 대상이 외부 API이다. 외부 API를 사용하는 로직에 대한 통합 테스트 작성 방법은 크게 2가지가 있다.
- 테스트에서 실제 외부 API 호출
- 외부 API를 Mocking (Stub 역할)
- 방법 1은 아래와 같은 불편함이 존재한다.
- 다양한 테스트 상황을 만들기 어렵다. (ex) 타임아웃이 발생하도록 처리 시간 설정)
- 일부 외부 API는 호출에 비용이 발생한다.
- 방법 2는 이러한 불편함을 해소할 수 있다.
- 외부 API를 Mocking 해주는 도구로는 WireMock이 있다.
- 아래의 예시처럼 WireMockServer 객체를 수동으로 관리하는 방법 대신 @AutoConfigureWireMock 어노테이션을 사용하는 방법도 있다. @AutoConfigureWireMock 예시
@ActiveProfiles("test")
@SpringBootTest
@TestPropertySource(properties = "card.server.url=http://localhost:8089")
public class UserServiceIntegrationTest {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private UserRepository userRepository;
@Autowired
private UserService userService;
private WireMockServer wireMockServer; // WireMockServer는 HTTP 서버를 흉내냄
@BeforeEach
void setUp() {
jdbcTemplate.update("truncate table users"); // 테스트 전 데이터를 초기화
wireMockServer = new WireMockServer(options().port(8089));
wireMockServer.start(); // 실제 HTTP 서버가 뜸
}
@AfterEach
void tearDown() {
wireMockServer.stop();
}
@Test
@DisplayName("카드 번호가 유효하면 카드 등록 성공")
void valid() {
// given
wireMockServer.stubFor(post(urlEqualTo("/card"))
.withRequestBody(equalTo("123456789"))
.willReturn(aResponse()
.withHeader("Content-Type", "text/plain")
.withBody("ok"))
); // Stub => WireMockServer의 동작을 기술
User user = User.builder()
.email("joyuri@gmail.com")
.password("yuri123")
.build();
Long userId = userRepository.save(user).getId();
// when
boolean isValidated = userService.registerCard(userId, "123456789");
// then
assertThat(isValidated).isTrue();
User savedUser = userRepository.findById(userId)
.orElseThrow(() -> new ExpectedException(ErrorCode.USER_NOT_FOUND));
assertThat(savedUser.getCardNumber()).isEqualTo("123456789");
}
@Test
@DisplayName("외부 API 응답 타임아웃 테스트")
void timeout() {
// given
wireMockServer.stubFor(post(urlEqualTo("/card"))
.willReturn(aResponse()
.withFixedDelay(5000)) // 5초
); // Stub => WireMockServer의 동작을 기술
User user = User.builder()
.email("joyuri@gmail.com")
.password("yuri123")
.build();
Long userId = userRepository.save(user).getId();
// when & then
assertThatThrownBy(() -> userService.registerCard(userId, "123456789"))
.isInstanceOf(ExpectedException.class)
.satisfies(e -> {
assertThat(e.getErrorCode()).isEqualTo(ErrorCode.EXTERNAL_API_TIMEOUT);
}
);
}
}
- 참고 자료
테스트 주도 개발 시작하기(저자: 최범균)
https://martinfowler.com/articles/practical-test-pyramid.html
https://techblog.woowahan.com/17674/