📚 Study/Spring

[Spring] JUnit을 통한 TDD

0_ch4n 2022. 8. 5. 19:55
반응형
공부를 위한 목적으로 찾아보며 작성한 글이므로 잘못된 부분이 있을 수 있습니다.
추가해야 하거나 수정되어야 할 부분을 댓글로 알려주시면 다시 공부한 후 반영하도록 하겠습니다.

 

✔️ TDD(Test-Driven Development)

 

📌 TDD란

TDD는 Test-Driven Development 의 약자로 테스트 주도 개발이라고 하며 반복 테스트를 이용한 소프트웨어 개발 프로세스입니다.

우선 개발자는 요구되는 새로운 기능에 대한 자동화된 테스트 케이스를 작성하고 해당 테스트를 통과하는 가장 간단한 코드를 작성합니다.

일단 테스트 통과하는 코드를 작성하고 상황에 맞게 리팩토링하는 과정을 거치는 것입니다.

 

짧은 개발 주기의 반복에 의존하는 개발 프로세스이며 애자일 방법론 중 하나인 eXtream Programming(XP)의 Test-First 개념에 기반을 둔 단순한 설계를 중요시합니다.

이 기법을 개발했거나 재발견한 것으로 인정되는 Kent Beck은 TDD가 단순한 설계를 장려하고 자신감을 불어넣어 준다고 말했습니다.

 

📌 TDD의 중요성

 

1. 보다 튼튼한 객체지향적인 코드 생산

TDD는 코드의 재사용 보장을 명시하므로 TDD를 통한 소프트웨어 개발 시 기능 별 철저한 모듈화가 이뤄진다.

이는 종속성과 의존성이 낮은 모듈로 조합된 소프트웨어 개발을 가능하게 하며 필요에 따라 모듈을 추가하거나 제거해도 소프트웨어 전체 구조에 영향을 미치지 않게 된다.

 

2. 재설계 시간의 단축

테스트 코드를 먼저 작성하기 때문에 개발자가 지금 무엇을 해야하는지 분명히 정의하고 개발을 시작하게 된다.

또한 테스트 시나리오를 작성하면서 다양한 예외사항에 대해 생각해볼 수 있다.

이는 개발 진행 중 소프트웨어의 전반적인 설계가 변경되는 일을 방지할 수 있다.

 

3. 디버깅 시간의 단축

이는 유닛 테스팅을 하는 이점이기도 하다. 예를 들면 사용자의 데이터가 잘못 나온다면 DB의 문제인지, 비즈니스 레이어의 문제인지, UI의 문제인지 실제 모든 레이어들을 전부 디버깅 해야하지만 TDD의 경우 자동화 된 유닛테스팅을 전재하므로 특정 버그를 쉽게 찾아낼 수 있다.

 

4. 테스트 문서의 대체 가능

어떤 요소들이 테스트 되었는지 테스트 정의서를 만들 때 TDD를 하게 될 경우 테스팅을 자동화 시킴과 동시에 보다 정확한 테스트 근거를 산출할 수 있다.

 

5. 추가 구현의 용이함

개발이 완료된 소프트웨어에 어떤 기능을 추가할 때 가장 우려되는 점은 해당 기능이 기존 코드에 어떤 영향을 미칠지 알지 못한다는 것이다.

하지만 TDD의 경우 자동화된 유닛 테스팅을 전제하므로 테스트 기간을 획기적으로 단축시킬 수 있다.

 

📌 TDD는 항상 옳을까?

TDD의 단점을 꼽자면 사전 준비 기간이 길고 생산성이 저하된다는 점이 있습니다.

그렇기 때문에 TDD가 실무에서 실제로 많이 쓰이는지, 유용한지에 대해 찾아보다가 좋은 글을 발견했습니다.

TDD는 항상 옳지 않다

요약하자면 TDD는 역시 비용의 문제이기 때문에 시간을 투자함에 따라 효율적인 측면도 고려해야 한다는 것입니다.

 

📌 TDD 개발 주기

[RED] Write a failing test 단계에서는 실패하는 테스트 코드를 먼저 작성합니다.

[GREEN] Make the test pass 단계에서는 테스트 코드를 성공시키기 위한 실제 코드를 작성합니다.

