BackEnd/TDD

[TDD] JUnit 만들기 - 2

샤아이인 2022. 7. 15.

해당 글은 TDD: By Example 책의 2부 내용인 Python으로 xUnit 만들기를 Java 코드로 변경하여 스스로 만든 내용입니다.

총 2개의 글로 작성될 예정입니다.

1. Junit 만들기 1부

2. Junit 만들기 2부

 

코드 또한 Github에 올려두었으니 확인 가능하십니다!

https://github.com/zbqmgldjfh/xUnit

 

GitHub - zbqmgldjfh/xUnit: JUnit 직접 구현하기

JUnit 직접 구현하기. Contribute to zbqmgldjfh/xUnit development by creating an account on GitHub.

github.com

 

이전까지 작성한 TODO list를 보면 다음과 같다.

- [x] 테스트 메서드 호출하기
- [x] Assert 만들기
- [x] 먼저 setUp 호출하기
- [x] 나중에 tearDown 호출하기
- [ ] 수집된 결과를 출력하기

todo list에 몇 가지 추가할 내용이 생겼습니다만... 뒤에서 추가하죠!

 

6. 수집된 결과를 출력하기

이번에는 Test의 결과를 담아서 확인할 수 있도록 하는 기능을 만들어봅시다!

 

테스트 코드를 수행하면 전체 테스트 케이스 몇 개가 수행되었으며, 이들 중 몇 개가 테스트가 실패하고 몇 개가 성공했는지에 대한 정보를 제공할 것입니다.

 

테스트의 결과는 TestResult라는 class에 저장하도록 하겠습니다!

 

TestCaseTest에 다음과 같이 testResult()를 검증하는 테스트 코드를 작성하였다.

테스트의 결과로 1개를 실행했고, 0개가 실패했다는 결과를 얻고 싶습니다!

 

아직 class가 없기 때문에 컴파일 오류가 발생하는군요? 우선 컴파일 오류부터 해결해봅시다!

public class TestResult {

    public String getSummary() {
        return "";
    }
}

우선 위 코드로 컴파일 오류를 해결해도, 다음과 같이 에러가 발생합니다.

test의 run 메서드는 void를 반환하기 때문이죠! 이를 TestResult를 반환하도록 변경해 줍시다.

public abstract class TestCase {
    // 생략...

    public TestResult run() {
        setUp();
        runTestCase();
        tearDown();
        return new TestResult();
    }
    // 생략...
}

이후 테스트를 실행시키면 실패하게 됩니다. 우리의 TestResult()에 ""(빈 문자열)이 저장되어 있기 때문이죠!

기대하는 값은 "1 run, 0 failed" 입니다!

 

이를 해결하기 위해 켄트 백은 일종의 "악행"을 저지르게 됩니다. 예를 들면 다음과 같이 말이죠!

public class TestResult {

    public String getSummary() {
        return "1 run, 0 failed";
    }
}

테스트는 당연히 성공하게 됩니다 ㅎㅎ... 이런 식으로 구현해도 되냐고요? 예 됩니다 ㅎㅎ

 

일단 성공했으니 좀 더 refactoring을 해봅시다.

runCount를 하나 만들어 테스트가 실행될 때마다 +1을 해주면 어떨까요? 다음과 같이 말이죠!

public class TestResult {

    private int runCount = 0;

    public void testStarted() {
        runCount++; 
    }

    public String getSummary() {
        return runCount + " run, 0 failed";
    }
}

이후 TestCase에서 다음과 같이 사용하게 됩니다.

public abstract class TestCase {
    // 생략...

    public TestResult run() {
        TestResult testResult = new TestResult();
        testResult.testStarted();
        setUp();
        runTestCase();
        tearDown();
        return testResult;
    }

    // 생략...
}

테스트는 여전히 성공하며, 더 이상 고정된 횟수 1이 아닌, testStarted()를 호출한 만큼, 즉 테스트가 실행된 수만큼 출력하게 됩니다.

 

이번에는 실패하는 테스트 코드를 작성해 봅시다.

public void testFailedResult() {
    WasRun test = new WasRun("testFailedMethod");
    TestResult testResult = test.run();
    Assert.assertEquals(testResult.getSummary(), "1 run, 1 failed");
}

WasRun Class에 testFailedMethod()를 만들었습니다.

public class WasRun extends TestCase {

    // 생략...

    public void testFailedMethod() {
        throw new AssertionError();
    }
}

호출 시 바로 예외를 던지도록 만들었습니다!

 

실패하는 횟수 또한 동적으로 출력해야 하기 때문에, 다음과 같이 TestResult에 failCount를 추가해 줍니다.

public class TestResult {

    private int runCount = 0;
    private int failCount = 0;

    public void testStarted() {
        runCount++;
    }

    public void testFailed() {
        failCount++;
    }

