이번 글에서는 Java 진영에서 테스팅을 위해 사용되는 프레임워크인 JUnit을 만들어보겠습니다.
참고
프레임워크가 내가 작성한 코드를 제어하고, 대신 실행하면 그것은 프레임워크가 맞습니다(JUnit)
반면에 내가 작성한 코드가 직접 제어의 흐름을 담당하면 그것은 프레임워크가 아니라 라이브러리입니다.(스트림 라이브러리)
https://curlunit.sourceforge.net/doc/cookstour/cookstour.htm
JUnit A Cook's Tour를 참고해서 진행하겠습니다.
먼저 기본 개념인 TestCase를 나타내는 개체를 만들어야 합니다. 개발자는 종종 테스트 사례를 염두에 두고 있지만 다양한 방식으로 이를 실현합니다.
- print statements
- debugger expressions
- test scripts
테스트를 쉽게 조작하려면, 테스트를 객체로 만들어야 합니다. 이렇게 하면 개발자의 머릿속에만 있던 테스트가 구체화되어 시간이 지나도 그 가치를 유지하는 테스트를 만들겠다는 목표에 도움이 됩니다. 동시에 object developer는 객체로 개발하는 데 익숙하기 때문에 테스트를 객체로 만들기로 한 결정은 테스트 작성을 더 매력적으로(또는 덜 부담스럽게) 만들려는 우리의 목표에 도움이 됩니다.
FirstTestCase.java 를 생성하고 아래의 코드를 추가하겠습니다.
public class FirstTestCase {
public static void main(String[] args) {
new FirstTestCase().runTest();
}
public void runTest() {
int sum = 10 + 10;
Assert.assertTrue(sum == 20);
}
}
이 코드를 수행하기 위해 Assert 클래스와 static Method인 assertTrue를 생성하겠습니다.
@Slf4j
public class Assert {
public static void assertTrue(boolean condition) {
if (!condition) {
throw new AssertionFailedError();
}
log.info("Test Passed");
}
}
assertTrue 메서드는 boolean 값이 true이면 Test는 성공이며, false이면 테스트가 실패하는 메서드입니다.
AssertionFailedError 도 존재하지 않는 클래스이니 다음과 같이 만들어줍니다.
public class AssertionFailedError extends Error {
public AssertionFailedError() {}
}
FirstTestCase의 main 메서드를 실행하면, Test가 통과된 것을 확인할 수 있습니다.
현재 구조대로 진행하면, 테스트 마다 객체를 만들어야 합니다. 각각의 테스트 케이스 단위로 요청을 나눌 수 있는 구조가 되어야 합니다.

각각의 테스트 케이스를 Command로 보고, 이를 실행하는 것은 run 메서드가 담당하게 됩니다.

