들어가며
들어가기전에 3주차 미션 요구사항을 확인하고 왔는데 "단, UI(System.out, System.in, Scanner) 로직은 제외한다."가 추가되었다... 글 다썻는데 확인했다... 😂😂 NsTest를 통해 전체흐름과 입출력을 확인하는 것보단 "기능"별 단위테스트를 구성하는 것이 더 중요하고 그것이 단위 테스트의 좀 더 맞는 역할인 것 같다... 그래도 아래 자세하게 우테코의 라이브러리에 대해 같이 공부해봤다!
현재 우테코 프리코스 3주차 진행중이다. 2주차 피드백 중 다음과 같은 말이 있었다.
처음부터 큰 단위의 테스트를 만들지 않는다.
문제를 작게 나누어 핵심기능부터 작게 테스트를 만들어가는 것이 효과적이라는 말도 강조하셨다. 코드에 대해 자가피드백을 빨리 하면 할수록 문제를 빨리 찾을 수 있으니 백퍼 공감되는 말이다!
그리고 테스트 코드를 연습해볼 수 있는 예제가 담긴 파일도 주셨다. 그 파일에 있는 예제를 하나씩 풀어보며 3주차의 문제에 적용해보고자 한다! 테스트 코드 작성 연습에 들어가보자! 그리고 마지막엔 우테코 제공 라이브러리에 들어있는 NsTest도 알아보겠다.

