통합 테스트 이렇게만 하자🧪- 2편

임규성·2025년 3월 12일

설계는 완료

"이전 글에서 통합 테스트 관련 스터디를 완료하고, 설계까지 완료함"

"따라서 이제는 실제 구현을 해봐야한다"

테스트 환경


spring-boot 3.4.x : 메인 프레임 워크
Junit 5 : testing 메서드를 위해서 활용
webtestclient :servlet위에서 실제 요청을 하기위한 클라이언트
Test-container : 실제 인프라를 도커위에올리기 위해서 사용한 라이브러리


구현하면서 궁금했던 점

Q1) MVC로 http 클라이언트로 설정하면 왜 Security를 건너 뛰는건지??

아래는 공식문서에서 나타내는 MockMvcWebTestClient 설명와 예시코드 입니다.

"For Spring MVC, use the following .......(생략 )"

--> 즉 spring MVC테스트에 적합한 클라이언트임

코드

@ExtendWith(SpringExtension.class)
@WebAppConfiguration("classpath:META-INF/web-resources") 
@ContextHierarchy({
	@ContextConfiguration(classes = RootConfig.class),
	@ContextConfiguration(classes = WebConfig.class)
})
class MyTests {

	@Autowired
	WebApplicationContext wac; 

	WebTestClient client;

	@BeforeEach
	void setUp() {
		client = MockMvcWebTestClient.bindToApplicationContext(this.wac).build(); 
	}
}

현재 프로젝트는 WebFlux아키텍처가 아닌 MVC아키텍처 였기 때문에 초기 구현은 해당 클라이언트를 사용해줬음

하지만...!!!

이렇게 하게되면 Spring-Security인가 필터를 거치지 않게 되었음...!!
왜냐하면 mvc단에서만 요청을 처리하게 때문에 그보다 상위인 Servlet을 거치지 않게됨
따. 라. 서 해당 클라이언트를 폐기하기로 결정!!


Q2) 정확히 어떤것까지 테스팅을 할건지?

이전 설계에서 결국 통합 테스트에서 중요한점은 1)스프링 빈과의 결합 2) 요청에 맞는 알맞은 쿼리 요청 이었다.

하지만 어느정도 디테일까지 테스팅을 해야할지 몰라서 이 부분을 확실히 잡고 갔다.

✅ 유닛테스트에서 이미 디테일한 로직에 테스트를 완료했음
✅ 통합테스트에서 또 해당로직을 재점검 한다면 같은 동작을 반복하게되며 역할 분리가 필요

결론!

요청부터 응답까지 정상적으로 되는지를 확인하며 이때 확인하는 방법은, 올바른 요청값 형태로 부터 올바른 응답값 형태가 오느냐만을 확인한다!!


Q3) 인증 인가를 어떻게 세팅하지?

결국 실제환경과 거의 유사한 환경을 만들기 때문에 인증 인가까지 처리를 해야했습니다.

2가지 방식을 구상해봤는데요...

  1. Redis에 실제 세션을 삽입 후 @BeforeAll로 세팅하고 통합테스트 시 쿠키에 저장
  2. 회원 Repository에 유저 삽입 후 실제 로그인 API 호출 후 세션획득 및 쿠키에 저장

1번 방식
속도가 빠름 -> 추가적인 api호출이 없기 때문
난이도가 높음 -> 실제 세션 추가 획득같은 로직을 추적해서 정확한 Key-value에 값을 Redis에 등록해야함
2번 방식
속도가 느림 -> 추가적인 api호출이 있기 때문에
난이도가 낮음 -> webtestclient로 응답을 받아오면 쉽게 해결됨

결론

"속도는 느려질 수 있어도 더 안전하고 빠르게 개발이 완료가 가능한 방식이기 때문에 2번 방식을 채택하기로함!!"


핵심 코드

InitIntegration 클래스

모든 Integration에서 공유하고 재사용할 수 있게 하기 위해 공통 부모클래스를 구현함

핵심 로직

  1. 테스트 컨테이너를 통해 필요한 인프라를 도커 컨테이너 위에서 실행
    -> @BeforeAll로 모든 통합 테스트에서 1번만 실행하는 것을 목표로 함
  2. Login API를 동작해 protected변수로 자식 에게 넘겨줌
  3. webTestClient 인스턴스를 protected변수로 자식 에게 넘겨줌