    public String getSummary() {
        return runCount + " run, " + failCount + " failed";
    }
}

테스트 코드를 실행시키면 아직 테스트에 실패하게 됩니다.

예외가 발생했을 때 별도의 처리를 하고 있지 않기 때문에 StackTrace만 출력해버리고 끝나버리죠...

예외를 잡아야 failCount에 +1을 하고 다음 테스트를 진행하게 될 것 입니다.

 

또한 우리는 "1 run, 1 failed"와 같이 좀 더 이쁜 출력을 받고 싶습니다.

 

TestCase class에서 예외를 잡도록 코드를 변경해보시죠!

public abstract class TestCase {

    // 일부 생략...

    public TestResult run() {
        TestResult testResult = new TestResult();
        testResult.testStarted();
        setUp();
        runTestCase(testResult);
        tearDown();
        return testResult;
    }

    private void runTestCase(TestResult testResult) {
        try {
            Method method = getClass().getMethod(name);
            method.invoke(this);
        } catch (Exception e) {
            testResult.testFailed(); // 테스트 실패시 failCount +1
        }
    }
}

예외가 발생할 경우 TestResult의 failCount를 1 증가시키게 된다. 예외를 catch하기 때문에, 다음 test를 진행할 수 있습니다.

 

테스트를 실행해보니 정상적으로 통과되는 것을 확인할 수 있습니다.

public class XUnitTest {

    public static void main(String[] args) {
        System.out.println(new TestCaseTest("testTemplateMethod").run().getSummary());
        System.out.println(new TestCaseTest("testResult").run().getSummary());
        System.out.println(new TestCaseTest("testFailedResultFormatting").run().getSummary());
        System.out.println(new TestCaseTest("testFailedResult").run().getSummary());
    }
}

실행 결과는 다음과 같다.

하지만 문제가 있는데... 결과가 종합되어 나오는 것 이 아닌, 개별 case 결과가 나오고 있다.

우리는 모든 case에 대한 결과를 모두 총합한 결과를 받고 싶다.

예를 들어 "4 run, 0 failed"처럼 말이다.

 

Test Suite

 

test suite 키워드가 이때 등장한다!

 

7. TestSuite

지금까지 만든 코드에서는 각각의 테스트만 수행했습니다.

실제 Main 메서드에서는 각각의 TestCase가 한 묶음의 테스트인지, 개별 테스트인지 전혀 구분할 수 없습니다.

따라서 직전에 말했듯, TestSuite를 이용하여 개별 테스트들을 그룹화하고, 그룹 단위로 실행할 수 있어야 합니다.

 

이때 적용시키는 디자인 패턴이 composite 패턴입니다.

 

모든 테스트의 결과를 한 번에 받을 수 있는 테스트 코드를 작성해 보자.

여러 곳에서 컴파일 에러가 발생하고 있다. 컴파일 에러부터 해결해보자.

 

다음과 같이 TestSuite를 빠르게 만들고 실행해보면, 우선 실행은 된다.

public class TestSuite {

    public void add(WasRun method) {

    }

    public TestResult run() {
        return null;
    }
}

"악행"을 행하여 일단 실행은 되도록 만들 것이다... ㅎㅎ

 

이쯤 생각해볼 문제가 하나 있다.

지금 까지는 Test 하나당 하나의 TestResult를 반환한다 생각하였다. 하지만 이렇게 반환된 TestResult를 하나의 TestSuite에서 합치기가 생각보다 힘들다.

 

이를 어떻게 해결해야 할까? 이때 도움을 받을 수 있는 방식이 있다!

 

Collecting Parameter

 

여러 테스트 메서드들, Fixture 메소드 등으로 결과와 관련된 데이터들을 다 수집하기에 적절한 패턴은 Collecting Parameter 패턴입니다!

파라미터를 여러 메서드에 전달하면서 결과를 그 안에 누적시키는 방식이다.

외부에서 TestResult 객체를 만들어서 test.run()의 인자로 전달할 것이다.

public class TestSuite {

    private List<TestCase> tests = new ArrayList<>();

    public void add(TestCase test) {
        tests.add(test);
    }

    public void run(TestResult testResult) {
        tests.forEach(t -> t.run(testResult));
    }
}

모든 test들을 수행하면서 결과를 testResult 객체에 누적하게 됩니다!

 

TestCase 또한 이를 사용하도록 코드를 변경해야 한다.

public abstract class TestCase {
    protected final String name;

    public TestCase(String name) {
        this.name = name;
    }

    // 인자로 TestResult를 전달받음
    public void run(TestResult testResult) {
        testResult.testStarted();
        setUp();
        runTestCase(testResult);
        tearDown();
    }

    public void setUp() {}

    private void runTestCase(TestResult testResult) {
        try {
            Method method = getClass().getMethod(name);
            method.invoke(this);
        } catch (Exception e) {
            testResult.testFailed();
        }
    }

