자바/초록스터디

(2)초록스터디 step1 - 초간단 계산기 구현 : 계산기 구현과 JUnit5

문상휘파람 2024. 5. 4. 23:56

앞서 블로그에 소개한 순서대로, 공부를 진행해보겠다.

 

1. 계산기 메서드 구현

public class Calculator {

    public static int add(int num1, int num2) {
        return num1 + num2;
    }

    public static int subtract(int num1, int num2) {
        return num1 - num2;
    }

    public static int multiple(int num1, int num2){
        return num1*num2;
    }

    public static int divide(int num1, int num2){
        return num1/num2;
    }
}

 

일단은 요런식으로 빠르고 간단하게 구현했고, 빨리 Junit5 사용법 익혀서 위 메서드를 테스트 코드에 적용시켜 보겠다. (나중에 MVC 패턴으로 수정 할거임!!)

 

2. Junit5 학습

 

먼저 기본적으로 Junit5는, 

자바 언어를 사용하는 소프트웨어 개발자들을 위한 테스트 프레임워크 중 하나이다.

(가독성이 떨어질까봐 블록에 넣어봤음)

 

기본적으로 테스트를 통해 소프트웨어 품질을 향상시킬 수 있고, Junit5는 단위테스트를 하는 데에 주로 쓰인다고 한다.

 

이제 진짜 start!

학습 테스트 진행 방식

  • java-test/initial 아래에 있는 JUnit5Test로 이동한다.
  • 주석을 참고하며 학습을 진행한다.
    • 학습 테스트 기반으로 학습하는 과정을 경험해 본다.
    • 테스트를 잘 작성하기 위한 JUnit5 도구 사용법을 학습한다.
  • 충분히 고민하며 스스로 답을 해본 후 java-test/complete를 보며 한 번 더 학습한다.
    • 꼭 스스로 고민하고 답을 내본 후 봐야 한다.
    • 만약 자신만의 답이 나오지 않는다면 아예 보지 않는 것을 추천한다.

위 과정에 따라 진행해보겠다.


/**
 * `@Test` 애노테이션이 없다면 해당 메서드는 테스트 메서드가 아닙니다.
 * 따라서 해당 메서드는 테스트 메서드가 아니기 때문에, 테스트 메서드로서의 역할을 수행하지 않습니다.
 * `@Test` 애노테이션을 사용하면 해당 메서드는 테스트 메서드가 되며, 테스트 메서드로서의 역할을 수행합니다.
 */
@Test
void Test_애노테이션을_붙여_테스트_메서드로_만든다() {
    // TODO: `@Test` 애노테이션을 활용하여 테스트가 실행되게 해주세요.
}

* 꼭 @Test 어노테이션을 붙여줘야 한다.


@Test
void return_타입이_void가_아니라면_테스트_메서드가_아니다() {
    // TODO: return 타입을 변경하여 해당 테스트가 실행되게 해주세요.
}

* 꼭 void 타입이여야 한다. 

원래 int형에 return 0; 이였는데 내가 수정해줌.


@Test
@DisplayName("@DisplayName 학습")
void DisplayName_애노테이션을_붙여_경고를_제거한다() {
    // TODO: `@DisplayName` 애노테이션을 활용하여 `Non-ASCII characters` 경고를 제거해주세요.
}

* 메서드명 한글로 해주려면 @DisplayName 필요.


/**
 * `@Nested` 애노테이션은 해당 클래스가 중첩 클래스임을 나타냅니다.
 * 중첩 클래스는 클래스 내부에 선언된 클래스를 의미합니다.
 * 중첩으로 표현하는 이유는 클래스의 의미를 명확하게 하기 위함입니다.
 */
@Nested
@DisplayName("@Nested 애노테이션 학습 테스트")
class NestedAnnotationTest {
    /**
     * `@Nested` 애노테이션이 없다면 해당 메서드는 중첩 클래스가 아닙니다.
     * 따라서 해당 메서드는 중첩 클래스 내부에 있는게 아니기 때문에, 테스트 메서드로서의 역할을 수행하지 않습니다.
     * `@Nested` 애노테이션을 사용하면 해당 메서드는 중첩 클래스 내부에 있는 것으로 인식되며, 테스트 메서드로서의 역할을 수행합니다.
     */
    @Test
    @DisplayName("@Nested 애노테이션을 붙여줘야 중첩 클래스로서의 역할을 수행한다")
    void Nested_애노테이션을_붙여줘야_중첩_클래스로서의_역할을_수행한다() {
        // TODO: `@Nested` 애노테이션을 활용하여 테스트가 실행되게 해주세요.
    }
}

