BackEnd/TDD

[TDD] JUnit 만들기 - 1

샤아이인 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

 

예전에 켄트 백의 TDD를 읽다 쳅터 2에서 막힌 적이 있다.

애당초 코드가 Python이라 가독성이 나에게는 너무나 떨어졌다. 그렇게 시간이 어느 정도 지난 후?

다시 한번 이 책을 읽어볼 시간이 생겼다. 하지만 이번에는 2 쳅터를 단순하게 읽는 것이 아닌, 직접 구현을 해야겠다는 생각이 들었다.

 

향로님 블로그에서도 그렇고, Toby님의 아주 예전 페이스북 글에서도 그렇고,

기회가 된다면 꼭 만들어보기를 추천한다 하셨다. 또한 애당초 이걸 직접 실천해보는 개발자가 매우 적다 하셨으니...

내가 그 몇 안 되는 개발자가 되야겠다는 생각이 실천하였다.

 


우선 맨 처음 떠오른 테스트 프레임 워크에 대한 TODO list는 다음과 같다.

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

 

1. 테스트 메서드 호출하기

우리가 작성할 최초의 원시 테스트는 테스트 메서드가 호출되면 true, 아니면 false를 반환하도록 할 것입니다.

우선 어떤 Class가 하나 있어야 하고, Class의 메서드가 실행이 됐는지를 확인할 수 있는 테스트를 먼저 만들어봅시다.

 

아, 그전에 잠시 구현할 최종적인 package 구조를 보면 다음과 같습니다! 참고 정도만 하시길 권장합니다 ㅎㅎ

 

간단하게 테스트 코드를 작성해봅시다!

 

켄트 벡 - 우리의 첫 원시테스트는 테스트 메서드가 호출되면 true, 그렇지 않으면 false를 반환하는 작은 프로그램이 필요하다.


src/test/java에 XUnitTest.java를 생성하고 아래의 코드를 추가하겠습니다.

public class XUnitTest {

    public static void main(String[] args) {
        WasRun test = new WasRun();
        System.out.println("test.wasRun() = " + test.wasRun);
        test.testMethod();
        System.out.println("test.wasRun() = " + test.wasRun);
    }
}

아직 WasRun과 같은 Class가 존재하지 않기 때문에 컴파일 오류가 발생합니다.

(메서드가 실행되었는지 알려주는 테스트케이스 이므로 Class의 이름을 WasRun으로 하자!)

 

일단은 급하게 컴파일 오류만 해결해주고,

public class WasRun {

    public boolean wasRun;

    public void testMethod() {}
}

테스트를 실행해 봅시다.

테스트에 실패했습니다! 당연한 결과죠!

우리는 컴파일 오류만 해결했지, 어떠한 구현을 한 적이 없거든요!

 

다음과 같이 코드를 변경해봅시다.

public class WasRun {

    public boolean wasRun;

    public void testMethod() {
        this.wasRun = true;
    }
}

이후 다시 main메서드를 실행해 보면 다음과 같은 결과를 얻을 수 있게 되었습니다!

테스트가 성공하였군요! (가상의)초록막대가 보이는군요!

 

이제 약간의 refactoring을 진행할 단계인 것 같습니다. 

우선 testMethod()를 직접 호출하는 대신, run()을 호출하도록 바꿔봅시다!

우선 비어있는 run 메서드를 만들어 컴파일 오류부터 해결합시다. 

즉 우리는 run()이라는 메서드를 호출하면, 지정한 테스트 메서드가 실행이 됐으면 좋겠다.

 

그럼 어느 테스트 메서드가 실행되도록 할 것 인가?

 

다음과 같이 WasRun() 객체를 생성할 때, 실행할 테스트 메서드의 이름을 전달하도록 하죠~

public class XUnitTest {

    public static void main(String[] args) {
        WasRun test = new WasRun("testMethod"); // 실행할 테스트 메서드의 이름 전달
        System.out.println("test.wasRun() = " + test.wasRun);
        test.run();
        System.out.println("test.wasRun() = " + test.wasRun);
    }
}

테스트를 다시 실행해보니 실패 결과가 나왔습니다.

 

이제 다시 구현을 해야겠죠?

public class WasRun {

    private final String name;
    public boolean wasRun;

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

    public void testMethod() {
        this.wasRun = true;
    }

    public void run() {

    }
}

우선 WasRun()의 생성자를 통해 실행할 testMethod의 이름을 전달받도록 변경하였습니다.

 