[BLUE] Refactor 단계에서는 중복 코드 제거, 일반화 등의 리팩토링을 수행합니다.

 

중요한 것은 실패하는 테스트 코드를 작성할 때까지 실제 코드를 작성하지 않는 것과 실패하는 테스트를 통과할 정도의 최소 실제 코드를 작성해야 하는 것입니다.

이를 통해, 실제 코드에 대해 기대되는 바를 보다 명확하게 정의함으로써 불필요한 설계를 피할 수 있고 정확한 요구 사항에 집중할 수 있습니다.

 

👀 Spring에서 TDD 프로그래밍 방법

  1. Repository -> Service -> Controller 순서로 개발을 진행한다.
  2. Repository 계층의 테스트는 H2와 같은 인메모리 데이터베이스 기반의 통합 테스트로 진행한다.
  3. Service 계층의 테스트는 Mockito를 사용해 Repository 계층을 Mock하여 진행한다.
  4. Controller 계층의 테스트는 SpringTest의 MockMvc를 사용해 진행한다.

 

✔️ 단위 테스트(Unit Test)

테스트에는 단위 테스트말고도 통합 테스트, UI 테스트 등이 있지만

TDD는 첫 단계에서 기능 단위의 테스트 코드를 작성하므로 단위 테스트만을 다룰 것입니다.

 

단위테스트는 가장 작은 단위의 테스트로 일반적으로 메서드 레벨에서 테스트합니다.

특징으로는 매우 간단하고 명확하며 빠르게 실행된다는 특징이 있습니다.

쉽게 말하면, 하나의 함수에 대해 하나 이상의 테스트가 존재할 수 있고 각각의 조건에 대한 유효성을 검증합니다.

이렇게 작성된 단위 테스트가 많을수록 해당 로직에 대한 신뢰도가 높아질 수 있습니다.

또한 작게 쪼개진 단위 테스트는 해당 로직이 어떤 역할을 하는지 쉽게 파악할 수 있다는 장점이 있습니다.

 

✔️ JUnit

JUnit은 JAVA를 사용하는 xUnit 유닛 테스트 프레임워크입니다.

JUnit5는 JAVA8 이상부터 사용 가능하며 JUnit Platform + JUnit Jupiter + JUnit Vintage가 합쳐진 형태입니다.

  • JUnit Platform : 테스팅을 위한 TestEngine API를 제공하며 JVM에서 xUnit을 시작하기 위한 기반 역할을 합니다.
  • JUnit Jupiter : 테스트 및 확장을 작성하기 위한 프로그래밍 모델과 확장 모델의 조합으로 TestEngine API의 구현체입니다.
  • JUnit Vintage : 하위호환을 위해 JUnit3 및 JUnit4 기반 TestEngine API의 구현체를 제공합니다.

 

📌 정의

  • Platform 개념
    • 컨테이너 : 다른 컨테이너 또는 테스트를 자식으로 포함하는 테스트 트리의 노드 (ex. 테스트 클래스)
    • 테스트 : 실행될 때 예상되는 동작을 확인하는 테스트 트리의 노드 (ex. @Test 메서드)
  • Jupiter 개념
    • 수명주기 메서드 : @BeforeAll, @AfterAll, @BeforeEach 또는 @AfterEach 의 어노테이션이 직간접적으로 달린 메서드
    • 테스트 클래스 : 최소 하나의 테스트 메서드를 포함하는 최상위 레벨 클래스, static 멤버 클래스 또는 @Nested 클래스 (abstract 클래스이면 안되고 단일 생성자를 가져야 한다.)
    • 테스트 메서드 : @Test, @RepeatedTest, @ParameterizedTest, @TestFactory 또는 @TestTemplate 의 어노테이션이 직간접적으로 달린 메서드

수명주기 메서드, 테스트 클래스, 테스트 메서드는 접근제어자를 public으로 하지 않아도 되지만 private으로는 선언하면 안된다.

 

📌 Annotation

• @Test

해당 메서드가 테스트 메서드임을 나타냅니다.

@Test
void addition() {
    assertEquals(2, calculator.add(1, 1));
}

 

• @ParameterizedTest

