본문 바로가기
  • 개발하는 곰돌이
Programming Language/Kotlin & Java

[Kotlin/Java] JUnit을 사용한 단위 테스트에서 System.in을 사용하는 콘솔 입력 로직 테스트하기

by 개발하는 곰돌이 2023. 12. 7.

목차

    들어가기 전에

    간단한 토이 프로젝트를 진행하던 중에 콘솔을 통해 사용자로부터 값을 입력받아서 처리하는 로직을 구현하게 되었다.

     

    이 로직을 테스트하기 위해 단순히 IDE로 프로젝트를 실행해서 돌려볼 수도 있었지만 입력받아서 처리하는 로직만 테스트를 하고 싶었기 때문에 단위 테스트로 구현하고자 했다.

     

    그런데 그냥 평범하게 콘솔 입력을 하면 되겠지라는 생각과는 달리 단위 테스트에서는 콘솔 입력을 사용할 수 없었다. 하지만 단위 테스트에서도 콘솔로 입력하지 않을 뿐이지 표준 입력을 사용하는 로직을 테스트하는 방법을 찾을 수 있어서 정리해두려고 한다.

    테스트를 하려는 기능

    fun readConfig() = System.`in`.bufferedReader().run {
        val host = input("Host: ")
        val port = input("Port: ")
        val uri = input("URI: ")
        Config(host, port, uri).apply { save("./config.json") }
    }
    
    private fun BufferedReader.input(prompt: String): String {
        print(prompt)
        return readLine()
    }
    
    private fun Config.save(path: String) {
        val json = Json { prettyPrint = true }
        val file = File(path)
        file.writeText(json.encodeToString(serializer(), this))
    }

    이 메소드는 사용자에게 Host, Port, URI를 입력받아서 Config 객체를 반환하고, 동시에 config.json 파일에 해당 정보를 JSON 형태로 저장하는 메소드이다.

     

    이렇게 생성된 config.json에 사용자가 입력한 정보가 제대로 저장되어 있는지 확인하고 싶었다.

    테스트를 진행해 보자

    테스트를 위한 테스트 코드를 작성한다.

    @Test
    fun `콘솔 설정 및 저장 테스트`() {
        val config = readConfig()
        val fileConfig = jsonFileToConfig("./config.json")
        assertThat(config).isEqualTo(fileConfig)
    }

    여기서 jsonFileToConfig()는 파일에 있는 JSON을 읽어서 Config 객체로 변환해 주는 메소드이다. 이 메소드를 통해 생성된 Config 객체와 사용자 입력을 통해 생성된 Config 객체를 비교하여 일치한다면 사용자 입력이 정상적으로 json 파일에 저장됐다고 확인할 수 있을 것이다.

     

    이제 테스트 코드를 실행해 본다.

    테스트 코드를 실행하기만 했는데 입력을 받기는커녕 실행하자마자 NPE가 발생하고 있다.

    원인은 무엇일까?

    JUnit을 포함한 단위 테스트 도구들은 기본적으로 다른 구성 요소들과의 상호작용 없이 예상대로 작동하는지 확인할 수 있도록 설계되어 있다. 이러한 상호작용에는 사용자로부터 입력을 받는 것 또한 포함된다.

     

    이로 인해 JUnit 테스트에는 입력을 읽을 수 있는 콘솔이 존재하지 않아 예외가 발생하게 된다.

    그래도 방법이 있다

    콘솔 입력은 표준 입력인 System.in에 정의되어 있는 InputStream을 사용한다. 자바/코틀린에서는 System.setIn() 메소드를 통해 표준 입력 스트림을 재정의할 수 있다.

     

    즉, ByteArrayInputStream 등의 InputStream 구현체를 표준 입력 스트림으로 재정의한다면 사용자 입력을 받는 로직도 JUnit을 통한 단위 테스트가 가능하다!

     

    이 글에서는 ByteArrayInputStream을 사용하는 방법을 다룰 것이다.

     

    구체적인 방법은 다음과 같다.

     

    우선 다음과 같이 인자로 받은 문자열을 ByteArrayInputStream으로 변환하여 표준 입력으로 재정의하는 메소드를 작성해 준다.

     

    Kotlin

    private fun setInput(input: String) {
        System.setIn(input.byteInputStream())
    }

    Java

    private void setInput(String input) {
        System.setIn(new ByteArrayInputStream(input.getBytes()));
    }

    그 후 위의 테스트 코드에서 표준 입력을 사용하는 메소드를 호출하기 전에 setInput()을 호출해서 표준 입력을 재정의해준다.

     

    겸사겸사 given을 고정했으니 then에서 검증할 부분도 조금 추가해줬다.

    fun `콘솔 설정 및 저장 테스트`() {
        setInput("""
            http://localhost
            8080
            /hello
        """.trimIndent())
        val config = readConfig()
        val fileConfig = jsonFileToConfig("./config.json")
        assertThat(config.host).isEqualTo("http://localhost")
        assertThat(config.port).isEqualTo(8080)
        assertThat(config.uri).isEqualTo("/hello")
        assertThat(config).isEqualTo(fileConfig)
    }

    그 후 테스트 코드를 실행해 보면 테스트에 성공하는 것을 확인할 수 있다.

    표준 입력을 재정의하여 테스트에 성공했다.

    생성된 config.json 파일을 확인했을 때도 의도한 내용대로 파일이 생성된 것을 확인할 수 있다.

    표준 입력 재정의 시 주의할 점

    하나의 테스트 코드만 실행할 때는 표준 입력을 재정의하더라도 문제가 되지 않지만 그래들의 test 명령이나 클래스의 테스트 코드를 한 번에 실행할 때는 문제가 될 수 있다. 재정의된 표준 입력이 다른 테스트 코드에 영향을 줄 수 있기 때문이다.

     

    이를 방지하기 위해 테스트 클래스 내에 @BeforeEach 또는 @AfterEach로 각 테스트 코드 실행 전 또는 실행 후에 표준 입력을 초기화하는 메소드를 작성해 주는 것이 좋다.

     

    Kotlin

    private val initialInput = System.`in`
    
    @BeforeEach
    fun resetInput() {
        System.setIn(initialInput)
    }

    Java

    private InputStream initialInput = System.in;
    
    @BeforeEach
    void resetInput() {
        System.setIn(initialInput);
    }

    마치며

    글에서는 표준 입력에 대한 내용만을 이야기했지만 표준 출력을 사용하는 로직도 비슷한 방법으로 출력 스트림을 재정의하여 단위 테스트가 가능하다고 한다.

     

    단순히 JUnit 단위 테스트에서 콘솔 입력을 사용할 수 없어서 시작했던 문제였는데 글을 쓰는 과정에서 단위 테스트에 대해 다시 돌아볼 수 있는 계기가 되었다.

     

    이 글을 작성하는 계기가 된 상황에선 "어쨌든 assertThat()으로 원하는 대로 동작하는지 확인하니까 입력정도는 직접 넣어도 괜찮지 않을까" 하는 생각으로 진행을 했다. 하지만 단위 테스트에 대해 잘못 이해해서 이런 생각을 가지게 되었다는 것도 알게 되었다.

     

    단순히 단위 테스트가 아니더라도 비슷하게 잘못 이해하고 있는 개념이 있진 않은지 돌아볼 계기가 될 것 같다.

    참조링크

     

    JUnit testing with simulated user input

    I am trying to create some JUnit tests for a method that requires user input. The method under test looks somewhat like the following method: public static int testUserInput() { Scanner keyboar...

    stackoverflow.com

     

    [Java] 단위테스트: System.out.println() 과 Scanner.in 이 들어간 코드를 어떻게 테스트할까?

    사용자 입출력 함수로 구성된 함수는 어떻게 테스트 할 수 있을까? 회사 개발자 교육 때 진행하는 페어프로그래밍에서, 최대한 강의를 통해 배운 단위 테스트를 해보려고 했지만 void 형식의 함

    choichumji.tistory.com

     

    댓글