* 이런식으로 해당 메서드를 중첩 클래스 내부에서 사용하려면 @Nested 사용해야 한다. 


/**
 * `@Disabled` 애노테이션이 없다면 해당 메서드는 테스트 메서드입니다.
 * 따라서 해당 메서드는 테스트 메서드로서의 역할을 수행합니다.
 * `@Disabled` 애노테이션을 사용하면 해당 메서드는 테스트 메서드가 아니기 때문에, 테스트 메서드로서의 역할을 수행하지 않습니다.
 */
@Test
@DisplayName("@Disabled 애노테이션을 붙여줘야 테스트 메서드로서의 역할을 수행하지 않는다")
@Disabled
void Disabled_애노테이션을_붙여줘야_테스트_메서드로서의_역할을_수행하지_않는다() {
    // TODO: `@Disabled` 애노테이션을 활용하여 테스트가 실행되지 않게 해주세요.
    throw new RuntimeException("항상 실패한다.");
}

/**
 * `@Disabled` 애노테이션은 클래스에도 적용 가능하며, 클래스에 `@Disabled` 애노테이션을 붙이면 해당 클래스의 모든 테스트가 비활성화됩니다.
 * 따라서 해당 클래스의 모든 테스트가 비활성화되기 때문에, 테스트 메서드로서의 역할을 수행하지 않습니다.
 */

* @Disabled 사용하면 비활성화가 가능하다. 

근데 이건 어느때에 주로 사용될까 궁금하다. 많이 봤는데, 테스트 코드를 제대로 짜본 적이 없어서 어느 타이밍에 사용할 지 감이 안잡힌다. 좀 찾아봐야 할듯.


/**
 * `assertEquals` 메서드는 `assertEquals(expected, actual)` 형태로 오버로딩되어 있습니다.
 * `expected`는 예상되는 값이며, `actual`은 실제 값입니다.
 * <p>
 * `assertEquals` 메서드는 두 값이 같다면 테스트를 성공시킵니다.
 * 기존에 작성된 검증 코드를 `assertEquals` 메서드를 사용하도록 변경해주세요.
 */
@Test
@DisplayName("assertEquals 메서드로 두 값이 같은지 비교한다")
void assertEquals_메서드로_두_값이_같은지_비교한다() {
    final var a = 1;
    final var b = 2;
    final var actual = a + b;

    final var expected = 3;

    // TODO: 아래 코드를 assertEquals 메서드로 대체해주세요. 제대로 동작하는지 확인하기 위해 expected와 actual의 값을 바꿔보세요.
    assertEquals(actual,expected);
}

* assertEquals (여기에 메세지 추가도 가능함)


/**
 * `assertEquals` 메서드는 `expected`와 `actual`이 같은지 비교하기 위해 `equals` 메서드를 사용합니다.
 * 따라서 `expected`와 `actual`의 타입이 `equals` 메서드를 정의하고 있다면 `assertEquals` 메서드를 사용할 수 있습니다.
 * <p>
 * 그 말은 `equals` 메서드를 정의하지 않은 타입은 `assertEquals` 메서드를 사용할 수 없다는 것을 의미합니다.
 * 기존 객체 비교 코드에서 `equals`를 재정의하여 테스트를 성공시켜주세요.
 */
@Test
@DisplayName("assertEquals 메서드로 두 객체가 같은지 비교한다")
void assertEquals_메서드로_두_객체가_같은지_비교한다() {
    class LocalObject {
        private final int value;

        public LocalObject(int value) {
            this.value = value;
        }

        @Override
        public boolean equals(Object obj) {
            if(this==obj){
                return true;
            }
            if(!(obj instanceof LocalObject)){
                return false;
            }
            LocalObject localObject = (LocalObject) obj;
            return Objects.equals(this.value,localObject.value);
        }
    }

    final var a = new LocalObject(1);
    final var b = new LocalObject(1);

    // TODO: LocalObject 클래스의 equals를 재정의하여 아래 코드가 테스트를 성공시키도록 해주세요.
    assertEquals(a, b);
}