매개변수를 받는 테스트를 작성할 수 있습니다.

  1. @ValueSource : short, byte, int, long, float, double, char, boolean, String, Class
  2. @NullSource : 단일 null 인수를 제공합니다.
  3. @EmptySource : 기본 배열, 객체 배열 유형의 매개변수에 대해 빈 인수를 제공합니다.
  4. @NullAndEmptySource : @NullSource + @EmptySource
  5. @EnumSource : Enum 상수를 클래스명을 지정하여 제공합니다.
  6. @MethodSource : 메서드를 제공합니다.
  7. @CsvSource : CSV 리터럴을 제공합니다.
  8. @CsvFileSource : CSV 리터럴의 파일 경로를 제공합니다.
  9. @ArgumentsSource : 재사용 가능한 사용자 지정을 지정하는데 사용할 수 있습니다.
@ParameterizedTest
@NullSource
@EmptySource
@NullAndEmptySource
@ValueSource(strings = { " ", "   ", "\t", "\n" })
@EnumSource(ChronoUnit.class)
@EnumSource(names = { "DAYS", "HOURS" })
@EnumSource(mode = EXCLUDE, names = { "ERAS", "FOREVER" })
@EnumSource(mode = MATCH_ALL, names = "^.*DAYS$")
@MethodSource("stringProvider")
@CsvSource({
    "apple,         1",
    "banana,        2",
    "'lemon, lime', 0xF1",
    "strawberry,    700_000"
})
@CsvFileSource(resources = "/two-column.csv", numLinesToSkip = 1)
@ArgumentsSource(MyArgumentsProvider.class)
void testWithValueSource(int argument) {
    assertTrue(argument > 0 && argument < 4);
}

 

• @RepeatedTest

반복되는 테스트를 작성할 수 있습니다.

@RepeatedTest(10)
void repeatedTest() {
    // ...
}

 

• @TestFactory

@Test로 선언된 정적 테스트가 아닌 동적으로 테스트를 사용합니다.

interface TestInterfaceDynamicTestsDemo {
    @TestFactory
    default Stream<DynamicTest> dynamicTestsForPalindromes() {
        return Stream.of("racecar", "radar", "mom", "dad")
            .map(text -> dynamicTest(text, () -> assertTrue(isPalindrome(text))));
    }
}

 

• @TestInstance

테스트 클래스의 생명주기를 설정합니다.

@TestInstance(Lifecycle.PER_CLASS)
interface TestLifecycleLogger {
    static final Logger logger = Logger.getLogger(TestLifecycleLogger.class.getName());

    @BeforeAll
    default void beforeAllTests() {
        logger.info("Before all tests");
    }

    @AfterAll
    default void afterAllTests() {
        logger.info("After all tests");
    }

    @BeforeEach
    default void beforeEachTest(TestInfo testInfo) {
        logger.info(() -> String.format("About to execute [%s]",
            testInfo.getDisplayName()));
    }

    @AfterEach
    default void afterEachTest(TestInfo testInfo) {
        logger.info(() -> String.format("Finished executing [%s]",
            testInfo.getDisplayName()));
    }
}

 

• @TestTemplate

공급자에 의해 여러 번 호출될 수 있도록 설계된 테스트 케이스 템플릿임을 나타냅니다.

 

• @TestClassOrder

테스트 클래스 실행 순서를 구성하는데 사용합니다.

  1. ClassOrderer.ClassName : 정규화된 클래스 이름을 기반으로 테스트 클래스를 영숫자순으로 정렬합니다.
  2. ClassOrderer.DisplayName : 표시 이름에 따라 테스트 클래스를 영숫자순으로 정렬합니다.
  3. ClassOrderer.OrderAnnotation : @Order 에 지정된 숫자를 기반으로 테스트 방법을 정렬합니다.
  4. ClassOrderer.Random : 테스트 방법을 무작위로 정렬합니다.
@TestClassOrder(ClassOrderer.OrderAnnotation.class)
class OrderedNestedTestClassesDemo {

    @Nested
    @Order(1)
    class PrimaryTests {

        @Test
        void test1() {
        }
    }

    @Nested
    @Order(2)
    class SecondaryTests {

        @Test
        void test2() {
        }
    }
}

 

• @TestMethodOrder