본론으로
첫번째 예제는 비교적 쉬운 요구사항이다.
- "1,2"을 , 로 split 했을 때 1과 2로 잘 분리되는지 확인하는 학습 테스트를 구현한다.
- "1"을 , 로 split 했을 때 1만을 포함하는 배열이 반환되는지에 대한 학습 테스트를 구현한다.
- "(1,2)" 값이 주어졌을 때 String의 substring() 메소드를 활용해 () 을 제거하고 "1,2"를 반환 하도록 구현한다.
아래와 같이 간단하게 작성해보았다. 문제를 풀며.. 검색해보고 우여곡절 배우다가 이렇게 간단하면서 명확한 테스트를 적어보니 개념이 잡히는 느낌이다.
@Test
@DisplayName("1,2을 콤마(,)로 split 했을 때 1과 2로 잘 분리되는지 확인한다")
void splitCommaTest1() {
// given
String input = "1,2";
// when// then
Assertions.assertThat(input.split(",")).contains("1", "2");
}
@Test
@DisplayName("1을 콤마(,)로 split 했을 때 1만을 포함하는 배열이 반환되는지에 대한 테스트")
void splitCommaTest2() {
// given
String input = "1";
// when // then
Assertions.assertThat(input.split(",")).containsExactly("1");
}
// indexOf로 ()를 찾고 그 사이의 값만 자르는 방식으로도 할 수 있을듯, 지금은 테스트 메서드랑 친해지는 중이니 넘어가겠다!
@Test
@DisplayName("(1,2) 값이 주어졌을 때 String의 substring() 메소드를 활용해 () 을 제거하고 1,2를 반환하는 테스트")
void substringTest() {
// given
String input = "(1,2)";
// when // then
Assertions.assertThat(input.substring(1, 4)).isEqualTo("1,2");
}
}
이렇게 작게작게 테스트 추가도 적어가면서 해보자!
- "abc" 값이 주어졌을 때 String의 charAt() 메소드를 활용해 특정 위치의 문자를 가져오는 학습 테스트를 구현한다.
- String의 charAt() 메소드를 활용해 특정 위치의 문자를 가져올 때 위치 값을 벗어나면 StringIndexOutOfBoundsException이 발생하는 부분에 대한 학습 테스트를 구현한다.
- JUnit의 @DisplayName을 활용해 테스트 메소드의 의도를 드러낸다.
아래와 같이 구현해보았다! 음음 에러 발생시키는 로직도 좋구나!! 정말 다양하게 있다.
@Test
@DisplayName("문자열이 주어졌을 때 String의 charAt() 메소드를 활용해 특정 위치의 문자를 가져오는 테스트")
void findOneCharacter() {
// given
String input = "abc";
// when
char character = input.charAt(1);
// then
Assertions.assertThat(character).isEqualTo('b');
}
@Test
@DisplayName("문자열이 주어졌을 때 String의 charAt() 메소드를 활용해 특정 위치의 문자를 가져올 때 에러 테스트")
void findOneCharacterOrThrowException() {
// given
String input = "abc";
// when // then
Assertions.assertThatThrownBy(() -> {
input.charAt(4);
}).isInstanceOf(StringIndexOutOfBoundsException.class);
}
다음은 Set 자료구조를 테스트해보는 것이다! @BeforeEach는 저번 코드에서는 쓸일이 없어서 대충 알고만 있었는데, 해당 클래스의 모든 테스트마다 기본세팅을 다시 해주는 느낌이라 출발점이 같은 테스트를 고려해볼 수 있을 것 같다. 초반에 값이 주어지는 코드중복을 피할 수도 있겠다! 간단한 사이즈 확인 테스트 메서드도 구현해봤다!
public class SetTest {
private Set<Integer> numbers;
@BeforeEach
void setUp() {
numbers = new HashSet<>();
numbers.add(1);
numbers.add(1);
numbers.add(3);
numbers.add(4);
}
}
//Set의 size() 메소드를 활용해 Set의 크기를 확인하는 학습테스트를 구현한다
@Test
@DisplayName("Set의 크기 테스트")
void checkSize() {
// given // when
int size = numbers.size();
// then
Assertions.assertEquals(3, size);
}
아래의 요구사항이 있다!
- Set의 contains() 메소드를 활용해 1, 2, 3의 값이 존재하는지를 확인하는 학습테스트를 구현하려한다.
- 구현하고 보니 다음과 같이 중복 코드가 계속해서 발생한다.
- JUnit의 ParameterizedTest를 활용해 중복 코드를 제거해 본다.
@Test
void contains() {
assertThat(numbers.contains(1)).isTrue();
assertThat(numbers.contains(3)).isTrue();
assertThat(numbers.contains(4)).isTrue();
} -> 중복코드!!
어떻게 줄이지..? -> 매개변수만 바꿔끼우면 될 거 같은데!!
사실 이에 대해선 2주차 테스트코드 작성할 때 피드백을 받았었다 ㅎㅎ.. 그리고 우테코에서 제공해준 테스트코드 작성 pdf에도 @Parameterized를 활용해보라고 권하고 있다! 바로 적용해보자!