모든 TestCase는 이름을 가지고 생성되므로, 테스트가 실패하면 실패한 테스트를 식별할 수 있습니다.
public abstract class TestCase implements Test {
protected final String fName;
public TestCase(final String fName) {
this.fName = fName;
}
public abstract void run();
}
위 TestCase를 사용해서 FirstTestCase를 수정해보겠습니다.
@Slf4j
public class FirstTestCase extends TestCase {
public FirstTestCase(final String fName) {
super(fName);
}
public static void main(String[] args) {
new FirstTestCase("runTest").run(); // 각각의 테스트 케이스를 Command로 보고, 이를 실행하는 것은 run 메서드
}
public void runTest() {
int sum = 10 + 10;
Assert.assertTrue(sum == 20);
}
@Override
public void run() {
try {
Method method = this.getClass().getMethod(super.fName, null);
log.debug("super.fName= {}", super.fName);
method.invoke(this, null);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
FirstTestCase를 new로 인스턴스를 생성해서 사용하기 때문에 각각의 테스트는 독립적으로 실행될 수 있습니다.
다음과 같이 테스트가 잘 통과하는 것을 확인할 수 있습니다.
이제는 테스트 케이스 메서드들을 여러개를 생성하고, 실제 main 메서드에서는 해당 메서드들의 이름만 추가하면 테스트를 실행할 수 있습니다. runTestMinus() 라는 메서드를 추가해보겠습니다.
@Slf4j
public class FirstTestCase extends TestCase {
public FirstTestCase(final String fName) {
super(fName);
}
public static void main(String[] args) {
new FirstTestCase("runTest").run(); // 각각의 테스트 케이스를 Command로 보고, 이를 실행하는 것은 run 메서드
new FirstTestCase("runTestMinus").run();
}
public void runTest() {
int sum = 10 + 10;
Assert.assertTrue(sum == 20);
}
public void runTestMinus() {
int minus = 10 - 10;
Assert.assertTrue(minus == 0);
}
@Override
public void run() {
try {
Method method = this.getClass().getMethod(super.fName, null);
log.debug("{} execute", fName);
method.invoke(this, null);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
main 메서드에서 2개의 테스트 케이스를 실행할 때 생성자로 메서드 이름만 변경해 실행하는 것을 알 수 있습니다.
다음과 같이 2개의 테스트가 성공적으로 통과한 것을 알 수 있습니다.
다음과 같이 runTestFail 메서드로 실패하는 경우의 테스트를 작성하고,
@Slf4j
public class FirstTestCase extends TestCase {
public FirstTestCase(final String fName) {
super(fName);
}
public static void main(String[] args) {
new FirstTestCase("runTest").run(); // 각각의 테스트 케이스를 Command로 보고, 이를 실행하는 것은 run 메서드
new FirstTestCase("runTestMinus").run();
new FirstTestCase("runTestFail").run();
}
public void runTest() {
int sum = 10 + 10;
Assert.assertTrue(sum == 20);
}
public void runTestMinus() {
int minus = 10 - 10;
Assert.assertTrue(minus == 0);
}
public void runTestFail() {
int sum = 10 + 10;
Assert.assertTrue(sum == 30);
}
@Override
public void run() {
try {
Method method = this.getClass().getMethod(super.fName, null);
log.debug("{} execute", fName);
method.invoke(this, null);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
main 메서드를 실행시켜 확인해보면
메서드 invoke시 호출한 메서드 내에서 예외가 발생했을 때 해당 예외를 Wrapping한 예외 클래스인 InvocationTargetException이 보이고, 아래에 AssertionFailedError 가 발생한 것을 볼 수 있습니다.
run 메서드는 TestCase 클래스를 상속하는 모든 하위 클래스들이 공통으로 사용하므로, 부모인 TestCase가 가지도록 변경해보겠습니다.
@Slf4j
public abstract class TestCase implements Test {
protected final String fName;
public TestCase(final String fName) {
this.fName = fName;
}
public void run() {
try {
Method method = this.getClass().getMethod(fName, null);
log.debug("{} execute", fName);
method.invoke(this, null);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
(실패하는 테스트를 제외한)기존 테스트를 다시 실행시킨 결과는 다음과 같습니다.
테스트가 정상적으로 통과하는 것을 알 수 있습니다.
현재 JUnit A Cook's Tour에서 말하는 3.1단계까지가 완료된 것으로 볼 수 있습니다.
3.2단계로 들어가기 전에, 여기서 JUnit이 왜 Exception 대신 Error를 사용할까요?
테스트를 작성한 후, assertEquals로 runTestFail 과 비슷한 로직을 비교해보면 AssertionFailedError가 발생함을 알 수 있습니다.
오류(Error)는 시스템이 종료되어야 할 수준의 상황과 같이 수습할 수 없는 심각한 문제를 의미합니다.
예외(Exception)는 개발자가 구현한 로직에서 발생한 실수나 사용자의 영향에 의해 발생합니다.
JUnit은 테스트 메서드가 예외를 발생시키는지 여부를 테스트하는 기능도 제공합니다. 이 때, 테스트 메서드가 예외를 발생시키는 것은 테스트의 일부로 간주됩니다. Error를 사용하여 명시적으로 테스트 결과의 실패를 표시함으로써, 예외 발생과 일반적인 실패를 구분할 수 있습니다.
다음으로 해결해야 할 문제는 개발자에게 픽스처 코드와 테스트 코드를 넣을 수 있는 편리한 "place"(장소)를 제공하는 것입니다.
TestCase를 추상 클래스로 선언한 것은 개발자가 서브클래스를 통해 TestCase를 재사용할 수 있다는 것을 의미합니다. 하지만 변수가 하나만 있고 동작이 없는 슈퍼클래스만 제공한다면 테스트를 더 쉽게 작성할 수 있다는 첫 번째 목표를 달성하는 데 큰 도움이 되지 않을 것입니다.
픽스처 메서드의 경우, @BeforeEach, @AfterEach 와 같이 각각의 테스트 케이스들에게 공통적으로 수행되는 메서드를 의미합니다.
템플릿 메서드 패턴은 우리의 문제를 아주 잘 해결합니다.
템플릿 메서드를 사용하면 알고리즘의 구조를 변경하지 않고 알고리즘의 특정 단계를 재정의할 수 있습니다. 그러나 이 시퀀스의 실행은 픽스처 코드가 작성되거나 테스트 코드가 작성되는 방법에 관계없이 모든 테스트에 동일하게 유지됩니다.
다음과 같은 템플릿 메서드가 있습니다.
public void run() {
setUp();
runTest();
tearDown();
}
JUnit A Cook's Tour에서는 이러한 메서드의 기본 구현이 아무 작업도 수행하지 않는 것이라고 나와있습니다.
protected void runTest() {}
protected void setUp() {}
protected void tearDown() {}
추상 메서드로 구현할 경우 상속받는 클래스들에서 무조건 오버라이딩해야 하는데, 이 픽스처 메서드들은 선택사항이기 때문에 구현하지 않는 경우도 있기 때문입니다.
@Slf4j
public abstract class TestCase implements Test {
protected final String fName;
public TestCase(final String fName) {
this.fName = fName;
}
public void setUp() {}
public void run() {
setUp();
runTest();
tearDown();
}
public void runTest() {
try {
Method method = this.getClass().getMethod(fName, null);
log.debug("{} execute", fName);
method.invoke(this, null);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public void tearDown() {}
}
다음과 같이 run 메서드를 setUp() -> runTest() -> tearDown() 순서대로 실행되도록 변경하였습니다.
FirstTestCase에서 setUp 메서드를 오버라이드 한 후, 테스트 메서드 이름을 각각 runPlusTest, runMinusTest로 변경하였습니다.
@Slf4j
public class FirstTestCase extends TestCase {
private static int base;
public FirstTestCase(final String fName) {
super(fName);
}
@Override
public void setUp() {
base = 10;
log.info("setUp invoked");
}
public static void main(String[] args) {
new FirstTestCase("runPlusTest").run(); // 각각의 테스트 케이스를 Command로 보고, 이를 실행하는 것은 run 메서드
new FirstTestCase("runMinusTest").run();
}
public void runPlusTest() {
int sum = base + 10;
Assert.assertTrue(sum == 20);
}
public void runMinusTest() {
int minus = base - 10;
Assert.assertTrue(minus == 0);
}
}
테스트를 실행하면, 테스트 별로 setUp() 메서드가 호출되고 테스트가 수행된 후, 결과가 나오는 것을 알 수 있습니다.
현재까지 진행한 단계는 다음과 같습니다.
테스트가 실행된 후에는 작동한 것과 작동하지 않은 것에 대한 요약인 기록이 필요합니다.(TestResult) 우리는 실패와 성공에 대한 매우 압축된 요약만 기록하기를 원합니다.
SmallTalk Best Practice Patterns에는 적용할 수 있는 패턴이 있습니다. 이 패턴은 Collecting Parameter라고 불립니다. 이 패턴은 여러 메서드에 걸쳐 결과를 수집해야 할 때 메서드에 매개 변수를 추가하고 결과를 수집할 객체를 전달해야 한다고 제안합니다. 테스트 실행 결과를 수집하기 위해 TestResult라는 새 객체를 만듭니다.
@Slf4j
public class TestResult {
protected int fRunTests;
public TestResult() {
fRunTests= 0;
}
public void printCount() {
log.info("Total Test Count: {}", fRunTests);
}
}
이 간단한 버전의 TestResult는 실행된 테스트 수만 계산합니다. 이를 사용하려면 TestCase.run() 메서드에 매개변수를 추가하고 테스트가 실행 중임을 TestResult에 알려야 합니다.
public void run(TestResult result) {
result.startTest(this);
setUp();
runTest();
tearDown();
}
그리고 TestResult는 실행된 테스트 수를 추적해야 합니다.
public synchronized void startTest(Test test) {
fRunTests++;
}
하나의 TestResult를 여러 테스트 케이스에서 사용하게 될 경우 쓰레드 동기화 문제가 발생하므로 여기서는 synchronized로 간단하게 해결합니다. (테스트 케이스에서만 사용하므로 실시간 성능 이슈를 고려하지 않아도 되기 때문입니다.)
마지막으로 TestCase의 간단한 외부 인터페이스를 유지하고 싶기 때문에 자체 TestResult를 생성하는 매개변수 없는 run() 버전을 생성합니다. (TestCase 추상 클래스에)
@Slf4j
public abstract class TestCase implements Test {
protected final String fName;
public TestCase(final String fName) {
this.fName = fName;
}
public TestResult run() {
TestResult result = createResult();
run(result);
return result;
}
protected TestResult createResult() {
return new TestResult();
}
public void run(TestResult result) {
result.startTest(this);
setUp();
runTest();
tearDown();
}
public void setUp() {}
public void runTest() {
try {
Method method = this.getClass().getMethod(fName, null);
log.debug("{} executed", fName);
method.invoke(this, null);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public void tearDown() {}
}
다음과 같은 구조가 만들어졌습니다. 테스트를 실행하면 다음과 같은 결과를 만들어냅니다.
테스트가 항상 올바르게 실행된다면 테스트를 작성할 필요가 없습니다. 테스트는 실패할 때, 특히 실패할 것이라고 예상하지 못했을 때 흥미롭습니다. 또한 테스트는 잘못된 결과를 계산하는 등 우리가 예상한 방식으로 실패할 수도 있고, 배열의 범위를 벗어나는 등 멋진 방식으로 실패할 수도 있습니다. 테스트가 어떻게 실패하든 우리는 다음 테스트를 실행하고 싶습니다.
Junit은 Failures(실패) 와 errors(에러)를 구분합니다. 실패 가능성은 assertions를 통해 예상되고 확인됩니다. Errors는
ArrayIndexOutOfBoundsException와 같이 예상치 못한 문제입니다. 실패는 AssertionFailedError 오류로 표시됩니다. 예기치 못한 에러와 실패를 구분하기 위해 추가 catch(1) 절에서 실패를 포착합니다. 두 번째 절(2)은 다른 모든 예외를 포착하고 테스트 실행이 계속되도록 합니다.
public void run(TestResult result) {
result.startTest(this);
setUp();
try {
runTest();
}
catch (AssertionFailedError e) { // 1 실패 포착
result.addFailure(this, e);
}
catch (Throwable e) { // 2 다른 모든 예외 포착
result.addError(this, e);
}
finally {
tearDown();
}
}
TestResult에서는 다음과 같은 방법으로 error를 수집합니다.
@Slf4j
public class TestResult {
protected int fRunTests;
private List<TestFailure> failures;
private List<TestError> errors;
public TestResult() {
fRunTests = 0;
this.failures = new ArrayList<>();
this.errors = new ArrayList<>();
}
public synchronized void startTest(final Test test) {
fRunTests++;
}
public void printCount() {
log.info("=====================Test Result=====================");
log.info("Total Test Count: {}", fRunTests);
log.info("Total Test Success Count: {}", fRunTests - failures.size() - errors.size());
log.info("Total Test Failure Count: {}", failures.size());
log.info("Total Test Error Count: {}", errors.size());
}
public void addFailure(final TestCase testCase, final Throwable t) {
failures.add(new TestFailure(testCase, t));
}
public void addError(final TestCase testCase, final Throwable t) {
errors.add(new TestError(testCase, t));
}
}
TestResult는 테스트 실패와 에러 발생에 대한 처리 메서드들이 추가되었습니다.
printCount에서 전체 테스트 수, 성공한 테스트 수, 실패한 테스트 수, 에러가 발생한 테스트 수를 차례로 콘솔에 출력합니다.
TestFailure는 나중에 보고하기 위해 실패한 테스트와 signaled exception을 함께 묶은 little framework internal helper 클래스입니다.
public class TestFailure {
private Test fFailedTest;
protected Throwable fThrownException;
public TestFailure(final TestCase testCase, final Throwable t) {
this.fFailedTest = testCase;
this.fThrownException = t;
}
}
TestError 클래스의 구조는 다음과 같습니다.
public class TestError {
private Test fErrorTest;
protected Throwable fThrownException;
public TestError(final TestCase testCase, final Throwable t) {
this.fErrorTest = testCase;
this.fThrownException = t;
}
}
Collecting Parameter를 사용해서 리팩터링 한 FirstTestCase의 코드는 다음과 같습니다.
@Slf4j
public class FirstTestCase extends TestCase {
private static int base;
public FirstTestCase(final String fName) {
super(fName);
}
@Override
public void setUp() {
base = 10;
log.info("setUp executed");
}
public static void main(String[] args) {
TestResult testResult = new TestResult();
new FirstTestCase("runPlusTest").run(testResult);
new FirstTestCase("runMinusTest").run(testResult);
testResult.printCount();
}
public void runPlusTest() {
int sum = base + 10;
Assert.assertTrue(sum == 20);
}
public void runMinusTest() {
int minus = base - 10;
Assert.assertTrue(minus == 0);
}
}
Collecting parameter의 표준 형식은 각 메서드에 Collecting parameter 를 전달해야 합니다. 우리가 이 조언을 따른다면, 각 test method에는 TestResult에 대한 매개변수가 필요합니다. 이로 인해 메서드 시그니처가 "오염"됩니다.
signal failure에 대한 예외를 사용하면 이러한 시그니처 "오염"을 피할 수 있는 유익한 부작용이 있습니다.
테스트 케이스 메서드 또는 여기에서 호출된 helper method는 TestResult에 대해 알지 못해도 예외를 throw 할 수 있습니다.
public void testMoneyEquals() {
assert(!f12CHF.equals(null));
assertEquals(f12CHF, f12CHF);
assertEquals(f12CHF, new Money(12, "CHF"));
assert(!f12CHF.equals(f14CHF));
}
위 코드를 보면 testing method가 TestResult에 대해 알 필요가 없는 방법을 보여줍니다.
JUnit은 TestResult의 다양한 구현과 함께 제공됩니다. 기본 구현은 실패 및 오류 수를 계산하고 결과를 수집합니다.
TextTestResult는 결과를 수집하여 텍스트 형식으로 표시합니다. 마지막으로 UITestResult는 JUnit Test Runner의 그래픽 버전에서 그래픽 테스트 상태를 업데이트하는 데 사용됩니다.
JUnit A Cook's Tour에서는 여기서 다음 단계로 넘어가지만, TestCase에서 AssertionFailedError를 처리하는 부분을 리팩터링 한 후 넘어가도록 하겠습니다.
@Slf4j
public abstract class TestCase implements Test {
protected final String fName;
public TestCase(final String fName) {
this.fName = fName;
}
public TestResult run() {
TestResult result = createResult();
run(result);
return result;
}
protected TestResult createResult() {
return new TestResult();
}
public void run(TestResult result) {
result.startTest();
setUp();
try {
runTest();
}
catch (InvocationTargetException ite) {
if (isAssertionFailed(ite)) {
result.addFailure(this, ite);
} else {
result.addError(this, ite);
}
}
catch (Exception e) {
result.addError(this, e);
}
finally {
tearDown();
}
}
private boolean isAssertionFailed(final InvocationTargetException ite) {
return ite.getTargetException() instanceof AssertionFailedError;
}
public void setUp() {}
public void runTest() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
Method method = this.getClass().getMethod(fName, null);
log.info("{} executed", fName);
method.invoke(this, null);
}
public void tearDown() {}
}
runTest() 메서드에서 리플렉션을 사용하기 때문에 invoke 메서드가 발생시킬 수 있는 NoSuchMethodException, InvocationTargetException, IllegalAccessException을 Throw하도록 변경했습니다.
이 구조에 맞춰서 setUp(), runTest(), tearDown() 순서로 메서드 호출이 일어납니다.
위에서 설명한 것처럼 method.invoke 로 테스트 메서드를 실행할 때 Exception이 InvocationTargetException으로 랩핑되기 때문에 진짜 InvocationTargetException이 발생한 것인지, 아니면 AssertionFailedError가 발생했는데 InvocationTargetException으로 랩핑된것인지 알 수 없습니다. 따라서 isAssertionFailed 메서드를 통해 실제 그 안의 메서드가 AssertionFailedError인지 확인하도록 하였습니다.
그외 다른 Exception에선 모두 Error로 간주하고 처리됩니다.
FirstTestCase의 실행 결과는 다음과 같습니다.
TestSuite에 들어가기에 앞서, run() 메서드를 FirstTestCase.java 가 아닌 부모인 TestCase에서 실행하도록 리팩터링했는데 그와 관련된 디자인 패턴을 간단하게 알아보겠습니다.
문서에는 No stupid subclasses - TestCase again이라고 설명하고 있습니다.
모든 테스트 케이스는 동일한 클래스에서 서로 다른 메서드로 구현됩니다. 이렇게 하면 클래스의 불필요한 확산을 방지할 수 있습니다.
주어진 테스트 케이스 클래스는 각각 하나의 테스트 케이스를 정의하는 여러 가지 메서드를 구현할 수 있습니다. 각 테스트 케이스에는 testMoneyEquals 또는 testMoneyAdd와 같은 설명적인 이름이 있습니다. 테스트 케이스는 단순한 명령인 인터페이스를 따르지 않습니다.
동일한 Command 클래스의 다른 인스턴스는 다른 메서드를 사용하여 호출해야 합니다. 따라서 다음 문제는 테스트 호출자의 관점에서 모든 테스트 케이스가 동일하게 보이도록 만드는 것입니다.
이 문제를 해결하기 위해 디자인 패턴을 검토하다 보면 어댑터 패턴이 떠오릅니다.
어댑터는 "클래스의 인터페이스를 클라이언트가 기대하는 다른 인터페이스로 변환" 이라는 의도를 가지고 있습니다. 어댑터는 이를 위한 다양한 방법을 알려줍니다. 그 중 하나는 클래스 어댑터로, 서브클래싱을 사용해 인터페이스를 변환합니다.
public class TestMoneyEquals extends MoneyTest {
public TestMoneyEquals() { super("testMoneyEquals"); }
protected void runTest () { testMoneyEquals(); }
}
예를 들어 testMoneyEquals를 runTest에 adapt하기 위해 MoneyTest의 하위 클래스를 구현하고 runTest를 재정의하여 testMoneyEquals를 호출합니다.
서브클래싱을 사용하려면 각 테스트 케이스에 대해 서브클래스를 구현해야 합니다. 이는 테스터에게 추가적인 부담을 줍니다. 이는 프레임워크가 테스트 케이스를 가능한 한 간단하게 추가할 수 있도록 해야 한다는 JUnit의 목표에 위배됩니다. 또한 각 테스트 메서드에 대해 서브클래스를 생성하면 클래스가 부풀어 오르게 됩니다. 메서드가 하나만 있는 클래스는 비용 대비 가치가 없으며 의미 있는 이름을 짓기도 어렵습니다.
Java는 클래스 이름 지정 문제에 대한 흥미로운 Java 관련 솔루션을 제공하는 익명의 내부 클래스(anonymous inner class)를 제공합니다. 익명의 내부 클래스를 사용하면 클래스 이름을 발명하지 않고도 어댑터를 만들 수 있습니다.
TestCase test= new MoneyTest("testMoneyEquals ") {
protected void runTest() { testMoneyEquals(); }
};
Smalltalk Best Practice는 pluggable behavior 이라는 공통된 제목 아래 "서로 다른 인스턴스가 다르게 동작하는 문제"에 대한 또 다른 해결책을 설명합니다.
이 아이디어는 서브클래싱 없이도 다른 로직을 수행하도록 매개변수화할 수 있는 단일 클래스를 사용하는 것입니다.
JUnit은 pluggable selector를 사용하거나 anonymous adapter class를 구현할 수 있는 선택권을 클라이언트에게 제공합니다.
이를 위해 runTest 메서드의 기본 구현으로 pluggable selector를 제공합니다. 이 경우 테스트 케이스의 이름은 테스트 메서드의 이름과 일치해야 합니다. 아래 코드와 같이 리플렉션을 사용하여 메서드를 호출합니다.
protected void runTest() throws Throwable {
Method runMethod= null;
try {
runMethod= getClass().getMethod(fName, new Class[0]);
} catch (NoSuchMethodException e) {
assertTrue("Method \""+fName+"\" not found", false);
}
try {
runMethod.invoke(this, new Class[0]);
}
// catch InvocationTargetException and IllegalAccessException
}
먼저 Method 객체를 조회합니다. Method 객체를 찾으면 메서드를 호출하고 인수를 전달할 수 있습니다. 테스트 메서드는 인수를 받지 않으므로 빈 인자 배열을 전달할 수 있습니다.
JDK 1.1 리플렉션 API를 사용하면 public method만 찾을 수 있습니다. 이러한 이유로 테스트 메서드를 공개로 선언해야 합니다. 그렇지 않으면 NoSuchMethodException이 발생합니다.
현재까지의 진행 상황을 그림으로 표현하면 다음과 같습니다.
마지막으로 TestSuite를 적용해보겠습니다.
system의 상태에 대한 확신을 얻으려면 많은 테스트를 실행해야 합니다. 지금까지 JUnit은 단일 테스트 케이스를 실행하고 그 결과를 TestResult로 보고할 수 있습니다. 다음 과제는 다양한 테스트를 실행할 수 있도록 확장하는 것입니다. 테스트 호출자가 테스트 케이스를 하나 실행하든 여러 개 실행하든 신경 쓸 필요가 없다면 이 문제는 쉽게 해결할 수 있습니다.
쉽게 설명하자면, 개별 테스트케이스 단위로도 테스트는 수행할 수 있어야하며 그룹 단위로도 수행될 수 있어야 한다는 점입니다.
이러한 상황에서 자주 사용되는 패턴은 Composite입니다.
Composite 패턴의 구성요소는 아래와 같습니다.
- Component: 테스트와 상호작용하는데 사용할 인터페이스 (여기선 Test)
- Composite: Component 인터페이스를 구현하고 Leaf의 컬렉션을 관리 (여기선 TestSuite)
- Leaf: Component 인터페이스를 따르는 자식 (여기선 TestCase)
"개체를 트리 구조로 구성하여 부분-전체 계층을 나타냅니다."
Composite를 사용하면 클라이언트가 개별 개체와 compositions of objects를 균일하게 처리할 수 있습니다.
여기에서 부분-전체 계층 구조에 대한 요점이 중요합니다. 우리는 일련의 테스트 모음(test suite)을 지원하고자 합니다.
자바에서 Composite을 적용할 때는 추상 클래스가 아닌 인터페이스를 정의하는 것을 더 선호합니다.
인터페이스를 사용하면 Composite와 Leaf는 특정 클래스를 따르지 않아도 되고 인터페이스를 따르기만 하면 됩니다.
public interface Test { // Component
public abstract void run(TestResult result);
}
TestCase는 Composite의 Leaf에 해당하며 위에서 본 것처럼 이 인터페이스를 구현합니다.
Command 패턴에서 각각의 테스트 케이스가 Command이고, 이를 실행하는 것은 run 메서드라고 하였는데요.
마찬가지로 Composite 패턴에서 Component는 run() 메서드만 가지고 있으면 됩니다.
다음으로, 우리는 Composite 역할이자, 각각의 TestCase를 관리할 클래스를 생성하겠습니다.
해당 클래스의 이름은 TestSuite라고 하겠습니다.
public class TestSuite implements Test { // Composite
private final Vector fTests = new Vector();
@Override
public void run(final TestResult result) {
for (Enumeration e = fTests.elements(); e.hasMoreElements();) {
Test test = (Test)e.nextElement();
test.run(result);
}
}
// clients have to be able to add tests to a suite
public void addTest(Test test) {
fTests.addElement(test);
}
}
그림으로 살펴보면 다음과 같습니다.
TestCase와 TestSuite는 모두 Test 인터페이스를 따르기 때문에 재귀적으로 테스트 스위트를 구성할 수 있습니다.
모든 개발자는 자신만의 TestSuite을 만들 수 있습니다. suite로 구성된 TestSuite를 생성해서 모든 테스트 스위트를 실행할 수 있습니다.
테스트 코드를 TestSuite 단위로 변경하면 다음과 같습니다.
public static void main(String[] args) {
TestSuite testSuite = new TestSuite();
testSuite.addTest(new FirstTestCase("runPlusTest"));
testSuite.addTest(new FirstTestCase("runMinusTest"));
TestResult testResult = new TestResult();
testSuite.run(testResult);
testResult.printCount();
}
테스트 코드들이 TestSuite 안에 관리되어 실행되는 것을 알 수 있습니다.
이 방법은 잘 작동하지만, 모든 테스트를 수동으로 TestSuite에 추가해야 합니다. JUnit의 얼리 어답터들은 이것이 어리석다고 말했습니다.
새 테스트 케이스를 작성할 때마다 정적 suite() 메서드에 추가하는 것을 기억해야 하며, 그렇지 않으면 실행되지 않습니다.
JUnit A Cook's Tour에는 다음과 같은 클래스를 인수로 받는 편리한 생성자를 TestSuite에 추가하는 방법을 알려주고 있습니다.
이 생성자의 목적은 테스트 메서드를 추출하고 이를 포함하는 스위트를 생성하는 것입니다.
테스트 메서드는 접두사 "test"로 시작하고 인수를 받지 않는다는 간단한 규칙을 따라야 합니다. 편의 생성자는 이 규칙을 사용하여 테스트 메서드를 찾기 위해 리플렉션을 사용하여 테스트 객체를 구성합니다.
이 생성자를 사용하면 위의 코드를 다음과 같이 단순화됩니다.
public static Test suite() {
return new TestSuite(MoneyTest.class);
}
저는 JUnit과 비슷한 애노테이션 방식으로 진행해보겠습니다.
annotation 패키지에 Test 애노테이션을 생성합니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Test {
}
테스트하려는 메서드에 @Test를 붙이고, @Test가 붙은 메서드만 실행하도록 하겠습니다.
TestSuite를 다음과 같이 변경합니다.
package me.euichan.javap.livestudy.junit;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.Vector;
import org.springframework.core.annotation.AnnotationUtils;
public class TestSuite implements Test { // Composite
private final Vector fTests = new Vector();
public TestSuite(Class<? extends TestCase> testClass) {
Arrays.stream(testClass.getMethods())
.filter(m -> AnnotationUtils.findAnnotation(m, me.euichan.javap.livestudy.junit.annotation.Test.class) != null)
.forEach(m ->
{
try {
addTest(testClass.getConstructor(String.class).newInstance(m.getName()));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
);
}
@Override
public void run(final TestResult result) {
for (Enumeration e = fTests.elements(); e.hasMoreElements(); ) {
Test test = (Test)e.nextElement();
test.run(result);
}
}
// clients have to be able to add tests to a suite
public void addTest(Test test) {
fTests.addElement(test);
}
}
FirstTestCase에서는 테스트하고자하는 메서드에 간단하게 @Test 애노테이션만 붙여주면 테스트 실행이 가능합니다.
runFailTest() 라는 실패하는 테스트도 넣어서 실행해보겠습니다.
@Slf4j
public class FirstTestCase extends TestCase {
private static int base;
public FirstTestCase(final String fName) {
super(fName);
}
public static TestSuite suite() {
return new TestSuite(FirstTestCase.class);
}
@Override
public void setUp() {
base = 10;
log.info("setUp executed");
}
public static void main(String[] args) {
TestSuite testSuite = FirstTestCase.suite();
TestResult testResult = new TestResult();
testSuite.run(testResult);
testResult.printCount();
}
@Test
public void runPlusTest() {
int sum = base + 10;
Assert.assertTrue(sum == 20);
}
@Test
public void runMinusTest() {
int minus = base - 10;
Assert.assertTrue(minus == 0);
}
@Test
public void runFailTest() {
int sum = base + 10;
Assert.assertTrue(sum == 30);
}
}
다음과 같이 정상적으로 결과가 잘 나옴을 확인할 수 있습니다.
다음 그림은 패턴으로 설명된 JUnit의 디자인을 한눈에 보여줍니다.
프레임워크의 중심 추상화인 TestCase가 네 가지 패턴에 어떻게 관여하는지 살펴보세요.
다음은 JUnit의 모든 패턴을 살펴보는 또 다른 방법입니다. 이 스토리보드에서는 각 패턴의 효과를 차례로 추상적으로 표현한 것을 볼 수 있습니다. 예를 들어 Command 패턴은 TestCase 클래스를 생성하고 템플릿 메서드 패턴은 실행 메서드를 생성하는 식입니다.
'Test' 카테고리의 다른 글
@WebMvcTest를 사용하지 않는 컨트롤러 테스트 작성하기 (0) | 2024.07.29 |
---|