[Spring Boot] WebFliter의 테스트 코드를 DefaultServerWebExchange사용하여 작성해보자

spring boot 프로젝트의 WebFliter 테스트 코드를 작성하며 공부한 내용을 정리한다.

번역은 훗날의 내가 이해할 수 있을 정도의 수준으로 해놨으므로 서치타고 들어온 방문객들은 원문을 읽기를 권장한다.

 

공식문서


WebFilter 정의

Contract for interception-style, chained processing of Web requests that may be used to implement cross-cutting, application-agnostic requirements such as security, timeouts, and others.

인터셉터 스타일이다. web request들을 체인으로 엮어서 security, timeout 등을 다루기 위해 처리한다. 

 

WebFilter 만들기

class MyWebFilter : WebFilter {
	override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
    	TODO()
    }
}

 

상속받아서 구현해야만하는 메소드가 filter 하나 밖에 없다.

ServerWebExchange가 뭔지부터 살펴보자

ServerWebExchange

인터페이스다

Contract for an HTTP request-response interaction. Provides access to the HTTP request and response and also exposes additional server-side processing related properties and features such as request attributes.

 

HTTP 요청-응답을 처리하기 위한 규약이다. HTTP 요청-응답에 접근할 수 있다. request attributes처럼 서버 사이드 프로그래밍에 도움을 주는 추가적인 properties를 제공한다

 

Google  AI가 설명하길...

ServerWebExchange는 Spring WebFlux에서 요청 및 응답을 처리하는 데 사용되는 핵심 인터페이스입니다. 테스트 코드에서는 이를 모의하여 웹 요청과 응답을 시뮬레이션하고, 다양한 시나리오를 테스트할 수 있습니다. 주로 MockServerHttpRequest, MockServerHttpResponse와 함께 사용하여 테스트를 구성합니다. 

 

  • 이런 식으로 요청 헤더에 접근할 수 있다.
exchange.request.headers.getFirst("customUuid")

 

  • 설명에 나온것처럼 attrbutes에 접근하여 원하는 커스텀 값을 삽입해 Controller 까지 전달할 수 있다.
exchange.attributes["CUSTOM_PROPERTIES"] = mapOf (
	"key" to "value",
)

 

  • 요청을 거절하고 싶을 때 사용할 수도 있다.
exchange.response.statusCode = HttpStatus.BAD_REQUEST
exchange.response.headers.contetnType = MediaType.APPLICATION_JSON
return exchange.response.writeWith(
	Mono.just(
    	exchange.response.bufferFactory().wrap(errorMessage.toByteArray())
    )
)

 

return chain.filter(exhange)
	.doOnSuccess { ... }
	.doOnError { ... }

 


그럼 이제 MyWebFilter의 테스트 코드를 작성해보자...

class MyRequestFilterTest {}

막막함...

지인 분께 테스트 코드 특강을 들었다.

테스트 코드 짜는 법.

  1. 일단 돌아가는 간단한 테스트를 작성해본다.
  2. 어떻게든 테스트코드를 짜본다
  3. 진짜 테스트하고 싶은 로직의 테스트코드를 짠다
  4. 테스트코드에서 테스트하기 어려운 부분과 쉬운 부분을 쪼갠다
  5. 그 둘을 분리한다.

일단 돌아가는 간단한 테스트를 짜보겠다.


1. 돌아가는 간단한 테스트 작성

@Test
fun `testcode should work`() {
	val a = "a"
    assertEquals("a", a) // success
}

성공했다.

2. 어떻게든 돌아가는 테스트코드를 짜본다.

그러려면 일단 뭐가 필요한지 생각해보자.

WebFilter를 테스트하는 것이므로, 그것의 인자인 ServerWebExhange와 WebFilterChain이 필요하다.

ServerWebExchange를 만들려면 뭐가 필요한지 클래스를 까서 봐보자.

인터페이스인데, 구현해야하는 함수가 엄청나게 많다... 이걸 테스트코드에 하나하나 다 구현하는 건 아닌 것 같다...

GPT한테 물어봤다.

 

ServerWebExchange의 구현체 중 하나인 DefaultServerWebExchange는 직접 생성이 가능하고, MockServerHttpRequest와 MockServerHttpResponse를 함께 사용하면 쉽게 구성할 수 있다.

 

그렇구나.

 

 

구글에 DefaultServerWebExchange를 검색해본다.

DefaultServerWebExchange is the concrete, default implementation of the ServerWebExchange interface in Spring WebFlux. It represents a server-side HTTP request-response interaction. It provides access to the request, response, and other server-side processing features like attributes and sessions. 

 