* assertEquals 쓰려면 꼭 equals 매서드를 재정의 한 타입이여야 한다.

평소에 equals 메서드를 왜 계속 재정의 하나 했더니, 이런 이유도 있다는 것을 오늘 또 처음 알았다. 이 부분에서 30분 동안 헤맴........답은 끝까지 안보고 구글링 통해서 알아냈다.


/**
 * `assertNotEquals` 메서드는 두 값이 다르다면 테스트를 성공시킵니다.
 * 기존에 작성된 검증 코드를 `assertNotEquals` 메서드를 사용하도록 변경해주세요.
 */
@Test
@DisplayName("assertNotEquals 메서드로 두 값이 다른지 비교한다")
void assertNotEquals_메서드로_두_값이_다른지_비교한다() {
    final var a = 1;
    final var b = 2;
    final var actual = a + b;

    final var unexpected = 0;

    // TODO: 아래 코드를 assertNotEquals 메서드로 대체해주세요. 제대로 동작하는지 확인하기 위해 expected와 actual의 값을 바꿔보세요.
    assertNotEquals(unexpected,actual);
}

* assertNotEquals 메서드.

두 값이 같지 않을 때, 테스트 통과.


@Test
@DisplayName("assertSame 메서드로 두 객체가 같은지 비교한다")
void assertSame_메서드로_두_객체가_같은지_비교한다() {
    final var object = new Object();

    final var actual = object;
    final var expected = object;

    // TODO: 아래 코드를 assertSame 메서드로 대체해주세요. 제대로 동작하는지 확인하기 위해 expected와 actual의 값을 바꿔보세요.
    assertSame(expected,actual);
}

* assertSame 메서드: 두 객체의 비교 메서드


/**
 * `assertThrows` 메서드는 `assertThrows(expectedType, executable)` 형태로 오버로딩되어 있습니다.
 * `assertThrows` 메서드는 특정 예외가 발생한다면 테스트를 성공시킵니다.
 */
@Test
@DisplayName("assertThrows 메서드로 특정 예외가 발생하는지 비교한다")
void assertThrows_메서드로_특정_예외가_발생하는지_비교한다() {
    // TODO: try-catch문을 사용하지 않고 assertThrows 메서드를 사용하여 테스트가 성공하도록 해주세요.
    try {
        causeException();
    } catch (Exception e) {
        return;
    }

    throw new RuntimeException("예외가 발생하지 않았습니다.");
}

* 대망의 try-catch 문이 나왔다. 나같은 초짜에게 있어서 이 부분 테스트 어떤식으로 해야할지 감이 1도 잡히지 않았슴......

기본적으로 try-catch 문은 try 문에서 발생한 예외를 catch 문에서 잡아서 오류 코드 실행시키는 것.

try{
  예외가 발생할 수 있는 코드
}
catch(발생할 수 있는 예외 타입){
  예외 처리 코드
}
finally{
  예외 관계 없이 실행되는 코드
}

내가 이해한 try-catch 문은 요런 느낌임. 이제 이 코드를 어떻게 assertThrows()를 사용해서 테스트 해볼까....

@Test
@DisplayName("assertThrows 메서드로 특정 예외가 발생하는지 비교한다")
void assertThrows_메서드로_특정_예외가_발생하는지_비교한다() {
    // TODO: try-catch문을 사용하지 않고 assertThrows 메서드를 사용하여 테스트가 성공하도록 해주세요.
    assertThrows(RuntimeException.class, this::causeException);
}

난 요런식으로 작성했다. 근데 예외 처리 문장은 언제 봐도 어색한 것 같다.. 많이 접해보질 않아서.. 그래도 이번 기회에 예외처리에 대해 어떤 느낌으로 테스트 코드 짜는지 알 수 있어서 진짜 진짜 좋음.......혼자 공부할 땐 너무너무 힘들었다ㅠ