다음으로는 조금 어려운 부분일 수도 있는데,

1) Reflection을 이용하여 생성자로 전달받았던 testMethod로 해당 테스트 메서드를 찾아옵니다.

2) 찾아온 method의 invoke를 호출하여 메서드를 실행시킵니다.

3) invoke는 실제로 실행시킬 Object가 필요하기 때문에 invoke(this)처럼, 인자로 자기 자신을 전달합니다.

public void run() {
    try {
        Method method = getClass().getMethod(name);
        method.invoke(this);
    } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
        throw new RuntimeException(e);
    }
}

위에서 리플렉션으로 가져온 method는 "testMethod()"에 해당되는데, testMethod는 WasRun class 의 메서드 이다.

따라서 invoke(this) 즉 invoke(WasRun 객체)를 통해 호출한 것 이다. 

 

다시 main 메서드에 가서 테스트를 실행해 봅시다.

결과는 다음과 같습니다.

테스트에 성공하게 되었습니다.

 

이제 우리의 작은 WasRun class는 독립된 두가지 일을 수행합니다.

1) 메서드가 호출됬는지, 아닌지를 기억하는 일

2) 메서드를 동적으로 호출하는 일

 

2. TestCase

현재 구조는 아직 테스트 프레임워크라고 할 수는 없습니다.
결국 각각의 테스트 케이스 단위로 요청을 나눌 수 있는 구조가 되어야 합니다.
이런 의도에 가장 어울리는 것이 커맨드 패턴입니다.

 

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

 

즉, 하나의 WasRun이라는 특정 case가 아닌, 좀 더 일반화를 시켜야 하는 것입니다.

공통적인 내용들을 뽑아서 추상화시키는 것 이죠!

 

Intellij의 도움을 좀 받아봅시다!

run 메서드 이름에다가 우클릭을 하여, Refactor -> Extract Superclass를 통해 추출합시다.

 

이후 다음과 같이 name, run을 추출해 줍시다. Superclass의 이름으로는 TestCase를 지정해줬습니다.

name 속성을 상위 class로 추출할 것 이며, run 메서드 또한 상위 class의 name 속성만을 사용하기 때문에 추출해 줍시다!

 

추출된 TestCase의 코드는 다음과 같다.

public abstract class TestCase {
    protected final String name;

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

    public void run() {
        try {
            Method method = getClass().getMethod(name);
            method.invoke(this);
        } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
            throw new RuntimeException(e);
        }
    }
}

생성자에 테스트할 메서드의 이름을 넣고, 실행하면, 해당 이름의 메서드를 실행시켜 줍니다!

즉, 위에서 언급한 커맨드 패턴이 자연스럽게 적용된 것입니다.

 

TestCase는 그 자체로 사용하기보다는, 이를 상속한 실제 테스트 케이스 클래스들을 사용할 것이기 때문에 abstract class로 만들었습니다!

 

기존의 WasRun이라는 class는 다음과 같이 TestCase를 상속하고 있게 된는거죠!

public class WasRun extends TestCase {

    public boolean wasRun;

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

    public void testMethod() {
        this.wasRun = true;
    }

}

 

지금 까지 우리는 테스트 코드를 작성하였고 (main 메서드 안에), 테스트 코드가 검증할 대상인 WasRun이라는 일종의 어플리케이션 코드를 만들었다.

 

처음 main 메서드에 만든 기초적인 테스트 코드 또한 우리가 만든 간단한 프레임워크에 적용시킬 수가 있다.

따라서 방금 만든 TestCase를 상속하도록 다음과 같이 만들어보자.

public class TestCaseTest extends TestCase {
    public TestCaseTest(String name) {
        super(name);
    }

    // 하나의 Test해야 하는 코드라고 생각하자
    public void testRunning() {
        WasRun test = new WasRun("testMethod");
        System.out.println("test.wasRun() = " + test.wasRun);
        test.run();
        System.out.println("test.wasRun() = " + test.wasRun);
    }
}

위 testRunning 테스트는, WasRun 객체인 test의 run()메서드가 정상적으로 호출되는지를 검증한다.

 

위에 만든 TestCaseTest를 다음과 같이 main에서 실행하도록 코드를 작성하자.

public class XUnitTest {

    public static void main(String[] args) {
        new TestCaseTest("testRunning").run();
    }
}

