System.in 테스트 코드 작성 (feat. NoSuchElementException)

Hanjmo·2023년 10월 29일
8

지금까지 나는 구현한 입력 기능이 정상 동작하는지 확인하기 위해 Application을 실행시켰다. 아주 간단한 프로그램이야 충분히 직접 실행해 볼 수 있지만, 입력값 검증에 대한 경우의 수가 많아지면?

검증하기 위해 잘못된 값을 하나씩 모두 입력하고, 프로그램을 다시 실행해야 하는데, 이는 굉장히 귀찮고 비효율적이다.

하지만 콘솔을 이용하는 입력 기능을 테스트하는 방식은 생소했기에, 구글에 검색해본 결과 역시 많은 분들이 이미 이에 대한 내용을 다루어주셨다.

System.in을 테스트하는 코드를 작성하고 그 사이에 발생한 문제를 해결하는 과정까지 글로 풀어내보려고 한다.

System.in 테스트 코드 작성하기

Scanner 동작 원리

대부분 Java에서 콘솔 입력 방법을 처음 배울 때 Scanner로 배웠을 것이다.

나는 Scanner를 사용만 해봤지, 어떻게 동작하는지에 대해서는 알아보려고 하지 않았던 것 같아서 이번 기회에 얕게나마 알아봤다.

Scanner scanner = new Scanner(System.in);

우리는 Scanner를 생성할 때마다 안에 System.in을 넣어줬다. 여기서 System.in은 사용자가 콘솔에 입력한 값을 InputStream에 담아 제공하는 역할을 수행한다.

그리고 Scanner는 생성되는 시점에 InputStream에 들어 있는 사용자의 입력 값을 스캔하는 방식으로 동작한다.

아래는 Scanner 클래스의 생성자인데, 설명을 보면 이해가 더 쉬울 것이다.

Scanner 생성자

InputStream에 있는 값을 스캔하여 새로운 Scanner를 생성한다. 입력 스트림에 있는 바이트 코드는 기본 문자셋을 사용하여 문자로 변환된다.

테스트 코드 작성

Scanner가 어떻게 동작하는지 알았으니, 이를 바탕으로 테스트 코드를 작성해보자.

우선 입력 값을 바이트 코드로 변환해서 InputStream에 넣고, InputStrem을 반환하는 메서드를 작성한다.

InputStream createUserInput(String input) {
		return new ByteArrayInputStream(input.getBytes());
}

이제 createUserInput()이 반환한 InputStream을 System.in에 할당하면 되는데, System 클래스에서 아래의 설명을 볼 수 있다.

이는 “표준” 입력 스트림이다. 이 스트림은 이미 열려 있으며 입력 값을 제공할 준비가 되어 있다.

위 설명을 통해, 이미 열려 있는 스트림에 임의의 입력 스트림을 할당해서 입력 값을 넣는다는 사실을 알 수 있다.

여기서 임의의 입력 스트림을 할당하는 방법은 아래에 나와 있는 setIn()을 사용하는 것이다.

여기서 주의할 점은 System.in은 적역적으로 사용되는 상태이기 때문에, 한번 설정하면 다른 부분에서도 영향을 받을 수 있다.

우리는 위에서 알게 된 내용을 통해 테스트 코드를 작성하면 된다.

class InputViewTest {

    @Test
    void readNumbers() {
        // given
        System.setIn(createUserInput("123"));

        // when, then
        Assertions.assertThat(InputView.readNumbers()).isEqualTo(List.of(1, 2, 3));
    }

    InputStream createUserInput(String input) {
        return new ByteArrayInputStream(input.getBytes());
    }
}

테스트 성공

우리가 작성한 테스트 코드가 정상 동작하는 것을 확인할 수 있다.

NoSuchElementException이 발생한 건에 대하여..

class InputViewTest {

    @Test
    void readNumbers() {
        // given
        System.setIn(createUserInput("123"));

        // when, then
        Assertions.assertThat(InputView.readNumbers()).isEqualTo(List.of(1, 2, 3));
    }

    @Test
    void readNumberOfGameStatusCommand() {
        // given
        System.setIn(createUserInput("1"));

        // when, then
        Assertions.assertThat(InputView.readNumberOfGameStatusCommand()).isEqualTo(1);
    }

    InputStream createUserInput(String input) {
        return new ByteArrayInputStream(input.getBytes());
    }
}

추가로 테스트 코드를 하나 더 작성했는데 갑자기 다음과 같은 예외가 발생하면서 테스트가 실패했다…😨

하나씩만 돌려봤을 때는 정상적으로 동작했는데, 두 개 이상 돌리면 예외가 발생한다.

어디가 문제인지 계속해서 찾아보다가 우테코에서 제공하는 camp.nextstep.edu.missionutils.Console을 보는 순간 원인을 알 수 있었다.

문제 원인

