❐ Description
Controller 테스트를 작성하기 위해서는 사용자의 입력을 mockking해줘야 한다.
오늘은 사용자 값을 mockking해서 테스트를 작성하는 과정을 정리하고 그에 필요한 지식들을 정리하려고 한다.
❐ 배경 지식
From. 자바의 정석 Chap.15
1. 입출력이란?
우선 자바의 입출력을 제대로 이해해야 한다.
입출력은 컴퓨터 내부 또는 외부의 장치와 프로그램 간의 데이터를 주고 받는 것을 의미한다. 자바에서 입출력을
수행하려면, 두 대상을 연결하고 데이터를 전송할 수 있는 무언가가 필요한데 이것을 stream이라고 정의한다.
자바에는 바이트 기반의 스트림과 문자 기반의 스트림이 있는데, 오늘은 바이트 기반의 스트림을 사용하게 된다.
2. 바이트 기반 스트림 (InputStream, OuputStream)
ByteArrayInputStream, ByteArrayOutputStream은 메모리, 즉 바이트 배열에 데이터를 출력하는데
사용되는 스트림이다. 주로다른 곳에 입출력하기 전에 데이터를 임시로 바이트 배열에 담아서 변환 등의
작업을 하는데 사용된다.
참고로 프로그램이 종료될 때 사용하고 닫지 않은 stream을 JVM이 자동적으로 닫아주기는 하지만,
스트림을 사용해서모든 작업을 마치고 난 후에는 close()를 호출해서 반드시 닫아주어야 한다.
그러나 메모리를 사용하는 스트림과 표준 입출력 스트림은 닫아 주지 않아도 된다.
3. 보조 스트림 (PrintStream)
보조 스트림은 실제 데이터를 주고받는 스트림이 아니기 때문에 데이터를 입출력할 수 있는 기능은 없다.
하지만, 스트림의 기능을 향상 시키거나 새로운 기능을 추가할 수 있다. 그래서 보조 스트림만으로는
입출력을 처리할 수 없고, 스트림을 먼저 생성한 다음에 이를 이용해서 보조스트림을 생성해야 한다.
보조 스트림에는 다양한 종류가 있는 오늘은 PrintStream만 사용한다. PrintStream은 버퍼를 이용하며
추가적인 print 관련 메서드를 가지고 있는 보조 스트림이다.
4. 표준 입출력 (System.in, System.out, System.err)
표준 입출력은 콘솔을 통한 데이터 입력과 콘솔로의 데이터 출력을 의미한다.
자바에서는 표준입출력을 위해 System.in, System.out, System.err 이렇게 세 가지 스트림을 제공한다.
이 들은 자바 application을 실행하면 자동으로 생성되기 때문에 사용자가 별도로 생성할 필요는 없다.
System 클래스를 확인하면 알 수 있지만 in, out, err는 해당 클래스에 선언된 static 변수이다.
public static final InputStream in = null;
public static final PrintStream out = null;
public static final PrintStream err = null;
5. 표준 입출력 대상 변경 (setIn( ), setOut( ), setErr( ))
초기에는 System.in, System.out, System.err의 입출력 대상이 콘솔화면 이지만, setIn(), setOut(), set(err)
메서드를 사용하면 입출력을 콘솔 이외에 다른 입출력 대상으로 변경하는 것이 가능하다. 테스트 코드를 작성할 때
이 부분도 사용하게 된다.
6. Scanner
Scanner는 자바에서 입력을 처리하기 위한 클래스다. 주로 콘솔 입력이나 파일, 문자열 등 다양한 입력 소스를
다루기 위해 사용되며, 입력을 토큰 단위로 구분하여 쉽게 처리할 수 있도록 도와준다. Scanner는 자바의 기본
제공 클래스 중 하나로, 자바 프로그램에서 사용자가 콘솔에 입력한 내용을 읽어오거나 파일, 문자열에서 데이터를
추출할 때 자주 사용된다.
❐ Test setting : 설계
추상 클래스를 작성하여 설계할 수도 있지만, JUnit5를 보면서 Annotation을 적극 활용하는 것을 느꼈다.
어차피 JUnit5을 사용하기도 하고 일관성을 유지하기 위해서, custom annotation을 작성하여 테스트를
설계해보려고 한다.
입출력 테스트는 다음과 같이 약간 확장자의 느낌으로 생각된다.
그래서 SpringBoot에서 테스트를 작성했을 때 사용했던 @ExtendWith 애노테이션으로 JUnit5의 기능을
확장해 줄 생각이다.
사용자 입력 값은 테스트 전에, 결과 출력은 테스트 후에 진행해야 하기 때문에 @BeforeEach, @AfterEach
애노테이션을 커스텀 해야한다. 이를 커스텀 하기 위해서는 다음 두 인터페이스를 상속해야 한다.
// package org.junit.jupiter.api.extension;
public interface BeforeEachCallback extends Extension {
void beforeEach(ExtensionContext context) throws Exception;
}
// package org.junit.jupiter.api.extension;
public interface AfterEachCallback extends Extension {
void afterEach(ExtensionContext context) throws Exception;
}
마지막으로 사용자 입력 값은 @MockInput 애노테이션을 만들어서 입력해줄 것이다.
이 때 애노테이션의 라이프 사이클은 RUNTIME으로, 타겟은 METHOD로 한정한다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MockInput {
// 구현
}
❐ Test setting : 로직
1️⃣ 입력 값을 바이트 기반 스트림으로 변경
ByteArrayInputStream inputStream = new ByteArrayInputStream("사용자 입력 값");
System.setIn(inputStream);
테스트 시에는 사용자가 콘솔창에 값을 입력 했음을 가정하기 때문에, 개발자가 강제로 입력 데이터를
바이트 배열로 변경해줘야 한다. 이렇게 변경한 inputStream을 `setIn( )` 메서드를 통해, 강제로 주입시켜준다.
2️⃣ 출력 값을 저장할 Buffer 셋팅
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
PrintStream buffer = new PrintStream(byteArrayOutputStream)
System.setOut(buffer);
콘솔에 내용을 바로 출력하지 않고 출력될 내용을 저장해야 한다. 이를 위해 바이트 배열이 Buffer 역할을
하도록 지정해줘야 한다. 그리고 `setOut( )` 메서드를 통해 System.out을 outputStream과 연결된
PrintStream으로 변경한다. 이렇게 하면 application이 동작하는 동안에 출력되야할 모든 결과들은
Buffer에 저장되게 된다.
3️⃣ Buffer에 저장된 데이터를 콘솔 창에 출력하기
//최초에 애플리케이션에서 자동으로 할당한 PrintStream
private final PrintStream printStream = System.out;
System.setOut(printStream);
System.out.println(byteArrayOutputStream.toString());
이제 Buffer에 저장된 데이터를 콘솔에 출력하기 위해서, 한 번 더 `setOut( )` 메서드를 통해
출력 스트림을 최초에 애플리케이션 구동시 자동으로 할당된 PrintStream으로 변경해줘야 한다.
❐ Trouble Shooting : 개별 실행은 성공, 동시 실행은 실패
1. Why?
각 테스트를 단독으로 실행했을 땐 모두 성공하지만, 동시에 실핼했을 때는 실패한다.
이슈의 원인은 프리코스에서 제공해주는 Console 클래스에서 찾을 수 있었다.
제공된 Console 클래스는 싱글톤 객체로 설계되어 있고 내부적으로 Scanner 클래스를 private static 변수에
할당하고 있다. 즉, 단일 실행 시에는 새로운 Scanner를 매번 생성하기 때문에 테스트를 성공하는 것이고,
동시 실행시에는 하나의 Scanner 객체를 공유하게 된다. 근데 Scanner는 한 번 사용된면 소모되기 때문에
다음 테스트는 Scanner를 사용할 수 없게 되어, NoSuchElementException 에러가 발생하는 것이다.
2. How?
이를 해결하기 위해서 강제로 Scanner 인스턴스를 초기화 시켜주는 방법을 사용하기로 했다.
강제로 Scanner를 주입하기 위해 Reflection을 사용하였다. 개인적으로 Reflection 사용은 안티 패턴으로
생각 되는데, 유연한 개발을 위해 Test 작성시에는 유도리 있게 어느정도 사용해도 되지 않나 싶다.
❐ Test setting : 코드 작성
1. MockIOExtension
public class MockIOExtension implements BeforeEachCallback, AfterEachCallback {
private static ByteArrayOutputStream byteArrayOutputStream;
private final PrintStream printStream = System.out;
@Override
public void beforeEach(ExtensionContext context) {
// @MockInput 애노테이션에 입력한 값 추출
Method testMethod = context.getRequiredTestMethod();
MockInput annotation = testMethod.getAnnotation(MockInput.class);
if (nonNull(annotation)) {
String userInput = annotation.value();
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(userInput.getBytes());
System.setIn(byteArrayInputStream);
ReflectionUtil.forceSetField(Console.class, "scanner", new Scanner(System.in));
}
// 출력 Buffer 생성
byteArrayOutputStream = new ByteArrayOutputStream();
System.setOut(new PrintStream(byteArrayOutputStream));
}
@Override
public void afterEach(ExtensionContext context) {
System.setOut(printStream);
System.out.println(byteArrayOutputStream.toString());
}
public static String getOutput() {
return byteArrayOutputStream.toString().split(":")[1].strip();
}
}
전체 코드 중 아래 부분이 Reflection을 사용해서 Trouble Shooting한 부분이다.
// Annotation에 입력된 값 추출
String userInput = annotation.value();
// 바이트 배열로 변환한 입력값을 System.in으로 설정
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(userInput.getBytes());
System.setIn(byteArrayInputStream);
/**
* ReflectionUtil을 통해 위해서 setIn으로 설정한 System.in과
* 연결된 Scanner 인스턴스를 Console 인스턴스에 강제로 주입
*/
ReflectionUtil.forceSetField(Console.class, "scanner", new Scanner(System.in));
2. MockInput
/**
* 애노테이션의 라이프 사이클 정책 : RUNTIME
* 애노테이션 target : METHOD
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MockInput {
String value();
}
3. ReflectionUtil
class ReflectionUtil {
static void forceSetField(Class<?> clazz, String fieldName, Object value) {
try {
Field field = clazz.getDeclaredField(fieldName);
field.trySetAccessible();
field.set(field, value);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
}
}
}
❐ Test 결과
결과적으로 테스트 코드는 아래와 같이 Annotation 기반으로 깔끔하게 작성할 수 있다.
@ExtendWith(MockIOExtension.class)
public class MainControllerTest {
private final SeparateService separateService = new SeparateService();
private final MainController sut = new MainController(separateService);
@Test
@DisplayName("컴스텀 구분자 : ^")
@MockInput("//^\\n6^7^8")
void mainTestV1() {
// given & when
sut.runCalculator();
// then
Assertions.assertThat(output()).isEqualTo("21");
}
// ...이하 생략
}
이렇게 동시 실행 시에도 정상적으로 테스트가 수행되며, 출력도 문제 없이 수행됨을 확인할 수 있다.
'우테코 7기 > 1주차' 카테고리의 다른 글
코드 리뷰 & 피드백 (1) | 2024.10.22 |
---|---|
메소드 파라미터에 final 사용하기 (0) | 2024.10.22 |
[Java] 객체를 복사해보자 (0) | 2024.10.19 |
[Refactoring] Pattern을 캐싱하자 (0) | 2024.10.19 |
Regex 뿌시기 (0) | 2024.10.17 |