테스트 메서드 실행 순서를 구성하는데 사용합니다.

  1. MethodOrderer.DisplayName : 표시 이름에 따라 테스트 방법을 영숫자순으로 정렬합니다.
  2. MethodOrderer.MethodName : 이름과 형식 매개변수 목록을 기반으로 테스트 방법을 영숫자순으로 정렬합니다.
  3. MethodOrderer.OrderAnnotation : @Order 에 지정된 숫자를 기반으로 테스트 방법을 정렬합니다.
  4. MethodOrderer.Random : 테스트 방법을 무작위로 정렬합니다.
@TestMethodOrder(OrderAnnotation.class)
class OrderedTestsDemo {
    @Test
    @Order(1)
    void nullValues() {
        // perform assertions against null values
    }

    @Test
    @Order(2)
    void emptyValues() {
        // perform assertions against empty values
    }

    @Test
    @Order(3)
    void validValues() {
        // perform assertions against valid values
    }
}

 

• @DisplayName

테스트 보고서와 테스트 실행기 및 IDE에 표시될 공백, 특수 문자 및 이모티콘을 통해

테스트 클래스 또는 메서드의 사용자 정의 이름을 선언할 때 사용합니다.

@DisplayName("A special test case")
class DisplayNameDemo {
    @Test
    @DisplayName("Custom test name containing spaces")
    void testWithDisplayNameContainingSpaces() {
    }

    @Test
    @DisplayName("╯°□°)╯")
    void testWithDisplayNameContainingSpecialCharacters() {
    }

    @Test
    @DisplayName("😱")
    void testWithDisplayNameContainingEmoji() {
    }
}

 

• @DisplayNameGeneration

테스트 클래스와 메서드 이름을 통해 DisplayName을 생성해내는 방법을 지정합니다.

  1. Standard : 메서드 이름과 그 뒤에 붙는 괄호를 그대로 보여준다.
  2. Simple : 매개변수가 없는 메서드인 경우 method() -> method
  3. ReplaceUnderscores : test_name -> test name
  4. IndicativeSentences : TestClass + testMethod() = TestClasstestMethod()
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class A_year_is_not_supported {
    @Test
    void if_it_is_zero() {
    }
}

 

• @BeforeEach

모든 테스트 실행 전에 실행할 테스트에 사용합니다.

@BeforeEach
void init() {
}

 

• @AfterEach

모든 테스트 실행 후에 실행할 테스트에 사용합니다.

@AfterEach
void tearDown() {
}

 

• @BeforeAll

현재 클래스를 실행하기 전 제일 먼저 실행할 테스트를 작성하는데 static으로 선언합니다.

@BeforeAll
static void initAll() {
}

 

• @AfterAll

현재 클래스 종료 후 해당 테스트를 실행하는데 static으로 선언합니다.

@AfterAll
static void tearDownAll() {
}

 

• @Nested

클래스를 정적이 아닌 중첩 테스트 클래스임을 나타냅니다.

@DisplayName("A stack")
class TestingAStackDemo {

    Stack<Object> stack;

    @Test
    @DisplayName("is instantiated with new Stack()")
    void isInstantiatedWithNew() {
        new Stack<>();
    }

    @Nested
    @DisplayName("when new")
    class WhenNew {

        @BeforeEach
        void createNewStack() {
            stack = new Stack<>();
        }

        @Test
        @DisplayName("is empty")
        void isEmpty() {
            assertTrue(stack.isEmpty());
        }

        @Test
        @DisplayName("throws EmptyStackException when popped")
        void throwsExceptionWhenPopped() {
            assertThrows(EmptyStackException.class, stack::pop);
        }

        @Test
        @DisplayName("throws EmptyStackException when peeked")
        void throwsExceptionWhenPeeked() {
            assertThrows(EmptyStackException.class, stack::peek);
        }

        @Nested
        @DisplayName("after pushing an element")
        class AfterPushing {

            String anElement = "an element";

            @BeforeEach
            void pushAnElement() {
                stack.push(anElement);
            }

            @Test
            @DisplayName("it is no longer empty")
            void isNotEmpty() {
                assertFalse(stack.isEmpty());
            }

            @Test
            @DisplayName("returns the element when popped and is empty")
            void returnElementWhenPopped() {
                assertEquals(anElement, stack.pop());
                assertTrue(stack.isEmpty());
            }

            @Test
            @DisplayName("returns the element when peeked but remains not empty")
            void returnElementWhenPeeked() {
                assertEquals(anElement, stack.peek());
                assertFalse(stack.isEmpty());
            }
        }
    }
}

 

