Home > TDD(Test-Driven Development) > 통합 테스트

통합 테스트
java

이전 글들을 모두 읽고 오자 !!

Dependencies
- Spring Boot 3.4.1
- Junit 5.10.5
- WireMock

통합 테스트 예시 - 스프링 부트

  • 스프링 부트 통합 테스트는 크게 2가지로 나눠볼 수 있다.
    1. Controller 통합 테스트
      • Controller와 관련된 계층(Controller, Service, Repository 등)을 통합적으로 테스트
      • @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) 사용
    2. 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가지가 있다.
    1. 테스트에서 실제 외부 API 호출
    2. 외부 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/