JUnit 5 + Kotlin 테스트 클래스에서 생성자 주입 이슈

서론

제가 현재 근무하고 있는 카카오 엔터프라이즈 AI 서비스 플랫폼실 서버개발팀은 테스트 코드의 중요성을 강조하는 팀입니다.

보다 퀄리티 높은 코드와 보다 편리한 문서 작업을 위해 (Spring RestDocs) 테스트 코드를 잘 작성하는 것은 개발자의 기본이자 미덕이지요.

그러나 저같은 0년차 주니어 개발자 입장에서 스프링은 여전히 어렵고 JUnit이라는 테스트 프레임워크는 낯선데다가, Kotlin이라는 언어는 더 생소하기까지 합니다.

그래서 테스트 코드를 작성하는 일이 쉽지 않았으며 지금도 여전히 테스트 코드와 씨름을 하면서 TDD라는 꿈을 향해 달려가고 있습니다.

문제 상황

테스트 코드에서 생성자를 이용하여 빈 객체를 주입받으려고 했으나 오류가 발생한 것이 문제의 발단입니다.

@SpringBootTest
@AutoConfigureMockMvc
class PostControllerTest (
        val mockMvc: MockMvc,
        val postRepository: PostRepository
){


    @Test
    fun getPost() {
        val post = Post()
        post.title = "jpa"
        postRepository.save(post)

        mockMvc.perform(
                get("/posts/${post.id}")
                        .contentType(MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(status().isOk)
                .andExpect(content().string("jpa"))
    }
}

분명 코틀린 생성자 주입을 메인 코드에서 썼을 때는 잘 됐었는데 왜 테스트코드에서는 실패하는 것인지 의아했습니다.

설상 가상으로 Intellij의 빈(콩) 아이콘도 나오지 않았습니다.

스크린샷 2020-06-28 오전 2 03 12


어딨니 콩아..


이 때 느낌적인 느낌으로 뭔가 빈 주입이 안되고 있음을 느끼긴 했지만 왜 그런가 계속 고민하고 있었습니다.

그 때 같은 팀 개발자 PJ가 비슷한 상황을 이전에 겪어보셨다고 하셔서 다음과 같이 코드를 수정해주셨습니다.

@SpringBootTest
@AutoConfigureMockMvc
class PostControllerTest @Autowired constructor(
        val mockMvc: MockMvc,
        val postRepository: PostRepository
){


    @Test
    fun getPost() {
        val post = Post()
        post.title = "jpa"
        postRepository.save(post)

        mockMvc.perform(
                get("/posts/${post.id}")
                        .contentType(MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(status().isOk)
                .andExpect(content().string("jpa"))
    }
}

@Autowired constructor를 추가하자 이제 IDE에서 콩모양도 잘 보이기 시작했습니다.

스크린샷 2020-06-28 오전 1 09 08

환호도 잠시 저는 깊은 생각에 빠졌습니다.

@Autowired constructor을 붙여야지만 빈 주입이 되는가..?

그렇습니다.

여러분도 잘 아시는 개발자의 딜레마가 시작된 것이지요.

112
5555

어 되네? 어 안되네?


다행히 학부생 시절부터 이런 짤들을 너무 많이 봐왔었기 때문에 저같은 개발자가 한 두명이 아닐 것이라는 것이 조금이나마 위안이 되었습니다.

그래서 차분하게 집에와서 문제를 파헤치기 시작했고 그 나름의 답을 찾아서 지금부터 공유를 드리고자 합니다. (잘못된 부분이 있으면 피드백 부탁드립니다.)

그럼 지금부터 논리적으로 이 문제가 왜 발생했으며 왜 해결됐는지를 하나씩 살펴보도록 하겠습니다.

에러로그를 읽어보자

PJ가 저의 코드를 봐주시면서 하셨던 말씀은

이럴 땐 에러 로그를 잘 봐야해요 해리, 여기에 답이 있어요.

라는 말씀이 기억납니다.

당시에는 빈 주입 외에도 다른 복잡하게 얽힌 다른 에러들이 있어 지금보다 훨씬 에러 로그가 길었습니다. (그래서 약간 멘붕하기도 했습니다.)

지금 여러분들에게 공유하는 에러는 당시 발생한 여러 에러들 중 오늘 주제에 대한 에러 재현해서 보여드리고자 합니다.

org.junit.jupiter.api.extension.ParameterResolutionException: No ParameterResolver registered for parameter [org.springframework.test.web.servlet.MockMvc mockMvc] in constructor [public me.harry.jpa.PostControllerTest(org.springframework.test.web.servlet.MockMvc,me.harry.jpa.PostRepository)].

대략 Jupiter가 해당 파라미터에 대해 등록된 ParameterResolver가 없다는 예외를 내뱉고 있었습니다.

우선 Jupiter란게 정확히 무엇이며 왜 저런 예외를 발생시키는지에 대해서 알아야했습니다.

저는 문제를 해결하기에 앞서 간략히 JUnit 5의 구조에 대해서도 공부를 해야할 필요성을 느꼈습니다.

JUnit 5는 어떻게 구성되어있는지 Jupiter의 역할은 정확히 무엇인지에 대해 알아야 이 문제의 해결에 실마리를 잡을 수 있을 것 같았습니다.

그래서 다음 부분은 JUnit 5의 구조에 대한 설명을 하려고 합니다. (이미 알고 계신분들은 스킵하시면 됩니다. 여러분의 시간은 소중하니까요.)

JUnit 5 Architecture

JUnit 5의 Architecture 크게 세 부분으로 나뉘어 집니다.

Jupiter, Vintage, JUnit Platform

스크린샷 2020-06-28 오전 1 22 36


각각 하나씩 자세히 알아보자면,

JUnit Platform은 테스트를 실행할 수 있는 엔진을 포함하고 여러 Tool(콘솔, 이클립스, Intellij등의 IDE)에 일관성 있는 API를 제공해주는 역할을 담당합니다.

JupiterVintage 모두 JUnit Platform을 구현하는 구현체입니다.

차이점이 있다면 JupiterJUnit 5의 구현체이며 Vintage는 이름만 들어도 느껴지지만 하위 버전(JUnit 4, JUnit 3)들에 대한 지원을 위해 구현된 구현체 입니다.

그래서 우리는 일반적으로 JUnit 5로 테스트 코드를 작성한다고 하면 Jupiter를 사용한다고 생각하면 되겠습니다.

여담이지만 JUnit팀이 이러한 JUnit Platform을 기반으로 구현체를 만드는 방식을 사용한 이유가 있습니다.

Jupiter나 Vintage처럼 다른 써드파티 구현체들이 JUnit Platform의 스펙을 준수해서 API를 구현하게 해서

오픈소스 기반으로 개발자 생태계에 JUnit이 기여하고 똑똑한 개발자들이 JUnit Platform을 기반으로 한 창의적인 테스트 프레임워크를 개발하기를 바랬기 때문입니다.

그에 따른 여러 JUnit 기반 오픈소스 테스트 프레임워크들도 속속 등장하고 있습니다. (Spec, Specsy 등등)

이는 결국 JUnit이라는 테스트 프레임워크 생태계를 구축하고 넓히려는데 그 목적이 있다고 볼 수 있겠습니다. (이미 자바 테스트 프레임워크 점유율 90% 이상인데…)

그렇다면 Jupiter는 왜 ParameterResolver를 못찾는가?

생성자 매개변수의 경우 메인 코드라면 Spring IoC 컨테이너가 이를 해결합니다.

스프링 프레임워크의 Application Context는 등록할 빈들을 찾아서 저장했다가 적절한 시점에 빈을 생성자에 주입하는 방식인 것은 다들 알고 계실 것입니다.

그렇기 때문에 Kotlin의 생성자는 @Autowired 애노테이션 없이도 스프링 컨테이너가 알아서 잘 생성자 주입을 해왔던 것이지요.

그러나 테스트 코드의 경우 조금 다릅니다.

테스트 프레임워크에서의 생성자 매개변수 관리는 스프링 컨테이너가 아닌 Jupiter가 담당합니다.

그래서 @Autowired를 명시적으로 선언해주어야 Jupiter가 Spring Container에게 빈 주입을 요청할 수 있게 되는 것이지요.

테스트 프레임워크에서 프레임워크의 주체는 Jupiter이기 때문에 아무리 생성자 주입이라 한들 @Autowired 애노테이션이 명시되지 않은 객체는 의존성 주입을 받을 수 없게 되는 것이지요.

최종적으로 Jupiter는 자기 딴에는 열심히 생성자 매개변수를 처리할 ParamterResolver를 뒤져보지만 그런게 있을리 없고, 결국 예외를 뱉게 되는 것이죠.

스크린샷 2020-06-28 오전 1 48 52 스크린샷 2020-06-28 오전 1 48 57 스크린샷 2020-06-28 오전 1 50 13 스크린샷 2020-06-28 오전 1 50 16

마치며

프레임워크의 사전적 의미는 뼈대라는 뜻이 있지만, 소프트웨어에서 프레임워크와 라이브러리의 차이를 이야기할 때 누가 코드를 컨트롤 하고 있느냐로 프레임워크인지, 라이브러리인지가 결정됩니다.

즉 제가 이 문제에서 헤맸던 이유도 테스트 프레임워크에서 코드를 컨트롤 하는 주체가 누구인지를 몰랐기 때문에 발생했던 문제였던 것이라고 생각합니다.

어쨌든 이번 기회로 많은 것들을 배울 수 있었는데 혼자 알게된 것 보다는 나눠서 더 많은 사람들에게 도움이 됐으면 하는 바램으로 이 글을 작성해보았습니다.

비록 부족한 글이지만 누군가에게는 도움이 되셨기를 바라며 글을 마치겠습니다.

끝으로 문제 해결에 도움을 주신 PJ에게 진심으로 다시 한번 감사드립니다.

Reference

더 자바, 애플리케이션을 테스트하는 다양한 방법 - 백기선님

https://blog.codefx.org/design/architecture/junit-5-architecture-jupiter/#JUnit-5

모르면 물어봐야지



© 2022. by minkuk

Powered by minkuk