우리가 맨 처음 만든 BootStrap 코드조차 우리가 만든 프레임워크를 통해 실행하도록 refactoring 하였습니다!

(뭐랄까 테스트 코드로 테스트 코드를 검증하고 있는 상황이랄까?)

 

왜 TestCase의 구현체인 TestCaseTest를 new로 인스턴스 생성한 것 일까?

 

모든 테스트는 서로 독립적이어야 하니까!!

 

따라서 하나의 TestCaseTest에다 여러 테스트 메서드를 작성하더라도, 모든 테스트 케이스는 Object를 만들어 독립적으로 실행시켜야만 합니다!

 

지금까지의 상황을 살펴보면 다음과 같습니다~

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

 

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

 

3. Assert 만들기

기존의 Test 코드에서는 테스트가 성공했는지 출력문을 통해 확인하고 있었다.

이를 다음과 같이 Assert문을 통해 확인할 수 있다면 얼마나 좋을까?

아직 Assert class가 없기 때문에 컴파일 오류가 발생하고 있다. 이를 구현해 보자.

public class Assert {

    private static final Logger logger = LoggerFactory.getLogger(Assert.class);

    public static void assertEquals(Object actual, Object expected) {
        if(!actual.equals(expected)) {
            logger.info("Test Failed : expected <" + expected + "> but was <" + actual + ">");
            throw new AssertionError("expected <" + expected + "> but was <" + actual + ">");
        }
        logger.info("Test Passed");
    }
}

테스트 메서드 실행 시 결과는 다음과 같다.

만약 테스트가 실패한다면 다음과 같이 출력된다.

실패할 경우 Test Failed가 출력되며, 기대값과 실제로 들어간 값을 비교 출력해준다.

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

 

4. 먼저 setUp 호출하기

테스트를 작성하다보면 공통된 패턴을 발견학 됩니다. Bill wake의 3A 패턴

1. 준비 - arrange

2. 행동 - act

3. 확인 - assert

이중 준비단계는 여러 테스트에 걸쳐 동일한 경우가 종종 있다.

 

실제로 Junit을 사용할 때 흔히 사용하는 @BeforeEach, @AfterEach와 같이 각각의 테스트 케이스들에게 공통적으로 필요한 TestFixture가 존재합니다.

 

각각의 테스트 케이스들 앞/뒤로 혹은 특별한 시점에 공통적으로 코드를 수행하고 싶다면 어떻게 해야 할까요?

템플릿 메서드 패턴 은 현재 상황에 적용할 수 있는 아주 적절한 디자인 패턴입니다.

 

이를 한번 구현해 봅시다!

public class XUnitTest {

    public static void main(String[] args) {
        new TestCaseTest("testRunning").run();
        new TestCaseTest("testSetUp").run();
    }
}

TestCaseTest에서 "testSetUp"이 호출되도록 메서드를 만들어 줍시다.

 

public class TestCaseTest extends TestCase{
    public TestCaseTest(String name) {
        super(name);
    }

    // 생략...

    public void testSetUp() {
        WasRun test = new WasRun("testMethod");
        Assert.assertEquals(test.wasSetUp, false);
        test.run();
        Assert.assertEquals(test.wasSetUp, true);
    }
}

 

이전과 유사하게 wasSetUp이라는 bool 값의 field를 통해 SetUp 메서드가 호출되는지 확인해볼 수 있습니다!

public class WasRun extends TestCase {

    public boolean wasRun;
    public boolean wasSetUp;

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

    public void testMethod() {
        this.wasRun = true;
    }

}

테스트를 실행했을 때, wasSetUp 값이 테스트 전에는 false, 테스트 후에는 true라면 성공적일 것입니다.

 

하지만 우리는 아직 실행될 SetUp 메서드를 구현하지 않았죠?

모든 테스트 케이스마다 공통의 이름으로 사용될 부분이기 때문에 setUp메서드를 TestCase에 구현해 줍시다!

public abstract class TestCase {
    protected final String name;

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

    public void run() { // 템플릿 메서드
        setUp();
        runTestCase();
    }

    public void setUp() {}; // setUp 메서드 추가

    private void runTestCase() {
        try {
            Method method = getClass().getMethod(name);
            method.invoke(this);
        } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
            throw new RuntimeException(e);
        }
    }
}

이제 다시 테스트를 실행해 봅시다! 결과는 다음과 같습니다!

4번의 Assert문 모두 통과하게 된 것을 확인할 수 있습니다. 초록막대입니다!! 

 