그리고 이 try-catch test 부분은 많이 중요한 것 같아서 문제 부분까지 코드를 같이 올렸다.

@Test
@DisplayName("assertThrows 메서드로 특정 예외가 발생하는지 비교한다")
void assertThrows_메서드로_특정_예외가_발생하는지_비교한다() {
    assertThrows(Exception.class, this::causeException);
}

요건 모범답안임. 난 Runtime에 대해서만 예외 발생시키고 답안은 모든 예외처리 한듯.


/**
 * `assertThrows` 메서드는 `assertThrows(expectedType, executable)` 형태로 오버로딩되어 있습니다.
 * `expectedType`은 예상되는 예외 타입이며, `executable`은 예외가 발생할 코드입니다.
 */
@Test
@DisplayName("assertThrows 메서드로 특정 예외가 발생하는지 비교하며, 특정 예외가 발생하지 않는다면 테스트가 실패한다")
void assertThrows_메서드로_특정_예외가_발생하는지_비교하며_특정_예외가_발생하지_않는다면_테스트가_실패한다() {
    // TODO: `causeException` 메서드에서 발생하는 예외 타입을 확인 후 `expectedType`를 변경하여 테스트가 성공하도록 해주세요.
    assertThrows(IllegalCallerException.class, this::causeException);
}

private void causeException() {
    throw new IllegalCallerException("예외가 발생했습니다.");
}

expectedType 을 Caller 타입으로 바꿔줬다. 이건 쉬웠다 ㅋ(밑에 예외 종류 써져있음 ㅋㅋ..)


/**
 * `assertDoesNotThrow` 메서드는 `assertDoesNotThrow(executable)` 형태로 오버로딩되어 있습니다.
 * `assertDoesNotThrow` 메서드는 특정 예외가 발생하지 않는다면 테스트를 성공시킵니다.
 * <p>
 * `assertDoesNotThrow` 메서드를 사용하지 않아도 테스트는 성공하지만, `assertDoesNotThrow` 메서드를 사용하지 않는다면 테스트의 의도를 명확하게 표현할 수 없습니다.
 * 따라서 `assertDoesNotThrow` 메서드를 사용하여 테스트의 의도를 명확하게 표현하는 것이 좋습니다.
 */
@Test
@DisplayName("assertDoesNotThrow 메서드로 특정 예외가 발생하지 않는 것을 명시한다")
void assertDoesNotThrow_메서드로_특정_예외가_발생하지_않는_것을_명시한다() {
    // TODO: `assertDoesNotThrow` 메서드를 사용하여 테스트의 의도를 명확하게 표현해주세요.
    assertDoesNotThrow(() -> {
        final var number = Integer.valueOf(0x80000000);
    });
}

assertDoesNotThrow는 처음 봐서 신기했다. 그래서 구글링 통해서 사용법 익히고 적용해봄. 당연한 코드를 테스트 할 때 사용하면 테스트의 의도를 더욱 확실하게 전달해 줄 수 있을 것 같다.


/**
 * `assertAll` 메서드는 `assertAll(executables)` 형태로 오버로딩되어 있습니다.
 * `assertAll` 메서드는 `executables`에 포함된 검증 코드를 모두 실행합니다.
 * `executables`에 포함된 검증 코드 중 하나라도 실패한다면 테스트는 실패합니다.
 * <p>
 * `assertAll`를 사용하는 이유는 여러 검증 코드가 존재할 때 문제가 발생한다면 어떤 검증 코드에서 문제가 발생했는지 알기 어렵기 때문입니다.
 */
@Test
@DisplayName("assertAll 메서드로 여러 검증 코드를 한 번에 실행한다")
void assertAll_메서드로_여러_검증_코드를_한_번에_실행한다() {
    // TODO: `assertAll`을 사용하지 않고 모든 테스트를 통과시키는 것과 `assertAll`를 사용하고 모든 테스트를 통과시키는 것에 차이를 비교해보세요.
    assertAll(
            () -> assertEquals(3, 1 + 2),
            () -> assertEquals(5, 3 + 2),
            () -> assertEquals(21, 7 * 3),
            () -> assertEquals(32, 3 * 7 ^ 5),
            () -> assertEquals(4, 7 * 3 / 5 + 33 / 21),
            () -> assertEquals(22, 33 * 3 / 5 + 7 / 2),
            () -> assertEquals(22, 33 * 3 / 5 + 7 / 2 + 1));

}