아래와 같이 작성해봤다!! 대박 유용하다! @ParameterizedTest로 여러 파라미터로 실행하겠다는 설정을 하고, @ValueSource로 원하는 인자값을 다양하게 설정해준다! @ValueMethod도 있다?! 메서드를 갈아끼우면서도 테스트가 가능한가보다. DIP 흐름이 잘 적용 되었는지 테스트도 가능하겠구나! 이건 진짜 유용하게 써먹을 듯 하다.
@DisplayName("Set의 1, 2, 3의 값이 존재하는지를 확인하는 테스트")
@ParameterizedTest
@ValueSource(ints = {1, 2, 3})
void containNumber(int input) {
assertThat(numbers.contains(input));
}
- 이전의 메서드는 contains 메소드 결과 값이 true인 경우만 테스트 가능하다. 입력 값에 따라 결과 값이 다른 경우에 대한 테스트도 가능하도록 구현한다.
- Set에 들어있는 값은 contains 메소드 실행결과 true, 들어있지 않은 값을 넣으면 false가 반환되는 테스트를 하나의 Test Case로 구현한다.
음음 1번을 보면 맞는 말이다. true케이스에서만 작동하는 테스트코드이다. false일때는 어떻게하지..? 문서에서는 @CsvSource를 활용하라고 되어있다. 완전 처음본다.. 이게 뭐지..? 아래 코드를 보자.
@DisplayName("Set에 값이 존재하면 true, 존재하지 않으면 false ")
@ParameterizedTest
@CsvSource(value = {"1:true", "2:false", "3:true", "4:true", "5:false"}, delimiter = ':')
void containNumberBoolean(int input, Boolean excepted) {
assertThat(numbers.contains(input)).isEqualTo(excepted);
}
대박이다! delimiter를 지정해서 인자를 받았을 때 원하는 값을 기대하게끔 설계할 수 있다. 이것도 너무 유용할 듯 하다. 줍줍..
2주차 과제에서 나름 테스트코드를 공 들여 작성했었다.(작은 단위부터 하진 못했지만 큼지막한 기능 테스트는 다 완료했다고 생각한다.) 하지만 콘솔 입출력은 도저히 어떻게 해야할지 감이 안왔다. 하나하나 콘솔에 입력을 해보는 것은 테스트가 아니라 그냥 프로그램 실행해서 직접 QA해보는 것과 비슷했다. 그리고 작은 단위 테스트가 모여 통합테스트가 한번에 잘 이루어져야 나중에 CI 환경 구축할 때 문제없이 잘 되는 것으로 알고있었다.
검색을 해보니 Mockito라는 라이브러리를 쓰던데, 이번 8기는 자바21 버전을 쓰기에 우테코 라이브러리의 Mockito 버전과는 호환이 안되어 해당 라이브러리는 쓰지 못하는 듯 했다. 그래서 찾다가 나는 직접 자바의 ByteArrayInputStream()을 썻었다.. 사실 그렇게 편하지는 않았다.
근데 2주차 코드리뷰를 하다보니 NsTest라는 우테코 제공 추상 클래스가 있어서 그것에 대해 공부하고 3주차에 적용해보려고 한다!! 추상클래스 코드 내부는 아래와 같다.
public abstract class NsTest {
private PrintStream standardOut;
private OutputStream captor;
@BeforeEach
protected final void init() {
standardOut = System.out;
captor = new ByteArrayOutputStream();
System.setOut(new PrintStream(captor));
}
@AfterEach
protected final void printOutput() {
System.setOut(standardOut);
System.out.println(output());
}
protected final String output() {
return captor.toString().trim();
}
protected final void run(final String... args) {
try {
command(args);
runMain();
} finally {
Console.close();
}
}
protected final void runException(final String... args) {
try {
run(args);
} catch (final NoSuchElementException ignore) {
}
}
private void command(final String... args) {
final byte[] buf = String.join("\n", args).getBytes();
System.setIn(new ByteArrayInputStream(buf));
}
protected abstract void runMain();
}
NsTest는 우테코에서 제공하는 입출력 기반 테스트를 쉽게 만들기 위한 추상 클래스이다.
내부적으로 @BeforeEach, @AfterEach를 이용해 테스트 전후에 표준 입출력을 자동으로 초기화해주므로, 단위 테스트 시 별도의 설정을 걱정할 필요가 없다! 이를 상속받아
- runMain()에서 프로그램의 시작 지점(예: main() 메서드, 컨트롤러 실행 등)을 호출하고,
- 각 테스트에서 run("입력1", "입력2", …)으로 표준 입력을 세팅해 프로그램을 실행한 뒤,
- output()을 통해 표준 출력 결과를 검증할 수 있다.
입력이 부족하거나 예외가 발생하는 흐름은 runException()을 사용하면 된다.
결과적으로, 여러 클래스가 실제로 함께 동작하는 통합 테스트를 간단히 구성할 수 있게 해주는 도구다.
runMain()에 원하는 서비스 계층을 주입해서 특정 외부라이브러리의 메서드만 테스트 해볼 수도 있겠구나! -> 즉 독립적인 단위 테스트가 가능해진다.
2주차 문제였던 자동차 경주 문제에 NsTest를 상속받은 테스트코드를 작성해보자!
package racingcar.controller;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import camp.nextstep.edu.missionutils.test.Assertions;
import camp.nextstep.edu.missionutils.test.NsTest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import racingcar.Application;
public class RaceControllerTest extends NsTest {
@Test
@DisplayName("자동차 하나 입력 예외 테스트")
void checkCarCountOne() {
Assertions.assertSimpleTest(
() -> {
// given & when
run("pobi,woni", "1"); // 입력값 (자동차 이름, 시도 횟수)
// then
assertThat(output())
.contains("경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)")
.contains("시도할 횟수는 몇 회인가요?")
.contains("실행 결과")
.contains(" : ")
.contains("최종 우승자 : ");
}
);
}
@ParameterizedTest
@ValueSource(strings = {"pobbbbbi,noeul", "pobi,noeul,pobi", ",pobi"})
@DisplayName("자동차 이름 테스트")
void carNameTest(String input) {
Assertions.assertSimpleTest(
() -> {
assertThatThrownBy(() -> runException(input, "1"))
.isInstanceOf(IllegalArgumentException.class);
}
);
}
@Override
protected void runMain() {
Application.main(new String[]{}); // args는 보통 메인함수 실행 할 때 어떻게 프로그램을 실행하라는 "설정 값"이므로 빈 배열 전달!
}
}
우선 runMain()을 오버라이딩하여 Applicatin.main 즉 메인함수가 실행되도록 만들었고 우테코 라이브러리의 assertSimpleTest()를 실행하여 프로그램의 흐름 전체를 통합테스트 할 수 있게 되었다! 특정 서비스 클래스가 실행되도록 구현하면 단위테스트도 노려 볼 수 있다! 아래는 우테코 라이브러리의 assertSimpleTest이다. 즉 안의 실행가능한 메서드를 넣고 메서드가 끝날때까지 타임아웃을 걸어놓고 기다린다⏰
public static void assertSimpleTest(final Executable executable) {
assertTimeoutPreemptively(SIMPLE_TEST_TIMEOUT, executable);
}
아래는 메인함수의 흐름과 비슷하지만 runMain()오버라이딩하여 원하는데로 프로그램의 흐름을 바꿔보았다!
@Test
@DisplayName("같은자동차 입력하면 예외 테스트")
void doRacingGame1() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException("pobi,javaji,pobi", "2"))
.isInstanceOf(IllegalArgumentException.class)
);
}
@ParameterizedTest()
@ValueSource(strings = {"-1", "0"})
@DisplayName("시도횟수 자연수아니면 예외 테스트")
void doRacingGame2(String text) {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException("pobi", text))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("시도 횟수는 범위 내의 자연수여야합니다.")
);
}
@Override
public void runMain(){
View view=new View();
RacingService racingService=new RacingService();
RacingController racingController = new RacingController(view,racingService);
racingController.doRacingGame();
}
NsTest를 활용하여 빈값을 검증할 때 ""(빈 문자열)이 아닌 "\n"(엔터)를 넘겨줘야 한다.
왜 그럴까?
일단 나는 사용자의 터무니 없는 빈값 검증을 View계층에서 해왔기에 별 걱정 없이 ""(빈 문자열)를 검증하다가 오류가 났다. isBlank()에 따르면 ""가 빈 문자열인데...? 실제 검증 로직에서 그렇게 검증했는데🤔🤔🤔
NsTest는 내부의 입력 메서드로 Console이라는 입력함수를 쓰고 있다. Console(우테코 제공)은 내부적으로 자바의 Scanner함수를 쓰는데, 아래 Scanner의 코드를 보자. ""가 들어왔을 때 빈 입력이 아니라, 진짜 입력이 없다고 판단하여 NoSuchElementExpception를 던진다.
// Scanner.java
public class Scanner {
...
public String nextLine() {
modCount++;
if (hasNextPattern == linePattern())
return getCachedResult();
clearCaches();
String result = findWithinHorizon(linePattern, 0);
if (result == null)
throw new NoSuchElementException("No line found");
MatchResult mr = this.match();
String lineSep = mr.group(1);
if (lineSep != null)
result = result.substring(0, result.length() - lineSep.length());
if (result == null)
throw new NoSuchElementException();
else
return result;
}
...
}
위에서 봤던 NsTest의 예외처리 메서드를 다시 가져와보면 NoSuchElementExpception를 의도적으로 ignore하고 있다!! 즉 ""를 빈값으로 인식하지 않는다는 것이고 예외를 터뜨리지 않겠다는 것이다.
// NsTest.java
...
// 예외 처리 테스트를 위해 사용하는 메서드
protected final void runException(final String... args) {
try {
run(args);
} catch (final NoSuchElementException ignore) {
}
}
...
흠.. 우테코는 NosuchElementException을 의도적으로 무시할까...? 혼자 생각해보며 지피티와 계속 싸워본 결과 내가 내린 결론은 우테코는 NoSuchElementException(= EOF)로 테스트가 깨지는 걸 원하지 않는다는 결론이다.
EOF는 “더 이상 읽을 게 없다”는 시그널일 뿐이니 조용히 삼키고 이미 캡처한 출력/예외를 기준으로 단언(assert)하려는 의도로 보인다.(🤖)
하긴 조금만 생각해보면 콘솔에서의 사용자의 빈값은 "아무것도 입력안하고 엔터를 눌러야" 프로그램이 끝나므로 그런 논리라면 "\n"가 빈문자열이라고 표현하는 것이 더 맞다고 생각한다. 그래서 결론이다.
프로그램에서는 ""를 ‘빈 문자열’로 본다.(실제로 그렇게 구현하였다. isBlank())
NsTest를 활용한 테스트에서 ""는 “값이 빈 문자열”이 아니라 입력 스트림이 아예 없음(EOF)을 뜻한다.
EOF의 예외(NoSuchElementException)를 잡아버리므로 예외가 터지지 않는다.
빈 문자열은 "\n"으로 하자.
마무리하며
2주차 미션의 목적 중 하나는 테스트 코드 작성법에 익숙해지기였는데, 아직 부족하다고 느꼈다😂 그리고 내가 제출한 코드는 작은 단위의 테스트라기보단 큰 단위의, 완성된 기능을 테스트하는 로직에 가깝다고 느꼈다.
마침 2주차 공통 피드백에서 Junit5, assertJ와 친해질 수 있는 예제를 주셔서 풀어보며 친해질 수 있는 시간이 된 것 같다.
공통 피드백을 적용해보려고 테스트를 작은 거부터 해보려는데,, 사실 막막하다. 그래도 일단 잘하시는 분들 코드 참고해서라도 적용해보려고 한다!! 참고하는 와중에 우테코 라이브러리에 대해 깊게 알아볼 수 있어서 좋았다👍 기회가 된다면 꼭 적용해봐야겠다!!
요즘은 하루하루 알차게 채워보자는 마음가짐으로 살고있다. 그러다보면 꽉찬 열매가 오지 않을까? 하는 바램이다! 오늘도 내일도 꽈악 채워보자!! 파이팅🔥
'Various Dev > 우아한테크코스' 카테고리의 다른 글
| [우아한테크코스] 로또 문제 코틀린으로 리팩토링하기(코드리뷰 피드백을 반영하며) (0) | 2025.11.09 |
|---|---|
| [우아한테크코스] 프리코스 3주차 미션 회고 - 로또 (0) | 2025.11.05 |
| [우아한테크코스] 프리코스 2주차 문제 회고 - 자동차 경주 (2) | 2025.10.28 |
| [우아한테크코스] 프리코스 1주차 문제 회고 - 문자열 덧셈 계산기 (0) | 2025.10.19 |
| [미리보는 프리코스] 시작 하루 전..! 가장 hot한 논제 "각 계층의 필요시점은 어떻게 될까요?" by me (근데 이제 회고와 다짐을 곁들인..) (2) | 2025.10.13 |