코드

@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("integration")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(locations = "classpath:application-integration-test.yml")
public class InitIntegrationTest {

  // WebTestClient를 주입받아 HTTP 요청을 테스트할 수 있도록 설정
  @Autowired
  protected WebTestClient client;

  // Redis와 MySQL의 Docker 컨테이너 실행
  private static final String MYSQL_IMAGE = "mysql:latest";
  private static final String REDIS_IMAGE = "redis:latest";
  private static final int REDIS_PORT = 6379;

  @Container
  @ServiceConnection
  static public GenericContainer<?> redis = new GenericContainer<>(
      DockerImageName.parse(REDIS_IMAGE))
      .withExposedPorts(REDIS_PORT)
      .waitingFor(Wait.forListeningPort())
      .waitingFor(Wait.defaultWaitStrategy());

  @Container
  static public MySQLContainer<?> mysql = new MySQLContainer<>(DockerImageName.parse(MYSQL_IMAGE))
      .waitingFor(Wait.forListeningPort());

  @BeforeAll
  static void setUp() {
    System.setProperty("spring.data.redis.host", redis.getHost());
    System.setProperty("spring.data.redis.port", redis.getFirstMappedPort().toString());
    System.setProperty("spring.datasource.url", mysql.getJdbcUrl());
    System.setProperty("spring.datasource.username", mysql.getUsername());
    System.setProperty("spring.datasource.password", mysql.getPassword());
  }

  @AfterAll
  static void tearDown() {
    System.clearProperty("spring.data.redis.host");
    System.clearProperty("spring.data.redis.port");
    System.clearProperty("spring.datasource.url");
    System.clearProperty("spring.datasource.username");
    System.clearProperty("spring.datasource.password");
  }

  protected static String sessionToken;

  /**
   * 실제 로그인 요청 이후 sessionToken에 유효한 세션 저장
   *
   * @param client
   * @param memberRepository
   * @param carRepository
   * @param passwordEncoder
   */
  @BeforeAll
  static void initTestUser(@Autowired WebTestClient client,
      @Autowired MemberRepository memberRepository,
      @Autowired CarRepository carRepository,
      @Autowired BCryptPasswordEncoder passwordEncoder) {

    CarEntity integrationCar = CarEntity.builder()
        .carNumber("integration-car")
        .carType(CarType.COMPACT)
        .isElectric(false).build();

    MemberEntity member = MemberEntity.builder()
        .authId("integration")
        .birthday("01-01")
        .birthdayYear(1990)
        .email("integration@example.com")
        .loginPlatform(LoginPlatform.NORMAL)
        .password(passwordEncoder.encode("integration"))
        .phoneNumber("01012341234")
        .role(MemberRole.ROLE_USER)
        .userName("name1")
        .carEntity(integrationCar)
        .build();

    carRepository.save(integrationCar);
    memberRepository.save(member);

    // 로그인 요청 후 세션 토큰 저장
    Map<String, String> loginRequest = Map.of(
        "username", "integration",
        "password", "integration"
    );

    client.post()
        .uri("/api/v1/auth/login")
        .contentType(MediaType.APPLICATION_FORM_URLENCODED)
        .body(BodyInserters.fromFormData("username", loginRequest.get("username"))
            .with("password", loginRequest.get("password")))
        .exchange()
        .expectStatus().isOk()
        .expectBody()
        .consumeWith(response -> {
          // 전체 쿠키 목록 출력
          Map<String, List<ResponseCookie>> cookies = response.getResponseCookies();
//          System.out.println("🔍 전체 쿠키 목록: " + cookies);

          // "SESSION" 쿠키에서 값을 가져옵니다.
          sessionToken = cookies.get("SESSION").stream()
              .findFirst()
              .map(ResponseCookie::getValue)  // 쿠키 값만 추출
              .orElse(null);

          // sessionToken이 null이 아닌지 검증
          assertThat(sessionToken)
              .as("로그인 후 세션 쿠키가 정상적으로 반환되어야 합니다.")
              .isNotNull();

        });
  }

PR링크


REF

  1. Spring Web test client 공식 문서
profile
삶의 질을 높여주는 개발자

0개의 댓글