assertAll 도 처음 써보는데 한 번에 검증할 때, 편리할듯.

터미널 결과에 어디 부분이 오류인지 다 뜸..!!!! 너무 길어서 첨부하지는 못했다 ㅠ


/**
 * `@ValueSource` 애노테이션은 `@ParameterizedTest` 애노테이션과 함께 사용되며 정의된 값을 하나의 인자로 받아들이는 역할을 합니다.
 * `@ValueSource` 내부에 선언된 `ints` 속성은 정수 형태의 인자를 입력할 수 있도록 만들어줍니다.
 */
@ParameterizedTest
@ValueSource(ints = 1)
@DisplayName("ValueSource 애노테이션을 붙여 정수 매개변수를 한 번 입력받는다")
void ValueSource_애노테이션을_붙여_정수_매개변수를_한_번_입력받는다(int value) {
}

@ParameterizedTest, @ValueSource 진짜 진짜 이 기능을 원했다............ 맨날 매개변수 어떻게 넘겨줄 지 고민했는데....초록스터디 진짜 짱이네

/**
 * `@ValueSource` 애노테이션의 속성들은 다음과 같이 배열 형태로 입력이 가능합니다.
 * `@ValueSource` 애노테이션의 속성을 배열 형태로 입력해줌으로서, 각 배열의 값마다 각각의 테스트를 수행하도록 구현할 수 있습니다.
 */
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4})
@DisplayName("ValueSource 애노테이션을 붙여 정수 매개변수를 여러 번 입력받는다")
void ValueSource_애노테이션을_붙여_정수_매개변수를_여러_번_입력받는다(int value) {
    // TODO: `@ValueSource`를 사용하지 않고 여러 정수의 범위를 테스트하는 것과, `@ValueSource`를 사용해서 테스트하는 것의 차이를 비교해보세요.
    assertTrue(value > 0 && value < 10);
}

이런 식으로 사용할 수 있음 !! 매개변수 여러개 넘겨주기 가능(배열 형식으로)

@ParameterizedTest
@ValueSource(strings = {"a", "b", "c"})
@DisplayName("ValueSource 애노테이션을 붙여 문자열 매개변수를 여러 번 입력받는다")
void ValueSource_애노테이션을_붙여_문자열_매개변수를_여러_번_입력받는다(String value) {
    // TODO: `@ValueSource`를 사용하지 않고 여러 문자열의 Length를 테스트하는 것과, `@ValueSource`를 사용해서 테스트하는 것의 차이를 비교해보세요.
    assertEquals(value.length(), 1);
}

문자열도 가능함

@ParameterizedTest
@ValueSource(strings = {"1", "2", "3", "4", "5"})
@DisplayName("ValueSource 애노테이션을 활용하여 1부터 5까지의 문자열 값을 Integer로 parseInt하는 로직이 예외를 발생시키지 않는 지 검증한다")
void ValueSource_애노테이션을_활용하여_1부터_5까지의_문자열_값을_Integer로_변경하는_로직이_예외를_발생시키지_않는_지_검증한다(String value) {
    // TODO: `@ValueSource`를 사용하지 않고 1~5 까지의 문자열 값을 `Integer`로 `parseInt`하는 로직을 테스트하는 것과, `@ValueSource`를 사용해서 테스트하는 것의 차이를 비교해보세요.
    assertDoesNotThrow(() -> Integer.parseInt(value));
}

/**
 * `@MethodSource` 애노테이션은 메서드 이름을 인자로 받아들이며, 인자에서 호출된 메서드는 테스트 메서드에 전달될 인자를 반환합니다.
 */
@ParameterizedTest
@MethodSource("methodSourceTestArguments")
@DisplayName("MethodSource 애노테이션을 붙여 Object 매개변수를 한 번 입력받는다")
void MethodSource_애노테이션을_붙여_Object_매개변수를_한_번_입력받는다(Object value) {
}