그리고 놀랍게도!! Spring framework에서 만들어 둔 해당 클래스의 테스트 코드가 존재한다... https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/test/java/org/springframework/web/server/adapter/DefaultServerWebExchangeTests.java#L86

createExchange()함수는 MockServerHttpRequest를 사용하여 get 요청을 보낸다. 이 request를 인자로 사용하여 DefaultServerWebExchange를 생성할 수 있다.

DefaultServerWebExchange는 ServerWebExchange를 구현한 것이므로 unboxing이 자동으로 이루어져서..

ServerWebExchange exchange = createExchange();

위와 같은 코드가 탄생할 수 있다. 우리는 ServerWebExchange 객체를 만들어냈다!

내 경우엔 get, post 요청을 따로 지정하고 싶었기 때문에 .get하는 부분은 수정했다.

 

private fun createExchange(request: MockServerHttpRequest): DefaultServerWebExchange {
	return DefaultServerWebExchange(
    	request, 
        mockServerHttpResponse,
        defaultWebSessionManger,
        serverCodecConfigurer,
        localContextResolver
    )
}

여기서 좀 고생했다. java code에서는 new ...()를 해주면 자동으로 객체가 생성되니 문제가 없었는데

코틀린에서는 new를 어떻게하는지 모르겠더라... thus must initiallized here라는 컴파일러 오류가 발생했다.

 

찾아보니 lateinit을 사용하라고 하더라. lateinit은 init이 된 뒤에 해당 객체를 처리하도록 해준다.

// 컴파일러 오류 발생하는 코드
abstract class Parent(val self: Parent)
class Child : Parent(this) // this must be initialized here // this는 생성자 호출 시점에는 아직 초기화되지 않았기 때문에 사용할 수 없다.

// 패스하는 코드
abstract class Parent {
    lateinit var self: Parent // init이 실행된 뒤에 생성되도록 함
}
class Child : Parent() {
    init { self = this }
}

 

lateinit만 작성하면 해당 객체가 초기화되지 않았다는 오류가 발생하므로 @BeforeEach를 사용해서 꼭 init을 해주자

lateinit property mockServerHttpRequest has not been initalized

class RequestFilterTest {
	private lateinit var serverCodecConfigurer : ServerCodecConfigurer
	...
    
    @BeforeEach
    fun setUp() {
    	serverCodecConfigurer = mockk(relaxed = true)
        ...
    }
}

 

그리고 테스트 코드를 사용하면 정상적으로 createExchange가 실행된다.

정상적으로 작동하는지 간단한 테스트코드 먼저 작성해보자.

@Test
fun `mockWebExchange should work`() {
	val exchange = createExchange(mockServerHttpRequest)
    asserEquals(exchange.transformUrl("/foo"), "/foo")
}

3. 진짜 테스트하고 싶은 로직의 테스트코드를 짠다

이 java 코드를 참고해보자...

MockServerHttpRequest에 어떤 요청을 보낼건지를 셋팅해주면 되는 모양이다..

.post

.header

.body

를 셋팅하자...


🔊 header의 map을 만들 때 자꾸 같은 컴파일러 오류를 마주쳐서 기록해놓는다

 

Interface MultiValueMap does not have constructors

MultiValueMap은 interface이기 때문에, 직접 인스턴스를 생성할 수 없다는 뜻이다.

val map = MultiValueMap<String, String>() // ❌ 불가능

 

이건 마치 Java에서 다음을 하는 것과 같다:

List<String> list = new List<>(); // ❌ 안 됨 (List는 인터페이스)

 

대신 구현체(implementation) 를 사용해야 한다. 예를 들어 LinkedMultiValueMap이 대표적인 구현체다.

 
import org.springframework.util.LinkedMultiValueMap
import org.springframework.util.MultiValueMap

val map: MultiValueMap<String, String> = LinkedMultiValueMap()
map.add("key", "value")

Spring의 WebFilterChain도 인터페이스이므로 테스트용 구현이 필요하다.

private class TestWebFilterChain : WebFilterChain {
    var called = false

    override fun filter(exchange: ServerWebExchange): Mono<Void> {
        called = true
        return Mono.empty()
    }
}

 

test하려고하는 filter의 객체를 구현해준다. chain도 객체를 생성한 뒤 인자로 넘겨주자.

val filter = MyWebFilter()
val chain = TestWebfilterChain()

filter.filter(exchange, chain).block()

 

MyWebFilter에서는 exchange에 attribute로 "key"에 "value"를 부여하는 코드가 있다.

테스트 코드에서는 attribute에 값을 추가한 적이 없으므로, 이 테스트코드가 실제로 MyWebFilter를 거쳤는지를 해당 값을 조회하여 확인할 수 있을 것이다.