• @Tag

클래스 또는 메서드 레벨에서 태그를 선언할 때 사용합니다.

 

• @Disabled

이 클래스나 테스트를 사용하지 않음을 표시합니다.

@Test
@Disabled("for demonstration purposes")
void skippedTest() {
    // not executed
}

 

• @Timeout

테스트 실행 시간을 선언 후 초과되면 실패하도록 설정합니다.

 

• @ExtendWith

확장을 선언적으로 등록할 때 사용합니다.

@Tag("timed")
@ExtendWith(TimingExtension.class)
interface TimeExecutionLogger {
}
class TestInterfaceDemo implements TestLifecycleLogger, TimeExecutionLogger, TestInterfaceDynamicTestsDemo {
    @Test
    void isEqualValue() {
        assertEquals(1, "a".length(), "is always equal");
    }
}

 

• @RegisterExtension

필드를 통해 프로그래밍 방식으로 확장을 등록할 때 사용합니다.

 

• @TempDir

필드 주입 또는 매개변수 주입을 통해 임시 디렉토리를 제공하는데 사용합니다.

 

📌 Custom Annotation

JUnit Jupiter의 어노테이션은 메타 어노테이션으로 사용할 수 있습니다.

예를 들어 @Tag("fast") 를 매번 사용하는 대신 @Fast로 대체할 수 있습니다.

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Tag("fast")
public @interface Fast {
}
@Fast
@Test
void myFastTest() {
    // ...
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Tag("fast")
@Test
public @interface FastTest {
}
@FastTest
void myFastTest() {
    // ...
}

 

📌 Assertions

class AssertionsDemo {
    private final Calculator calculator = new Calculator();
    private final Person person = new Person("Jane", "Doe");

	//assertEquals()와 assertTrue()를 이용한 단순 비교
    @Test
    void standardAssertions() {
        assertEquals(2, calculator.add(1, 1));
        assertEquals(4, calculator.multiply(2, 2),
                "추가적인 실패 메시지는 마지막 파라미터에 넣는다.");
        assertTrue('a' < 'b', () -> "Assertion 메시지는 불필요하게 메시지를 만드는 일을 피하기 위해 지연로딩과 비슷하게 동작한다.");
    }

	//assertAll()을 이용한 여러 개의 assertion
    @Test
    void groupedAssertions() {
        // In a grouped assertion all assertions are executed, and all
        // failures will be reported together.
        assertAll("person",
            () -> assertEquals("Jane", person.getFirstName()),
            () -> assertEquals("Doe", person.getLastName())
        );
    }

	//assertThrows()를 이용한 예외 확인
    @Test
    void exceptionTesting() {
        Exception exception = assertThrows(ArithmeticException.class, () ->
            calculator.divide(1, 0));
        assertEquals("/ by zero", exception.getMessage());
    }

	//assertTimeout()을 이용한 시간 초과 확인
    @Test
    void timeoutNotExceeded() {
        // The following assertion succeeds.
        assertTimeout(ofMinutes(2), () -> {
            // Perform task that takes less than 2 minutes.
        });
    }

    @Test
    void timeoutNotExceededWithResult() {
        // The following assertion succeeds, and returns the supplied object.
        String actualResult = assertTimeout(ofMinutes(2), () -> {
            return "a result";
        });
        assertEquals("a result", actualResult);
    }

    @Test
    void timeoutNotExceededWithMethod() {
        // The following assertion invokes a method reference and returns an object.
        String actualGreeting = assertTimeout(ofMinutes(2), AssertionsDemo::greeting);
        assertEquals("Hello, World!", actualGreeting);
    }

    @Test
    void timeoutExceeded() {
        // The following assertion fails with an error message similar to:
        // execution exceeded timeout of 10 ms by 91 ms
        assertTimeout(ofMillis(10), () -> {
            // Simulate task that takes more than 10 ms.
            Thread.sleep(100);
        });
    }

    @Test
    void timeoutExceededWithPreemptiveTermination() {
        // The following assertion fails with an error message similar to:
        // execution timed out after 10 ms
        assertTimeoutPreemptively(ofMillis(10), () -> {
            // Simulate task that takes more than 10 ms.
            new CountDownLatch(1).await();
        });
    }
}

JUnit Jupiter가 제공하는 assertion이 부족할 땐 AssertJ, Hamcrest, Truth 등의 라이브러리를 사용하는걸 추천드립니다.

 

📌 Assumptions

public class AssumptionTest {
    private final Calculator calculator = new Calculator();

    @Test
    void CI서버에서만_테스트() {
        assumeTrue("CI".equals(System.getenv("ENV")));
    }

    @Test
    void 개발환경에서만_테스트() {
        assumeTrue("DEV".equals(System.getenv("ENV")),
                () -> "Aborting test: not on developer workstation");
    }

    @Test
    void 모든환경_테스트() {
        assumingThat("CI".equals(System.getenv("ENV")), () -> {
            // CI 서버에서만 실행하는 테스트
            assertEquals(2, calculator.divide(4, 2));
        });
        // 모든 환경에서 실행하는 테스트
        assertEquals(42, calculator.multiply(6, 7));
    }
}

 

📌 조건부 테스트 실행

//운영체제 기반 조건부 실행
@Test
@EnabledOnOs(MAC)
void onlyOnMacOs() {
    // ...
}

@TestOnMac
void testOnMac() {
    // ...
}

@Test
@EnabledOnOs({ LINUX, MAC })
void onLinuxOrMac() {
    // ...
}

@Test
@DisabledOnOs(WINDOWS)
void notOnWindows() {
    // ...
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Test
@EnabledOnOs(MAC)
@interface TestOnMac {
}

//아키텍처 기반 조건부 실행
@Test
@EnabledOnOs(architectures = "aarch64")
void onAarch64() {
    // ...
}

@Test
@DisabledOnOs(architectures = "x86_64")
void notOnX86_64() {
    // ...
}

@Test
@EnabledOnOs(value = MAC, architectures = "aarch64")
void onNewMacs() {
    // ...
}

@Test
@DisabledOnOs(value = MAC, architectures = "aarch64")
void notOnNewMacs() {
    // ...
}

//자바 런타임 환경 조건
@Test
@EnabledOnJre(JAVA_8)
void onlyOnJava8() {
    // ...
}

@Test
@EnabledOnJre({ JAVA_9, JAVA_10 })
void onJava9Or10() {
    // ...
}

@Test
@EnabledForJreRange(min = JAVA_9, max = JAVA_11)
void fromJava9to11() {
    // ...
}

@Test
@EnabledForJreRange(min = JAVA_9)
void fromJava9toCurrentJavaFeatureNumber() {
    // ...
}

@Test
@EnabledForJreRange(max = JAVA_11)
void fromJava8To11() {
    // ...
}

@Test
@DisabledOnJre(JAVA_9)
void notOnJava9() {
    // ...
}

@Test
@DisabledForJreRange(min = JAVA_9, max = JAVA_11)
void notFromJava9to11() {
    // ...
}

@Test
@DisabledForJreRange(min = JAVA_9)
void notFromJava9toCurrentJavaFeatureNumber() {
    // ...
}

@Test
@DisabledForJreRange(max = JAVA_11)
void notFromJava8to11() {
    // ...
}

//시스템 속성 조건
@Test
@EnabledIfSystemProperty(named = "os.arch", matches = ".*64.*")
void onlyOn64BitArchitectures() {
    // ...
}

@Test
@DisabledIfSystemProperty(named = "ci-server", matches = "true")
void notOnCiServer() {
    // ...
}

//환경변수 조건
@Test
@EnabledIfEnvironmentVariable(named = "ENV", matches = "staging-server")
void onlyOnStagingServer() {
    // ...
}

@Test
@DisabledIfEnvironmentVariable(named = "ENV", matches = ".*development.*")
void notOnDeveloperWorkstation() {
    // ...
}

//사용자 정의 조건
@Test
@EnabledIf("customCondition")
void enabled() {
    // ...
}

@Test
@DisabledIf("customCondition")
void disabled() {
    // ...
}

boolean customCondition() {
    return true;
}

 

📄 Reference

https://wooaoe.tistory.com/33

https://mangkyu.tistory.com/182

https://velog.io/@sungjin0757/SPRING-TDDTest-Driven-Development

https://junit.org/junit5/docs/current/user-guide/

 

 

반응형