@MethodSource 이 어노테이션은 메서드 이름을 받아서 사용할 수 있게 해줌. 완전 신기하네 메서드명 받아서 해주는건

@ParameterizedTest
@MethodSource("methodSourcesTestArguments")
@DisplayName("MethodSource 애노테이션을 붙여 Object 매개변수를 여러 번 입력받는다")
void MethodSource_애노테이션을_붙여_Object_매개변수를_여러_번_입력받는다(Object object) {
    assertInstanceOf(Object.class, object);
}

/**
 * `Arguments`를 `Stream` 내부에 여러 개 선언해줌으로서, 각 `Arguments`마다 각각의 테스트를 수행하도록 구현할 수 있습니다.
 */
private static Stream<Arguments> methodSourcesTestArguments() {
    return Stream.of(
            Arguments.arguments(new Object()),
            Arguments.arguments(new Object()),
            Arguments.arguments(new Object()),
            Arguments.arguments(new Object())
    );
}

이런 식으로 사용할 수 있음.

 

/**
 * `@MethodSource` 애노테이션을 사용하면 `Iterable` 또한 테스트의 인자로 입력받을 수 있으며, 이 외에 어떠한 객체라도 입력받을 수 있습니다.
 * `Arguments`의 `arguments` 메서드는 매개변수로 `Object... arguments`를 입력받고, 내부 구현을 통해 `Arguments` 객체를 생성하기 때문에 어떠한 타입이든 테스트의 인자로 사용할 수 있는 것입니다.
 */
@ParameterizedTest
@MethodSource("methodSourceIterableTestArguments")
@DisplayName("MethodSource 애노테이션을 붙여 Iterable 매개변수를 입력받는다")
void MethodSource_애노테이션을_붙여_Iterable_매개변수를_입력받는다(List<Integer> values) {
    assertEquals(values.size(), 3);
}

private static Stream<Arguments> methodSourceIterableTestArguments() {
    return Stream.of(
            Arguments.arguments(
                    List.of(1, 4, 5),
                    List.of(1, 2, 3),
                    List.of(1, 3, 4)
            )
    );
}

그냥 메서드 명으로 메서드가 실행되는게 아직도 신기하다.. 자바 왤케 기능이 많냐 ㅠㅠ

/**
 * `@MethodSource` 애노테이션과 `Arguments` 객체를 사용하면 서로 다른 자료형의 값 또한 테스트의 인자로 함께 입력받을 수 있습니다.
 */
@ParameterizedTest
@MethodSource("methodSourcesStringAndIntegerTestArguments")
@DisplayName("MethodSource 애노테이션을 붙여 정수와 문자열 매개변수를 입력받는다")
void MethodSource_애노테이션을_붙여_정수와_문자열_매개변수를_입력받는다(String v1, int v2) {
    // TODO: `MethodSource`를 사용하지 않고 문자열과 정수 변수들을 직접 선언하여 테스트하는 방식과, `MethodSource`를 통해 입력받아 테스트하는 방식의 차이를 비교해보세요.
    assertEquals(Integer.parseInt(v1), v2);
}

private static Stream<Arguments> methodSourcesStringAndIntegerTestArguments() {
    return Stream.of(
            Arguments.arguments("1", 1),
            Arguments.arguments("2", 2),
            Arguments.arguments("3", 3),
            Arguments.arguments("4", 4)
    );
}

이런 방법도 있음

 

 

 

여기까지가 Junit5 학습이였고, 위에 있는 코드들은 모두 내가 문제->정답으로 작성한 코드들이다. 

 

Junit5 단위테스트 학습을 하면서 느낀 점은, 내가 처음 spring boot 시작했을 때 다들 테스트 코드가 중요하다고 하는데 어떤 식으로 작성해야 할 줄 몰랐는데 이 답답함에 대한 부분을 많이 해소한 것 같다. 물론 테스트 코드 짤 때, 여러 가지 경우의 수는 내가 생각해야 하지만 작성법, 어찌보면 사용법을 알았으니까 제대로된 방향성을 잡고 공부할 수 있을 것 같다. 열심히 해야쥥