    public void tearDown() {}
}

 

이를 사용하는 테스트 코드는 다음과 같습니다.

public class TestCaseTest extends TestCase {

    public TestCaseTest(String name) {
        super(name);
    }
    
    // 생략...

    public void testSuite() {
        TestSuite testSuite = new TestSuite();
        testSuite.add(new WasRun("testMethod"));
        testSuite.add(new WasRun("testFailedMethod"));
        TestResult testResult = new TestResult(); // 외부에서 Collecting Parameter 생성
        testSuite.run(testResult);
        Assert.assertEquals(testResult.getSummary(), "2 run, 1 failed");
    }
}

 

이후 Main 코드에서 다음과 같이 사용하면 된다.

public class XUnitTest {

    public static void main(String[] args) {
        // testSuite 생성
        TestSuite testSuite = new TestSuite();
        
        // 테스트 추가
        testSuite.add(new TestCaseTest("testTemplateMethod"));
        testSuite.add(new TestCaseTest("testResult"));
        testSuite.add(new TestCaseTest("testFailedResultFormatting"));
        testSuite.add(new TestCaseTest("testFailedResult"));
        testSuite.add(new TestCaseTest("testSuite"));

        // collectable parameter 생성
        TestResult testResult = new TestResult();
        
        // testSuite에게 전달
        testSuite.run(testResult);
        
        // 종합 결과 출력
        System.out.println("[TEST RESULT] = " + testResult.getSummary());
    }
}

TestSuite에 여러 테스트를 추가하고, 한 번에 실행하고 결과받아, 종합적으로 보여주게 되었습니다!

지금까지 적용된 모습을 보면 다음과 같습니다.

출처 -&nbsp;http://junit.sourceforge.net/doc/cookstour/cookstour.htm

- [x] 테스트 메서드 호출하기
- [x] Assert 만들기
- [x] 먼저 setUp 호출하기
- [x] 나중에 tearDown 호출하기
- [x] 수집된 결과를 출력하기

 

8. Composite Pattern 적용하기

우리가 테스트 메서드를 많이 가지고 있는 TestCase를 상속한 class를 만들면,

관례적으로 해당 class 안에 static 메서드로 모든 test case를 다 suite에 담아서 반환하도록 할수 있습니다.

public class TestCaseTest extends TestCase {

    public TestCaseTest(String name) {
        super(name);
    }

    public static TestSuite suite() {
        TestSuite testSuite = new TestSuite();
        testSuite.add(new TestCaseTest("testTemplateMethod"));
        testSuite.add(new TestCaseTest("testResult"));
        testSuite.add(new TestCaseTest("testFailedResultFormatting"));
        testSuite.add(new TestCaseTest("testFailedResult"));
        testSuite.add(new TestCaseTest("testSuite"));
        return testSuite;
    }
    // 생략...
}

이를 사용하는 main 메서드에서, 어떤 Test class를 하나 만들고 그 안에 있는 모든 테스트를 실행하고 싶으면

다음과 같이 작성할 수가 있다.

public class XUnitTest {

    public static void main(String[] args) {
        TestSuite testSuite = TestCaseTest.suite();
        TestResult testResult = new TestResult();
        testSuite.run(testResult);
        System.out.println("[TEST RESULT] = " + testResult.getSummary());
    }
}

위 코드는 일종의 Runner 역할을 하는 bootstrap 코드가 된다.

 

위에서 본 그림을 다시 한번 살펴보자.

출처 -&nbsp;http://junit.sourceforge.net/doc/cookstour/cookstour.htm

TestCase와 TestSuite은 매우 닮은 모양을 하고 있다.

 

TestSuite은 여러 TestCase를 모아둔 1급 컬렉션과 유사하다.

여기서 TestSuite을 개선하면, 단순히 TestCase만 담을 수 있는 것이 아니라 또 다른 TestSuite을 담을 수도 있다.

 

우리는 지금 테스트 하나와 테스트 집단을 동일하게 다루고 싶습니다 - 켄트 벡

 

이렇게 개선하면 테스트를 한 번에 실행하기가 매우 편해지는 것이다.

위 코드는 컴파일 에러가 발생하고 있다.

TestSuite도 일종의 TestCase로 인식이 돼야 suite에 원소로 추가할 수가 있다.

(위 코드가 다소 복잡해보이는데, 간단하게 TestCase, TestSuite 모두를 Suite에 추가하는 코드이다)

 

이를 위해 TestCase와 TestSuite이 공통으로 삼는 Type을 추가하는 것이다. 

공통 Type으로 Test라는 Interface를 만들어봅시다!

public interface Test {
    void run(TestResult testResult);
}

TestCase와 TestSuite 각각이 Test 인터페이스를 구현하도록 다음과 같이 만들어봅시다!

 