다음으로는 Refactoring을 해야겠죠?

지금 우리의 코드를 보면 TestCaseTest에서 다음과 같은 중복된 코드가 도사리고 있습니다 ㅎㅎ

WasRun test = new WasRun("testMethod");

TestCaseTest 또한 우리가 만든 TestCase 프레임워크를 상속하고 있으니, 방금 만든 setUp()을 통해서 추가해 줍시다!

 

public class TestCaseTest extends TestCase{

    private WasRun test;

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

    @Override
    public void setUp() {
        test = new WasRun("testMethod");
    }

    public void testRunning() {
        Assert.assertEquals(test.wasRun, false);
        test.run();
        Assert.assertEquals(test.wasRun, true);
    }

    public void testSetUp() {
        Assert.assertEquals(test.wasSetUp, false);
        test.run();
        Assert.assertEquals(test.wasSetUp, true);
    }
}

리팩터링을 한 후에도 정상적으로 테스트가 동작하는 것을 확인할 수 있습니다.

하지만 한 가지 문제점이 있습니다. flag 방식은 실행이 됐는지를 확인할 때는 적합하지만, 어떤 순서에 의해 실행되는지는 보장하지 못한다.

 

예를 들어 다음과 같이 runTestCase()를 먼저 호출해도, 위 테스트 코드는 성공하게 되버립니다...

public void run() { // 템플릿 메서드
    runTestCase();
    setUp();
}

setUp()이 먼저 호출되어야 하는데, runTestCase()가 호출된다면 무슨 소용이 있겠습니까?

 

따라서 순서를 보장하도록 refactoring 해보자!

 

log라는 필드를 검증하면 어떨까? setUp이 먼저 실행된다면 log라는 필드에 "setUp"이 저장되어 있을 것이다.

log라는 필드가 없어 컴파일 오류가 발생하고 있습니다. 이를 구현해 봅시다~

public class WasRun extends TestCase {

    public boolean wasRun;
    public boolean wasSetUp;
    public String log; // 추가된 필드

    @Override
    public void setUp() {
        wasSetUp = true;
    }

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

    public void testMethod() {
        this.wasRun = true;
    }

}

지금까지의 상황을 살펴보면 다음과 같습니다~

 

이후 테스트를 실행하면 실패하게 됩니다. log 필드가 null상태이기 때문이죠.

WasRun()의 setUp()이 호출될 때 log 필드에 "setUp"을 대입하도록 코드를 추가해 줍시다.

public class WasRun extends TestCase {

    public boolean wasRun;
    public boolean wasSetUp;
    public String log;

    @Override
    public void setUp() {
        wasSetUp = true;
        log = "setUp"; // 추가된 부분
    }

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

    public void testMethod() {
        this.wasRun = true;
    }

}

테스트가 성공하는 것을 확인할 수 있다.

 

다음으로는 검증할 대상 테스트가 setUp 다음에 호출되는지 확인해야 한다.

테스트를 다음과 같이 작성해 봅시다.

public class TestCaseTest extends TestCase{

    // 생략...

    public void testRunning() {
        Assert.assertEquals(test.wasRun, false);
        test.run();
        Assert.assertEquals(test.log, "setUp testMethod");
        Assert.assertEquals(test.wasRun, true);
    }

    public void testSetUp() {
        Assert.assertEquals(test.wasSetUp, false);
        test.run();
        Assert.assertEquals(test.log, "setUp");
        Assert.assertEquals(test.wasSetUp, true);
    }
}

이전과 마찬가지로 이번에는 testMethod()에 "testMethod"를 추가하는 부분을 다음과 같이 만들자.

public void testMethod() {
    this.wasRun = true;
    log += " testMethod";
}

하지만 테스트는 여전히 실패한다. 왜 그럴까? 다음 TestCaseTest를 확인해 보자.

1) testRunning()이 실행될 때

-> log가 "setUp testMethod"이기 때문에 테스트는 성공한다.

 

2) testSetUp()이 실행될 때

-> log가 "setUp testMethod"이기 때문에 테스트는 실패한다. 기대하는 값은 "setUp"이다.

이를 문자열을 쪼개서 구현은 하지 않는 이상 불가능하다.

 

따라서 다음과 같이 동일하게 만들어 주자.

public void testRunning() {
    Assert.assertEquals(test.wasRun, false);
    test.run();
    Assert.assertEquals(test.log, "setUp testMethod");
    Assert.assertEquals(test.wasRun, true);
}

