본문 바로가기
스프링

[Spring] 테스트 클래스는 빈으로 등록이 될까? [ 그러면 nested-class 들은?]

by itstime0809 2024. 5. 2.
728x90

스프링은 테스트 클래스를 빈으로 등록을 할까..?

 스프링에서 unit test, integration test를 진행하고자 할 때, test 폴더 계층에서 테스트를 보통 진행한다. 그러다 보면 자연스럽게 application context 등록 과정에 대해서 고찰을 하게 되고, component scan, 공식문서등 여러 정보와 설명들을 찾아보게 된다. 그러나 항상 보는 글 그리고 공식문서 까지도, 스프링에 비교적 익숙하지 않은 사용자들은 이해하기 어려운 부분이 다소 포함이 되어 있고, 이 빈 등록 과정에 대해서 추상적으로 설명되어 있는 글들을 마주하곤 해서, 이번 주제는 스프링에서 테스트할 때 '테스트 클래스'를 빈으로 등록할까를 시작으로 끝에는 중첩클래스(inner class, static class)가 빈으로 등록되는가? 에 대한 답을 구해 보려고 한다.

 

Spring Test Class

@SpringBootTest
@AutoConfigureMockMvc
// @ContextConfiguration(classes = {OrderProjectApplicationTests.InnerClass.class})
class OrderProjectApplicationTests {


    @Autowired
    ApplicationContext applicationContext;


    @Autowired
    private MockMvc mockMvc;

    // constructor for test class
    OrderProjectApplicationTests() {
        System.out.println("teststs");
    }

    // inner class (OrderProjectApplicationTests$InnerClass)
    @RestController
    class InnerClass {
        @Getter
        @Setter
        class UserDTO {
            private String name;
        }

        @GetMapping("/v/api")
        public ResponseEntity<String> get(UserDTO userDTO, BindingResult bindingResult) {
            if(bindingResult.hasErrors()) {
                return ResponseEntity.badRequest().body("bad request");
            }
            return ResponseEntity.ok("good");
        }

    }

    
    @Test
    public void getMethodTest() throws Exception{

        String[] beans = applicationContext.getBeanDefinitionNames();
        for (String bean : beans) {
            System.out.println(bean);
        }
        mockMvc.perform(get("/v/api"))
                .andExpect(status().isOk());
    }
}

 

 위 코드는, 임의로 생성한 Test클래스다. 이 테스트 클래스의 목적은, InnerClass 및 StaticClass를 빈으로 등록하는가에 대한 마지막 고찰을 위한 클래스다.

 

우선 어노테이션은 간단하다. Integration test에 사용되는 @SpringBootTest, Web Layer 계층을 테스트하기 위한 @AutoConfigureMockMvc 어노테이션이 설정되어 있다. 현재 주석 처리 되어 있는 @ContextConfiguration은 일단 무시한다. 마지막 세션에서 사용될 어노테이션이기 때문이다.

 

일단 많은 글들을 접하다보면, Innerclass가 빈으로 등록이 안된다, Innerclass를 빈으로 등록을 하려면 @Component 등.. 빈으로 등록하는 작업이 필요하다고 한다. 그렇다면 먼저 빈으로 등록하는 과정이 어떻게 이루어질까 테스트 클래스에서 말고, 메인 클래스에서 빈으로 등록이 되는 과정을 좀 살펴볼 필요가 있다. 

 

@SpringBootApplication
public class OrderMainProjectApplication {

    public static void main(String[] args) {
        ConfigurableApplicationContext context = SpringApplication.run(OrderMainProjectApplication.class, args);
        String[] beans = context.getBeanDefinitionNames();
        for (String bean : beans) {
            System.out.println(bean);
        }

    }

}

 

 메인 클래스가. SpringApplication에 해당하는 부분이고 메인 진입점이다. 여기서 @SpringBootApplication 어노테이션의 역할이 중요한데, @ComponentScan 어노테이션을 포함하고 있다. 그래서 Application이 동작되면, @ComponentScan 때문에 CurrentPackage부터 Sub-package까지 빈으로 등록될 수 있는 것들은 전부 ApplicationContext에 등록이 된다.

 

따라서, main 메소드가 실행된 후, context에 등록된 모든 빈들을 가지고 와보면, 아래와 같이 많은 빈들이 등록되어 있는 것을 알 수 있다.