val customProperties = exchange.attributes[CUSTOM_PROPERTIES] as Map<String, String>
assertNotNull( customProperties.getValue("key") // success

 

이로써 mock객체로  ServerWebExchange를 만들고 실제로 Filter를 거치는 테스트를 완성했다.

4, 5번 과정은 아직 진행하지 못했다. 기억이 휘발되기 전에 기록하자는 마음으로 기록 먼저 시작함...

 

아래부터는 삽질 내용이다.


삽질


1) test 실패했다는데 뭔 말인지 알아들을 수가 없다.

Verification failed: call 6 of 6: MockServerHttpResponse(#5).writeWith(eq(MonoJust)).
Only one matching call to MockServerHttpResponse(#5)/writeWith(Publisher) happened,
but arguments are not matching:
[0] argument: MonoJust,
    matcher: eq(MonoJust),
    result: -

 

하나하나 뜯어서 번역해보면...

  • Verification failed: call 6 of 6: 
    Mockk가 총 6개의 호출을 감지했고, 그 중 6번째 호출에서 문제가 생겼다는 뜻이다
  • MockServerHttpResponse(#5).writeWith(eq(MonoJust)).
    검증하려던 호출: response.writeWith(...)라는 메서드를 호출했는지 확인하려고 한 것.
    eq(MonoJust)는 Mono.just(...)를 정확히 넘겼는지 확인하려고 한 것.
  • Only one matching call to MockServerHttpResponse(#5)/writeWith(Publisher) happened,
    실제로 writeWith(...)는 1번 호출되었음. 즉, 호출 자체는 있었음.
  • but arguments are not matching:
    그러나 넘긴 인자가 기대한 값과 다름.
    즉, writeWith(...)는 호출됐지만 Mono.just(...) 안의 내용이 예상과 다름.
  • [0] argument: MonoJust,
    비교된 인자는 둘 다 Mono.just(...)였지만,

        matcher: eq(MonoJust),
        result: -
    내용이 다르기 때문에 result: -로 불일치.

2) 람다를 verify하면 안된다

verify { filter.filter(exchange, chain) }

 

filter가 실제로 호출됐는지를 확인하고 싶었는데 이게 문제였다.

verify 구문에서 doOnSuccess(...) 호출을 검증하려고 했지만, Consumer (람다) 가 예상한 것과 다르다는 이유로 검증이 실패한 것.

구문 의미
call 2 of 3 총 3번 호출 중 2번째 호출에 대한 검증 실패
Mono(...).doOnSuccess(...) doOnSuccess 메서드가 호출되었는지 확인하려 함
eq(com...Lambda@...) 특정 람다(함수 객체)가 전달되었는지 확인하려 함
arguments are not matching 실제 호출된 doOnSuccess의 람다와, verify에 넘긴 람다가 동일하지 않음

 

 

람다는 참조값 비교*이므로, eq{람다}는 거의 항상 실패한다.

verify { mono.doOnSuccess(eq { println("hello") }) } // 실패

참조값 비교는 두 변수가 가리키는 메모리 상의 주소 값이 동일한지 확인하는 것을 의미함

람다는 JVM에서 고유 객체로 취급되기 때문에 인스턴스가 따로 생기고 따라서 메모리 주소가 동일할 수 없으므로...

따라서 eq(람다)는 대부분 실패한다.


해결법: side-effect 기반 테스트

람다는 mock verify로 직접 검증하지 말고 실행 결과로 확인할 것

 

람다 자체를 verify { ... }로 검증하는 건 어렵기 때문에, 람다 안에서 일어난 "결과"를 검증하는 방식으로 테스트해야 한다.

이걸 side-effect 기반 테스트라고 한다.

>>> 요것이 위에서 소개한 최종 코드가 되었다.

val customProperties = exchange.attributes[CUSTOM_PROPERTIES] as Map<String, String>
assertNotNull( customProperties.getValue("key") // success

3) expected: <null> but was: <[null]>

assertEquals("null", customProperties.getValue("key") )

 

이 코드의 에러 메세지였다. [null] 뭔데. 알고나서 보면 참 쉬운데 막상 부딪치면 힘든 것 그것이 코딩라이프.. ㅜ

[null]은 List에 null이 들어가있단 소리다. List<null>

 

null vs [null] (null하나짜리 리스트) 간의 비교여서 실패.

val result = listOf(null)
assertEquals(null, result)

 

해결법 : firstOrNull()

assertEquals(null, value?.firstOrNull())

 


그 외에 WebClientTest의 예제를 볼 수 있는 페이지다 

https://docs.spring.io/spring-framework/reference/testing/webtestclient.html