public void testSetUp() {
    Assert.assertEquals(test.wasSetUp, false);
    test.run();
    Assert.assertEquals(test.log, "setUp testMethod"); // 이부분 변경
    Assert.assertEquals(test.wasSetUp, true);
}

성공적으로 테스트는 통과하게 됩니다 ㅎㅎ

 

이쯤 되면 테스트에 대한 Refactoring이 필요한 시점이다!

이를 해결하기 위해 TestCase에 적용된 Template Method 패턴을 생각해보자.

public abstract class TestCase {

    protected final String name;

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

    // Template Method 부분
    public void run() {
        setUp();
        runTestCase();
    }
    // 생략...
}

TestCase를 상속하는 Class들이 원하는 기능을 setUp, runTestCase에 알맞게 구현하여 오버라이딩 하면 된다.

순서는 이미 run() 메서드에 정해진 대로 실행이 된다. -> 사실상 순서가 보장되고 있음

 

따라서 다음과 같이 하나의 테스트만 남겨두자.

public class TestCaseTest extends TestCase{

    private WasRun test;

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

    @Override
    public void setUp() {
        test = new WasRun("testMethod");
    }

    public void testTemplateMethod() {
        Assert.assertEquals(test.wasRun, false);
        test.run();
        Assert.assertEquals(test.log, "setUp testMethod");
        Assert.assertEquals(test.wasRun, true);
    }
}
public class XUnitTest {

    public static void main(String[] args) {
        new TestCaseTest("testTemplateMethod").run();
    }
}

testTemplateMethod 테스트 하나로도 충분히, setUp이 먼저 실행되고 그다음 testMethod가 실행이 되는 것이 검증된다.

 

이제 우리가 만든 TestCase라는 프레임워크는, 순서대로 정상 실행되는 것이 검증되었다.

기존의 실행 유무, 순서 확인을 위해 사용하던 flag들을 제거하자!

public void testTemplateMethod() {
    test.run();
    Assert.assertEquals(test.log, "setUp testMethod");
}

 

WasRun에서도 기존에 사용하던 wasSetUp, wasRun 필드를 모두 제거하자.

public class WasRun extends TestCase {

    public String log;

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

    @Override
    public void setUp() {
        log = "setUp";
    }

    public void testMethod() {
        log += " testMethod";
    }
}

이렇게 하면 setUp 과 testMethod의 실행과 순서 확인을 log라는 필드를 통해 검증하도록 Refactoring 한 것이다.

 

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

 

5. 나중에 tearDown 호출하기

tearDown을 TestCase class에 추가해 보자.

public abstract class TestCase {
    protected final String name;

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

    public void run() {
        setUp();
        runTestCase();
        tearDown(); // 추가된 부분
    }

    public void setUp() {}

    private void runTestCase() {
        try {
            Method method = getClass().getMethod(name);
            method.invoke(this);
        } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
            throw new RuntimeException(e);
        }
    }

    private void tearDown() {} // 추가된 부분
}

 

TestCase를 구현하는 WasRun에서 tearDown을 다음과 같이 구현해주자!

public class WasRun extends TestCase {

    public String log;

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

    @Override
    public void setUp() {
        log = "setUp";
    }

    public void testMethod() {
        log += " testMethod";
    }

    @Override
    public void tearDown() {
        log += " tearDown";
    }
}

 

테스트 코드는 다음과 같다.

public class TestCaseTest extends TestCase{

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

    public void testTemplateMethod() {
        WasRun test = new WasRun("testMethod");
        test.run();
        Assert.assertEquals(test.log, "setUp testMethod tearDown");
    }
}

테스트가 정상적으로 통과된다!

 

지금까지 코드를 보면 setUp()과 tearDown()을 추상 메서드로 구현하지 않았습니다.
일반 메서드이지만 구현 부분이 없이 생성하였습니다. 일종의 default 메서드에 가까울 수 있죠?
추상 메서드로 구현할 경우 상속받는 클래스들에선 무조건 오버라이딩 해야하는데, 이들은 강제로 구현해야 할 대상은 아니고 선택 대상이기 때문에 구현 없이 메서드를 만들었습니다.

 

지금까지의 상황을 요약하면 다음과 같다.

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

 

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

 

나머지 내용을 다음 글에서 작성하도록 하겠습니다!

댓글