그중에서도 메인 진입점을 포함하고 있는 orderMainProejctApplication도 bean으로 등록이 되어 있는 것을 알 수 있다.

SpringApplication
mainTestClass도 등록이 되는가?

 

 위 사진은, 메인 메소드의 진입점 즉 Application과 같은 패키지에 존재하고 mainTestClass가 빈으로 등록될 수 있는 Component를 가지고 있기 때문에 mainTestClass도 빈으로 등록된 것을 확인할 수 있다.

 

이를 통해 알 수 있는 것은, @ComponentScan을 통해서, 빈으로 등록될 수 있는 모든 빈들을 등록한다는 것은 사실로 보인다. sub-package도 위에는 없지만, orderproject 하위 패키지로 만든다면, 등록이 잘 된다는 것을 테스트해 볼 수 있다.

 

Spring Test Class - 2

 다시 돌아와서, 메인에서 실행했을 때, @ComponentScan을 통해 빈이 등록된다는 것을 알 수 있다. 실행되는 클래스도 등록된다. 그렇다면 Test 할 때는 무엇이 달라질까?

@SpringBootTest
@AutoConfigureMockMvc
// @ContextConfiguration(classes = {OrderProjectApplicationTests.InnerClass.class})
class OrderProjectApplicationTests {}

 

위 코드의 class 부분과 어노테이션만 가지고 왔다. 먼저 @SpringBootTest 역할부터 제대로 알아볼 필요가 있다. 공식문서에서는 @SpringBootTest의 역할을 이렇게 정의하고 있다.


