"이전 글에서 통합 테스트 관련 스터디를 완료하고, 설계까지 완료함"
"따라서 이제는 실제 구현을 해봐야한다"
spring-boot 3.4.x : 메인 프레임 워크
Junit 5 : testing 메서드를 위해서 활용
webtestclient :servlet위에서 실제 요청을 하기위한 클라이언트
Test-container : 실제 인프라를 도커위에올리기 위해서 사용한 라이브러리
아래는 공식문서에서 나타내는 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을 거치지 않게됨
따. 라. 서 해당 클라이언트를 폐기하기로 결정!!
이전 설계에서 결국 통합 테스트에서 중요한점은 1)스프링 빈과의 결합 2) 요청에 맞는 알맞은 쿼리 요청 이었다.
하지만 어느정도 디테일까지 테스팅을 해야할지 몰라서 이 부분을 확실히 잡고 갔다.
✅ 유닛테스트에서 이미 디테일한 로직에 테스트를 완료했음
✅ 통합테스트에서 또 해당로직을 재점검 한다면 같은 동작을 반복하게되며 역할 분리가 필요
결론!
요청부터 응답까지 정상적으로 되는지를 확인하며 이때 확인하는 방법은, 올바른 요청값 형태로 부터 올바른 응답값 형태가 오느냐만을 확인한다!!
결국 실제환경과 거의 유사한 환경을 만들기 때문에 인증 인가까지 처리를 해야했습니다.
2가지 방식을 구상해봤는데요...
- Redis에 실제 세션을 삽입 후 @BeforeAll로 세팅하고 통합테스트 시 쿠키에 저장
- 회원 Repository에 유저 삽입 후 실제 로그인 API 호출 후 세션획득 및 쿠키에 저장
1번 방식
속도가 빠름 -> 추가적인 api호출이 없기 때문
난이도가 높음 -> 실제 세션 추가 획득같은 로직을 추적해서 정확한 Key-value에 값을 Redis에 등록해야함
2번 방식
속도가 느림 -> 추가적인 api호출이 있기 때문에
난이도가 낮음 -> webtestclient로 응답을 받아오면 쉽게 해결됨
결론
"속도는 느려질 수 있어도 더 안전하고 빠르게 개발이 완료가 가능한 방식이기 때문에 2번 방식을 채택하기로함!!"
모든 Integration에서 공유하고 재사용할 수 있게 하기 위해 공통 부모클래스를 구현함
핵심 로직
- 테스트 컨테이너를 통해 필요한 인프라를 도커 컨테이너 위에서 실행
-> @BeforeAll로 모든 통합 테스트에서 1번만 실행하는 것을 목표로 함- Login API를 동작해 protected변수로 자식 에게 넘겨줌
- 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();
});
}