▶ TestSuite

public class TestSuite implements Test {

    private List<Test> tests = new ArrayList<>();

    public void add(Test test) { // test를 인자로 받도록 변경
        tests.add(test);
    }

    public void run(TestResult testResult) {
        tests.forEach(t -> t.run(testResult));
    }
}

 

▶ TestCase

public abstract class TestCase implements Test {
    protected final String name;

    public TestCase(String name) {
        this.name = name;
    }
    
    // 생략...
}

이렇게 변경하면 위에서의 컴파일 오류가 해결된다.

 

테스트 실행 시 다음과 같은 결과가 나오게 됩니다!

테스트가 성공한것을 확인 할 수 있습니다!!

 

다음 내용을 책에는 없는 내용이다.

 

9. Test 코드 추가 자동화

이번에는 소문자 "test"로 시작하는 메서드는 새로운 TestCase로 만들어 자동 등록되도록 기능을 만들어보자

 

TestSuite class의 생성자에 Test Class 정보를 다음과 같이 주면,

해당 Test Class 내부의 "test"로 시작하는 모든 메서드를 TestSuite에 추가를 해주고 싶다.

TestSuite의 생성자 인자로 scan 할 대상 Class를 지정해준다.

 

TestSuite의 생성자 코드는 다음과 같습니다ㅎㅎ 한번 살펴볼까요?

public class TestSuite implements Test {

    private List<Test> tests = new ArrayList<>();

    public TestSuite() {}

    public TestSuite(Class<? extends TestCase> testClass) {
        Arrays.stream(testClass.getMethods())
                .filter(m -> m.getName().startsWith("test"))
                .forEach(m ->
                        {
                            try {
                                TestCase testCase = testClass.getConstructor(String.class).newInstance(m.getName());
                                tests.add(testCase);
                            } catch (Exception e) {
                                throw new RuntimeException(e);
                            }
                        }
                );
    }
}

생성자로 받은 testClass Class를 통해 getMethods()로 모든 Public 메서드를 가져옵니다.

Arrays.stream(testClass.getMethods())

 

이후 filter를 통해 "test"로 시작되는 메서드만 거릅니다.

.filter(m -> m.getName().startsWith("test"))

 

이후 걸러진 메서드 또한 Reflection을 통해 newInstance()를 호출하여 생성합니다.

.forEach(m ->
        {
            try {
                TestCase testCase = testClass.getConstructor(String.class).newInstance(m.getName());
                tests.add(testCase);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
);

 

이렇게 생성된 Test 메서드를 tests라는 컬렉션에 추가하게 됩니다.

테스트 코드 실행 시 정상적으로 테스트가 통과하는 것 을 확인할 수 있습니다!!

 

지금까지 긴 글 읽어주셔서 감사합니다 ㅎㅎ!!

 

10. 정리

지금 까지 구현한 모습을 보면 다음과 같습니다.

출처 - http://junit.sourceforge.net/doc/cookstour/cookstour.htm

여기서 설명 안한 부분이 하나 있는데, PluggableSelector & Adapter 패턴은 초반에 생성한 Method.invoke 부분이라고 보시면 됩니다.

 

또한 추가로 남겨둘만한 예제가 있는데, 우리의 테스트 프레임워크는 setUp()에서 예외가 발생할 경우 이를 컨트롤하지 못하고 있습니다.

이에 대한 구현은 나중에 해보도록 하겠습니다...(켄트 벡도 나중에 한다 했으니까? ㅎㅎㅎ...)

 

거의 3일을 온종일 책과 코드작성에 시간을 투자한 것 같습니다.

Python 코드에 약하기 때문에 어려움이 많았으며, 다행이 향로님의 블로그 글이 있어 도움을 많이 받으며 작성하였습니다.

또한 책에 없는 test로 시작하는 부분을 자동으로 등록하는 기능은 어느 한 블로그에서 확인했었던 기능인데... 기억안남....

 

여튼 이 글을 읽는 누군가에게도 많은 도움이 되길 기원합니다!

 

출처

http://junit.sourceforge.net/doc/cookstour/cookstour.htm

 

JUnit: A Cook뭩 Tour

JUnit A Cook's Tour Note: this article is based on JUnit 3.8.x. 1. Introduction In an earlier article (see Test Infected: Programmers Love Writing Tests, Java Report, July 1998, Volume 3, Number 7), we described how to use a simple framework to write repea

junit.sourceforge.net

https://jojoldu.tistory.com/231

 

JUnit 만들어보기

안녕하세요? 이번 시간엔 JUnit을 직접 만들어보는 시간을 가지려고 합니다. 모든 코드는 Github에 있기 때문에 함께 보시면 더 이해하기 쉬우실 것 같습니다. (공부한 내용을 정리하는 Github와 세미

jojoldu.tistory.com

 

댓글