The @SpringBootTest annotation tells Spring Boot to look for a main configuration class (one with @SpringBootApplication, for instance) and use that to start a Spring application context. 
(https://spring.io/guides/gs/testing-web)


 위와 같이, 해당 어노테이션은 main configuration class를 찾으려고 한다. 즉 Test가 실행되면 SpringBootApplication이 정의되어 있는(보통 main application)을 찾아가게 되고, 그게 위에서 보았던 OrderMainProjectApplication이다. 그럼 찾아가는 게 끝일까? 그렇지 않다. 테스트를 하기 위해서 여러 빈들이 등록되어 있는 Application Context를 사용해서 다양한 작업들을 하려면, 분명 ApplicationContext가 필요하다. 그러므로, production environment 환경과 유사한 ApplicationContext가 생성된다. 그래서 @Autowired로 주입이 가능해진다.

 ApplicationContext가 생성되었으니, 실제로 모든 빈들이 등록되어 있을까?

 

 테스트 코드를 돌려서 getBeanNames()로 확인해 본 결과다. 여전히 MainApplication에서 빈을 찾아봤을 때와 마찬가지로 빈들이 등록되어 있는 것을 볼 수 있다. 주입받은 context.getBeanNames()기 때문에, context가 null이 아님을 알 수 있고, 그렇다는 건 context에서 등록된 빈을 꺼내올 수 있다는 얘기가 된다.

 

과연 모든 빈들이 등록이 되어 있을까... 결과는 그렇지 않다. 위 Main Class에서 실행 했을 때는, MainClass 마저도 빈으로 등록되어 있는 것을 알 수 있다. 사실 그건 run() 때문에 가능한 결과지만, 이것을 몰라도 패키지에 @Component가 부착된 어노테이션은 Component scan에 의해서 빈으로 등록이 된다는 사실을 알고 있다면, InnerClass도 등록이 되어야 한다.

 

 

 그 뿐만 아니라, Test 환경과 같은 위치에 존재하는 ValidationTest도 mainTestClass처럼 등록이 되어야 하는데, 그 어디에도 빈으로 등록되어 있다는 것은 찾아볼 수 없다. 따라서 Test를 진행하는 클래스와 하위 패키지는커녕 같은 패키지에 있는 클래스조차 빈으로 등록되지 않는다. (ValidationTest는 @RestController가 부착되어 있다고 생각하자.)

 

그래서, Test를 수행하고자 할 때, 만약 InnerClass로 지정해서 테스트를 하고자 할 경우, 당연히 빈으로 등록이 안되며, 같은 패키지에 있다고 하더라도 명시적으로 application context에 추가하지 않으면, 빈으로 등록되지 않는다. 

 

적어도, 메인 클래스를 실행했던 것처럼 MainClass가 인스턴스화되면서, 빈으로 등록이 되고, 그에 따라 InnerClass도 사용할 수 있을 줄 알았던 나에겐 신선한 충격이었다. 이러한 논리적 오류는 모든 빈을 등록한다는 추상적인 표현에서 부터 시작되었다고 생각한다.

 

사실 모든 빈을 등록 한다는 것은, @SpringBootTest가 @SpringBootApplication을 찾아가 @Component scan을 통해 scanning과정을 거치면서, 그 패키지에 등록될 수 있는 빈들과, 하위 패키지들의 빈들을 등록할 수 있다는 거지. test 패키지의 빈들을 등록한다는 얘기가 아닌 게 된다. (@SpringBootApplication이 @SpringBootConfiguration을 포함하고 있기 때문에 @SpringBootTest는 @SpringBootConfiguration이 부착되어 있는 클래스를 찾아가게 되고, 따라서 @SpringBootApplication이 부착된 클래스를 찾게 된다.)

 

그럼 고찰을 통해 얻을 수 있는 결과는 결국, test 폴더의 패키지들은 application context의 빈등록 범위가 아니다. 그러므로, 처음 코드처럼 InnerClass를 통해서 테스트를 하고자 한다면 결국 OuterClass인 OrderProjectApplicationTests가 빈으로 등록이 안되니, InnerClass조차 등록되지 못하기 때문에 Test 수행결과에서 오류가 발생한다. 

 

ComponentScan은 기본적으로 클래스 안에 클래스가 중첩되어 있을때, @Component를 가지고 있다면 빈으로 등록이 된다. 그러나 test에서는 이런 진행과정이 아니다. OuterClass가 생성되어야 InnerClass가 생성될 수 있기 때문에, OuterClass가 생성이 안되면 InnerClass도 생성이 안된다는 얘기가 되고, 결국 둘 다 빈으로 등록이 되어 있지 않은 상태에서 Test에서는 MockMvc를 통해 수행을 하니 등록되지도 않은 컨트롤러에 요청을 하게 되므로 아래와 같은 에러가 발생한다.

 

테스트 빈 등록 실패

 

위 오류 결과는 이제 너무나 당연하다. 빈으로 등록도 안되어 있는 Controller에게 요청을 보내니, 요청 URL 또한 객체가 생겨야 메소드가 생기고 효력이 발생되는데, 그렇지 못한다는 사실을 이젠 알 수 있다. 즉, @SpringBootTest를 부착했다는 이유만으로 모든 빈들 테스트클래스까지 그리고 test 패키지에 포함되어 있는 빈으로 등록될 수 있는 클래스들까지 등록이 안된다는 것이다.

 

InnerClass가 그럼 이제 등록이 안된다는 것을 알 수 있다. (OuterClass도 빈으로 등록이 안되므로) 그러면 마지막 고찰인 static으로 선언하면 등록이 되는가?

 


@AutoConfigureMockMvc
//@ContextConfiguration(classes = {OrderProjectApplicationTests.InnerClass.class})
class OrderProjectApplicationTests {

    // inner class (OrderProjectApplicationTests$InnerClass)
    @RestController
    static class InnerClass {
        @Getter
        @Setter
        class UserDTO {
            private String name;
        }

        @GetMapping("/v/api")
        public ResponseEntity<String> get(UserDTO userDTO, BindingResult bindingResult) {
            if(bindingResult.hasErrors()) {
                return ResponseEntity.badRequest().body("bad request");
            }
            return ResponseEntity.ok("good");
        }

    }


}

 

 

설명에 불필요한 부분은 제외하고 static으로 선언한 InnerClass를 보자. static 클래스인 경우, 다른 글에서도 개념에서도 많이 보고 알 수 있듯이, static은 독립적인 클래스다. 즉 Tests 클래스 안에 정의만 되어 있을 뿐 독립적인 클래스라는 것이다. 그러면 이제 이러한 클래스는 정상적으로 빈으로 등록이 될까??

 

static InnerClass도 빈으로 등록이 안된다.

 

 정상적으로 찾지 못한다. 즉 static으로 선언했다고 해서 빈으로 등록된게 아니라는 말이다. 이건 앞서 언급했던, @SpringBootTest를 부착했다는 이유만으로 빈으로 등록하지 않는다는 말을 다시 한번 상기시켜 보면 이해가 가능하다. Main 패키지에서부터 즉 @SpringBootConfiguration이 부착된 곳에서부터 빈들을 등록해 나가는 거지, test 폴더에 있는 빈으로 등록 가능한 것들까지 등록한단 다는 게 아니다. 

 

따라서, static class라고 하더라도 명시적으로 application context에 빈으로 등록하지 않으면, 결국 포함하지 않는다. 그렇기 때문에 만약 컴포넌트 클래스를 빈으로 등록하고 싶다면 주석처리 되어 있는 @ContextConfiguration으로 직접 등록을 해주어야 한다. 또는 static nested class로 등록이 되어 있다면 ( 위 코드처럼 ) @ContextConfiguration에 classes 속성을 등록을 하거나 혹은 생략을 하더라도 @Configuration을 부착해주어야 한다. 

 

 이런식으로, 등록을 명시적으로 해준다면 그리고 static nested class에 대해서는 명시적 혹은 @Configuration을 부착만 해준다면, application context에는 이제 static으로 선언한 InnerClass가 빈으로 등록되어 있는 것을 확인할 수 있다. 이와 관련하여 stackoverflow 답변이 이미 있다. 아래에는 나와 같은 상황의 답변이 있었고, 그 답변에 answer로 체크된 답변이 is not loaded to the application context라고 언급했다. 처음엔 이 뜻을 잘 이해하지 못했지만, 실제 빈들의 등록과정과 테스트 클래스의 빈등록 여부를 파악하고 나서 보니, 왜 context에 로드가 되지 않는다고 하는지 이해를 할 수 있다.

 

https://stackoverflow.com/questions/60372144/spring-boot-restcontroller-as-inner-class-in-unit-test-webmvctest

 

한 가지, 더 확인해 볼 사항이 있다. 아직 @ContextConfiguration 어노테이션의 역할을 제대로 알지 못해기 때문에, 이게 무슨 역할을 하는 어노테이션인지 확인해볼 필요가 있다. 먼저 spring io 공식문서의 정의에는 이렇게 정의되어 있다.

 

@ContextConfiguration defines class-level metadata that is used to determine how to load and configure an ApplicationContext for integration tests. Specifically, @ContextConfiguration declares the application context resource locations or the component classes used to load the context.

 

보아하니, 통합 테스트를 위한 ApplicationContext를 구성하고 로드할 때 사용되는 어노테이션으로 보인다. 마지막 문단을 보았을 때, 'component classes'라는 단어가 보이고, 앞서 @SpringBootTest 나 @WebMvcTest 어노테이션을 부착한 것 만으로 ApplicationContext에 빈으로 등록이 안되었듯, 이 어노테이션을 붙였을 때, Context에 빈으로 등록이 되는 이유가 있어 보인다. 

 

먼저, component class라고 한다면, 아래와 같은 어노테이션들이 component class로 인식한다. @RestController는 @Controller 어노테이션을 포함하고 있다. 그러므로 다른 streotype annotations들이 부착된 클래스가 컴포넌트 클래스로 인식된다고 이해해 볼 수 있다. 

component class

 

 그러나, 위 정의와 아래 component 클래스 어노테이션들의 설명만으로는 context에 어떻게 등록이 되는지 알 수 없다. 즉 static nested class로 지정이 되어 있을 때, @ContextConfiguration이 어떻게 인식하고 Context에 빈으로 등록하는지에 대한 설명은 부족하다. 그래서 이 어노테이션에 대해 공식문서에서 이러한 글을 읽어볼 수 있었다.

 

@ContextConfiguration 어노테이션은 classes 속성이 존재하고, 그 속성을 위와 같이 빈에 등록할 클래스들을 등록했다. 그럼 static nested class는 어떻게 등록을 할 수 있었을까. test 폴더는 Component Scan 범위가 아니었는데, 위에서 한 번 언급을 했지만 classes 속성을 생략하거나, 명시적으로 등록을 해줄 때 context에 등록이 된다라는 설명의 근거가 필요하다.

 

그러한 근거가 공식문서에서 말해주고 있다. TestContext FrameWork는 classes 속성을 생략한다면 default configuration classes를 선정한다고 되어 있다. 그리고 AnnotationConfigContextLoader와 AnnotationConfigWebContextLoader가 detect all static nested classes of the test class 문장이 적혀 있으며, 이제 왜 static nested class가 @ContextConfiguration을 등록한 이유만으로 그리고 classes로 class를 정의한 이유만으로, 등록이 되는지 이해해 볼 수 있게 되었다. 결국 Loader에 의해서 Bean으로 등록이 되고, AnnotationConfigContextLoader나 WebLoader가 등록을 한다는 것을 알 수 있다. 

 

두 loader의 차이는, AnnotationConfigContextLoader인 경우, Controller 테스트를 위한 MockMvc가 AutoWiring이 되지 않는다. 그러나 AnnotationConfigWebContextLoader인 경우 MockMVC가 AutoWiring이 가능하다. 그러므로, 

아래와 같이 loader 속성을 이용해서 지정해 줄 수 있다. loader를 명시적으로 지정하지 않더라도 MockMvc를 테스트가 가능한데, Loader의 전략이 필요하다면 지정하겠지만, 현재 코드의 목적상 Loader의 정의는 필요가 없기 때문에 생략을 하게 되겠지만, 만약 그러면 생략을 했을 때는 @ContextConfiguration은 어떤 전략을 취할까?

https://stackoverflow.com/questions/23479345/spring-test-how-to-default-configuration-to-annotationdriven

 

이 답변은, stackover flow 답변이다. Spring은 자동으로 AnnotationConfigContextLoader를 사용한다고 한다. 내부 메커니즘을 정확히 들여다보지는 않았지만 AnnotationConfigContexetLoader를 사용한다면 MockMvc가 AutoWired 되지 않아야 하지만, 내부 동작으로 Loader전략을 수정하는 것으로 보인다. 따라서 @ContextConfiguration 어노테이션에 loader를 명시적으로 부착하지 않았더라도, @Configuration 어노테이션이 부착되어 있다면 MockMvc도 사용이 가능하다.

 

 

 

 

마지막으로 볼 사항은, 그러면 저렇게 명시적으로 등록만 하면 Test나 static이 아닌 Inner class는 빈으로 등록이 될까? 이렇게 말이다.

@SpringBootTest
@AutoConfigureMockMvc
@ContextConfiguration(classes = {OrderProjectApplicationTests.InnerClass.class})
class OrderProjectApplicationTests {


    @Autowired
    ApplicationContext applicationContext;


    @Autowired
    private MockMvc mockMvc;

    // constructor for test class
    OrderProjectApplicationTests() {
        System.out.println("teststs");
    }

    // inner class (OrderProjectApplicationTests$InnerClass)
    @RestController
    class InnerClass {
        @Getter
        @Setter
        class UserDTO {
            private String name;
        }

        @GetMapping("/v/api")
        public ResponseEntity<String> get(UserDTO userDTO, BindingResult bindingResult) {
            if(bindingResult.hasErrors()) {
                return ResponseEntity.badRequest().body("bad request");
            }
            return ResponseEntity.ok("good");
        }

    }

static을 제거하고 등록해보자.

 


그렇지 않다. InnerClass의 static을 제거하고, 아래와 같이 실행해 본 결과는 빈으로 등록이 안되어 있는 것을 알 수 있다. 그렇다면 InnerClass에는 @Component가 부착되어 있으니 OuterClass인 Tests클래스만 빈으로 등록되면 자동으로 등록되지 않을까? 결과는?

@SpringBootTest
@AutoConfigureMockMvc
@ContextConfiguration(classes = {OrderProjectApplicationTests.class})
class OrderProjectApplicationTests {}

Test 클래스를 명시적으로 등록해보자.

 

그렇지 않다. Tests 클래스를 명시적으로 application context에 로드하겠다고 했음에도, 빈에도 등록이 안된다. 그러므로 InnerClass 또한 자동으로 인식하지 않는다는 것이 되고, 테스트 케이스는 실패한다. 그렇다는 것은 자동으로 등록되는 것을 기대할 수 없으며 만약 InnerClass로 테스트를 진행을 먼저 하고자 한다면, static으로 만든 다음, 명시적으로 또는 @ContextConfiguration 어노테이션을 부착한 뒤 사용하는 방법을 생각해 볼 수 있을 것 같다.