Console 클래스의 Scanner는 static 키워드로 인해 전역에서 공유된다. 즉, 모든 테스트 메서드가 Scanner를 공유하고 있어서 예외가 발생하는 것이다.

우선 내가 작성한 InputView.readNumbers()가 어떻게 구현되어 있는지 살펴보자.

public static List<Integer> readNumbers() {
    System.out.print("숫자를 입력해주세요 : ");
	String input = Console.readLine();
    ...
}

readNumbers()가 호출되면 Console.readLine()을 통해 사용자로부터 입력을 받는 형태다.

이제 Console.readLine()의 구현 코드를 확인해보자.

readLine()이 호출되면 getInstance()를 호출하는데, 이 부분이 중요하다.

Scanner가 null이면 새로운 Scanner 객체를 생성하고, null이 아니면 Scanner를 그대로 반환한다. 그리고나서 반환된 Scanner의 nextLine()이 호출된다.

Scanner의 nextLine()이 호출되면, Scanner는 입력 스트림에서 데이터를 읽는다. 그리고나서 Scanner는 스캔 위치를 다음 줄로 이동시킨다.
(자세한 내용을 알고 싶다면 Scanner와 InputStream의 동작 원리에 대해서 검색해보자)

그래서 이게 무슨 문제를 발생시키는데?

좀 더 쉬운 설명을 위해 내가 작성한 테스트 코드가 실행되는 과정을 간략화해봤다.

// 1. static 영역에 존재하는 비어 있는 Scanner
Scanner scanner;

// 2. readNumbers() 실행 과정
System.setIn(createUserInput("123")); // System.in에 입력 값 할당
scanner = new Scanner(System.in); // 새로운 Scanner 생성
scanner.nextLine(); // 입력 값 스캔

// 3. readNumberOfGameStatusCommand() 실행 과정
System.setIn(createUserInput("1")); // System.in에 입력 값 할당
scanner.nextLine(); // 입력 값 스캔

1번부터 3번까지 천천히 살펴보자.

  1. Console 클래스에는 Scanner가 static 키워드로 선언되어 있다.
    이는 곧 Console 클래스에 대한 모든 참조에서 동일하게 접근할 수 있음을 의미한다.

  2. readNumbers() 테스트 메서드를 호출하면, System.in에 입력 값 "123"이 할당된다. 그리고나서 InputView.readNumbers()가 호출되는 순간 새로운 Scanner가 생성되면서 입력 값을 스캔한다.
    여기까지는 문제가 없다.

  3. readNumberOfGameStatusCommand() 테스트 메서드를 호출하면, 똑같이 System.in에 입력 값을 할당한다. 그리고나서 InputView.readNumberOfGameStatusCommand()가 호출되는데, 여기서 문제가 발생한다.
    Console 클래스의 getInstance()가 호출되는 시점에 Scanner의 인스턴스가 이미 존재한다고 판단해 2번 과정에서 생성한 Scanner를 그대로 반환하게 된다.
    다음으로 nextLine()을 통해 입력 값을 스캔하는데, 여기서 NoSuchElementException이 발생한다.

정리하면 두 번째 메서드가 실행될 때, Scanner에 주입된 System.in은 첫 번째 메서드에서 할당한 값이다.
그래서 두 번째 메서드에서 할당한 System.in은 Scanner에 주입되지 못했다.

이 상태에서 Scanner는 입력 값이 없는 상태에서 라인을 읽으려 했기 때문에 NoSuchElementException이 발생하는 것이다.

어떻게 해결해야 할까?

이 문제를 해결하기 위해서는 기존에 생성한 Scanner를 닫아주고, static 영역에 있는 Scanner를 null로 만들어야 한다.

다행히 Console 클래스에는 이미 이러한 역할을 수행하는 메서드가 구현되어 있다.🥺

이제 각 테스트 메서드가 실행된 후 Console.close()를 호출하도록 테스트 코드를 작성해보자.

class InputViewTest {

	@AfterEach
    void closeConsole() {
    	Console.close();
	}

    @Test
    void readNumbers() {
        // given
        System.setIn(createUserInput("123"));

        // when, then
        Assertions.assertThat(InputView.readNumbers()).isEqualTo(List.of(1, 2, 3));
    }

    @Test
    void readNumberOfGameStatusCommand() {
        // given
        System.setIn(createUserInput("1"));

        // when, then
        Assertions.assertThat(InputView.readNumberOfGameStatusCommand()).isEqualTo(1);
    }

    InputStream createUserInput(String input) {
        return new ByteArrayInputStream(input.getBytes());
    }
}

이제 모든 테스트가 깔끔하게 성공하는 것을 확인할 수 있다😄

2개의 댓글

comment-user-thumbnail
2023년 10월 30일

자세히 설명해주셔서 감사합니다

답글 달기
comment-user-thumbnail
2023년 11월 7일

테스트 코드 작성에 난감했었는데 너무 감사합니다!

답글 달기