Post

[Book - JUnit IN ACTION 3판] 3. JUnit 아키텍처

소프트웨어 아키텍처의 개념과 중요성

소프트웨어 아키텍처란 소프트웨어 시스템의 기본 구조를 말한다.
소프트웨어 시스템은 체계적인 방식으로 만들어야 한다.
소프트웨어 시스템 구조는 소프트웨어 요소, 소프트웨어 요소 간의 관게, 요소와 관계를 이루는 속성들로 구성된다.

소프트웨어 아키텍처는 건축에서의 아키텍처와 비슷하다.
소프트웨어 아키텍처의 기초를 이루는 요소는 물리적인 아키텍처의 기초만큼이나 이동이나 교체가 어렵다.
즉, 바닥을 수정하려면 그 위에 올려진 것을 전부 옮겨야 한다.

아키텍처에 대한 마틴 파울러의 정의를 따르다 보면 아키텍처 요소를 더 쉽게 교체할 수 있도록 구성해야 한다는 결론에 도달하게 된다.

아케텍처 요소를 쉽게 교체할 수 있도록 모듈화하자.
특정한 기능이 필요하다면 이를 구현한 모듈을 사용하면 된다.
테스트에서도 마찬가지로, 전체 테스트 프레임워크를 불러오는 대신 특정 모듈만 사용한다면 시간과 메모리를 절약할 수 있다.


JUnit 4 아키텍처

JUnit 4의 단점은 JUnit 5가 등장해야만 하는 이유가 되어 주었다.
JUnit 5가 왜 필요한지를 명확하게 보여 주는 JUnit 4 특성인 모놀리식 아키텍처, runner, rule을 살펴보자.

JUnit 4 모놀리식 아키텍처

2006년에 출시된 JUnit 4는 단순한 모놀리식 아키텍처를 가지고 있다.
JUnit 4의 모든 기능은 jar 파일 한 개 안에 들어 있다.
이는 개발자가 JUnit 4를 프로젝트에서 사용하고자 한다면 클래스패스에 단일 jar 파일만 추가하면 된다는 뜻이다.

JUnit 4 runner

JUnit 4 runner는 JUnit 테스트의 실행을 담당한다.
JUnnit 4는 단일 jar 파일로 동작이 가능하지만, JUnit 4의 기능을 조금 더 확장해서 사용하는 것이 일반적이다.
즉, 개발자는 테스트 실행 전후에 추가 작업을 수행하는 등 기본 기능에 사용자 정의 기능을 추가할 수 있다.

JUnit 4 runner는 리플렉션을 사용하여 테스트를 확장할 수 있다.
물론 리플렉션은 캡슐화를 저해한다.

리플렉션(Reflection)
힙 영역에 로드된 Class 타입의 객체를 통해 원하는 클래스의 인스턴스를 생성할 수 있도록 지원하고,
인스턴스의 필드와 메서드를 접근 제어자와 상관 없이 사용할 수 있도록 지원하는 API이다.

예시 : IntelliJ의 자동완성, 스프링의 애노테이션

  • 가져올 수 있는 정보
    • Class
    • Constructor
    • Method
    • Field
    • Annotation
    • extends, implements

그러나 이 기법은 JUnit 4와 그 이전 버전에서 확장성을 제공하는 유일한 방법이었고,
이는 JUnit 5가 만들어진 이유 중 하나가 되었다.

JUnit 5 extension이 JUnit 4 runner를 대체할 수 있다.


우선 build.gradle에 JUnit 4를 사용할 수 있도록 아래 코드를 추가했다.

1
2
3
4
5
6
7
8
9
10
...
dependencies {
    ...
    implementation 'org.junit.platform:junit-platform-launcher'
    implementation 'junit:junit:4.13.2'
    implementation 'org.junit.vintage:junit-vintage-engine:5.9.3'
    testImplementation 'junit:junit:4.13.2'
    testImplementation 'org.junit.vintage:junit-vintage-engine:5.9.3'
}
...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import org.junit.Test;
import org.junit.runner.Description;
import org.junit.runner.Runner;
import org.junit.runner.notification.RunNotifier;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class CustomTestRunner extends Runner {

    private final Class<?> testedClass;

    public CustomTestRunner(Class<?> testedClass) {
        this.testedClass = testedClass;
    }

    @Override
    public Description getDescription() {
        return Description
                .createTestDescription(testedClass, this.getClass().getSimpleName() + " description");
    }

    @Override
    public void run(RunNotifier notifier) {
        System.out.println("Running tests with " + this.getClass().getSimpleName() + ": " + testedClass);
        try {
            Object testObject = testedClass.newInstance();
            for (Method method : testedClass.getMethods()) {
                if (method.isAnnotationPresent(Test.class)) {
                    notifier.fireTestStarted(Description
                            .createTestDescription(testedClass, method.getName()));
                    method.invoke(testObject);
                    notifier.fireTestFinished(Description
                            .createTestDescription(testedClass, method.getName()));
                }
            }
        } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
            throw new RuntimeException(e);
        }
    }
}
1
Object testObject = testedClass.newInstance();

위 코드에서 newInstance()는 deprecated 되었기 때문에 아래 코드처럼 생성자를 통해서 만들어야 한다.

1
2
Constructor<?> constructor = testedClass.getConstructor(null);
Object testObject = constructor.newInstance();
  • 사용자 정의 runner인 CustomTestRunner 클래스에서는 테스트 대상 클래스에 대한 참조를 갖기 위해 멤버 변수 testedClass를 선언했다.
    • testedClass는 생성자에서 초기화된다.
  • 추상 클래스 Runner에서 상속한 getDescription 메서드를 재정의한다.
    • getDescription 메서드는 다양한 곳에서 사용할 수 있는 Description 객체를 반환한다.
  • 추상 클래스 Runner에서 상속한 run 메서드를 재정의한다.
    • run 메서드에서는 Constructor로 testedClass 변수를 인스턴스화한다.
  • testedClass가 가진 모든 public 메서드를 조회하고 for 문으로 그중에서 @Test 애노테이션이 달린 메서드를 걸러낸다.
  • fireTestStarted 메서드를 호출하여 리스너에게 atomic 테스트가 곧 시작한다고 알려준다.
  • 리플렉션을 활용하여 method.invoke(testObject)@Test 애노테이션이 달린 메서드를 호출한다.
  • fireTestStarted 메서드를 호출하여 리스너에게 atomic 테스트가 완료됨을 알려준다.


이제 위에서 활용한 CustomTestRunner를 @RunWith 애노테이션의 파라미터로 사용해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import org.example.junitinaction3.chapter03.Calculator;
import org.junit.Test;
import org.junit.runner.RunWith;

import static org.junit.jupiter.api.Assertions.assertEquals;

@RunWith(CustomTestRunner.class)
public class CalculatorTest {

    @Test
    public void testAdd() {
        Calculator calculator = new Calculator();
        double result = calculator.add(10, 20);
        assertEquals(30, result, 0);
    }
}
  • 이 방법은 JUnit 4에 사용자 정의 기능을 추가하는 방법이다.
    • 즉, Junit 4 기능을 확장한 것이다.
  • 실행 결과는 CustomTestRunner 클래스의 run 메서드에서 출력한 Running tests with CustomTestRunner: class org.example.junitinaction3.chapter03.runners.CalculatorTest가 나온다.

JUnit 4 rule

JUnit 4 rule은 테스트 메서드 호출을 가로채는 컴포넌트다.
JUnit 4 rule을 사용해서 테스트 메서드가 실행되기 전후에 다른 작업을 수행할 수 있다.

rule은 JUnit 4에만 적용된다는 점에 주의해야 한다.

실행할 테스트에 동작을 추가하려면 TestRule 인터페이스를 구현한 필드에 @Rule 애노테이션을 사용해야 한다.
JUnit 4 rule은 테스트 메서드에서 사용하거나 구성할 수 있는 객체를 만든다는 점에서 테스트를 유연하게 만들 수 있다.

JUnit 5에서는 개발자가 extension을 구현해야 한다.

ExpectedException

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import org.example.junitinaction3.chapter03.Calculator;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;

public class RuleExceptionTest {

    @Rule
    public ExpectedException expectedException = ExpectedException.none();

    private final Calculator calculator = new Calculator();

    @Test
    public void expectIllegalArgumentException() {
        expectedException.expect(IllegalStateException.class);
        expectedException.expectMessage("음수의 제곱근을 구할 수 없다");
        calculator.sqrt(-1);
    }

    @Test
    public void expectArithmeticException() {
        expectedException.expect(ArithmeticException.class);
        expectedException.expectMessage("0으로 나눌 수 없다");
        calculator.divide(1, 0);
    }
}
  • @Rule 애노테이션이 달린 ExpectedException 타입의 객체를 선언한다.
    • @Rule 애노테이션은 public 인스턴스 필드나 메서드에만 붙일 수 있다.
    • 팩터리 메서드인 ExpectedException.none()으로 ExpectedException 객체를 쉽게 생성할 수 있다.

      ExpectedException.none()는 JUnit 4.13부터 deprecated 되었다.
      따라서 JUnit 5에서 assertThrows()가 권장된다.

  • sqrt(-1)을 호출하면, ExpectedException 인스턴스에 예외가 만들어지고, 오류 메시지가 기록된다.
  • divide(1, 0)을 호출하면, ExpectedException 인스턴스에 예외가 만들어지고, 오류 메시지가 기론된다.

아래는 실행 결과이다.
RuleExceptionTest

두 번째 테스트가 성공하길래 divide 메서드에서 예외 처리까지는 디버깅이 되는데, 예외 발생이 되지 않은 이유를 찾지 못했다..

TemporaryFolder

때로는 테스트 관련 정보를 저장하기 위해 파일이나 폴더를 만드는 등 임시 자원을 가지고 작업해야 한다.
이때는 TemporaryFolder rule을 사용하면 테스트가 끝난 후 테스트 통과 여부에 상관없이 삭제되는 임시 폴더를 만들 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import org.junit.AfterClass;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;

import java.io.File;
import java.io.IOException;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

public class RuleTest {
    @Rule
    public TemporaryFolder folder = new TemporaryFolder();
    private static File createdFolder;
    private static File createdFile;

    @Test
    public void testTemporaryFolder() throws IOException {
        createdFolder = folder.newFolder("createdFolder");
        createdFile = folder.newFile("createdFile.txt");
        assertTrue(createdFolder.exists());
        assertTrue(createdFile.exists());
    }

    @AfterClass
    public static void cleanUpAfterAllTestsRan() {
        assertFalse(createdFolder.exists());
        assertFalse(createdFile.exists());
    }
}
  • @Rule 애노테이션은 public 필드나 메서드에만 적용할 수 있다.
  • testTemporaryFolder 테스트 메서드에서 임시 폴더와 파일이 만들어졌는지 검증한다.
  • 테스트가 모두 끝난 다음에는 @AfterClass 애노테이션이 붙은 cleanUpAfterAllTestsRan 메서드에서 임시 자원이 더 이상 존재하지 않는지 확인한다.

TestRule 구현

테스트 수행 전후로 유용하게 활용할 수 있는 사용자 정의 rule을 만들 수 있다.

사용자 정의 rule을 가지고 테스트를 실행하기 전에 특정 프로세스를 시작하고 테스트가 끝난 다음 중지하거나,
테스트를 실행하기 전에 데이터베이스에 커넥션을 얻고 테스트가 끝난 다음 커넥션을 반납할 수 있다.

JUnit 4 rule을 작성하려면 TestRule 인터페이스를 구현하는 클래스를 만들어야 한다.

TestRule 인터페이스를 구현하려면 Statement 타입 객체를 반환하는 apply(Statement, Description) 메서드를 재정의해야 한다.
Statement 객체는 JUnit 런타임 내의 테스트를 나타내면 evaluate 메서드로 테스트를 실행할 수 있다.
Description 객체는 리플렉션으로 테스트에 관한 정보를 읽어낼 수 있다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;

public class CustomRule implements TestRule {
    private Statement base;
    private Description description;

    @Override
    public Statement apply(Statement base, Description description) {
        this.base = base;
        this.description = description;
        return new CustomStatement(base, description);
    }
}
  • CustomStatement 객체를 반환할 수 있는 TestRule 인터페이스의 apply 메서드를 재정의한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import org.junit.runner.Description;
import org.junit.runners.model.Statement;

public class CustomStatement extends Statement {
    private Statement base;
    private Description description;

    public CustomStatement(Statement base, Description description) {
        this.base = base;
        this.description = description;
    }


    @Override
    public void evaluate() throws Throwable {
        System.out.println(this.getClass().getSimpleName() + " "
                + description.getMethodName() + " has started");
        try {
            base.evaluate();
        } finally {
            System.out.println(this.getClass().getSimpleName() + " "
                + description.getMethodName() + " has finished");
        }
    }
}
  • Statement 클래스에서 상속받은 evaluate 메서드를 재정의하고 base.evaluate()를 실행하여 원래의 테스트를 진행한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
import org.junit.Rule;
import org.junit.Test;

public class CustomRuleTest {

    @Rule
    public CustomRule myRule = new CustomRule();

    @Test
    public void myCustomRuleTest() {
        System.out.println("Call of a test method");
    }
}

아래는 실행 결과이다.

1
2
3
CustomStatement myCustomRuleTest has started
Call of a test method
CustomStatement myCustomRuleTest has finished

테스트가 실행되기 전후에 CustomStatement 클래스의 evaluate 메서드가 실행되어 추가적인 메시지를 볼 수 있었고, 이는 테스트를 효과적으로 만들어 주었다.

위 코드는 CustomRule 타입 필드가 public인데,
private으로 선언한 다음 @Rule 애노테이션이 붙은 getter 메서드를 public하게 선언하여 외부로 노출시킬 수도 있다.

JUnit 4 ruunner, rule 정리

JUnit 4 runner와 rule을 활용해서 JUnit 4의 모놀리식 아키텍처를 확장할 수 있었다.

위 gradle 코드에 추가한 것처럼 JUnit 5에서 JUnit 4를 사용하는 데 필요한 의존성은 junit-vintage-engine이다.
따라서 JUnit Vintage 의존성을 적용하면 한 프로젝트 안에 JUnit 4와 JUnit 5 테스트가 공존할 수 있게 된다.

JUnit 4 아키텍처의 단점

JUnit 4가 제공하는 API는 충분히 유연하지 못했다.
그 결과 JUnit 4를 사용하는 IDE나 빌드 도구와 JUnit 4 간에 지나치게 결합도가 높아지게 되었다.

JUnit의 개발자가 private 변수의 이름을 변경하기로 하면 이런 변경이 리플렉션으로 JUnit에 접근하는 도구에 역으로 영향을 줄 수 있었다.

모든 개발자가 똑같은 jar 파일을 사용하고, 빌드 도구와 IDE가 JUnit 4와 밀접하게 결합되었기 때문에 JUnit 4의 인기와 단순성이 되려 JUnit 4의 발전 가능성을 저해하게 되었다.

이런 문제를 해결하기 위해 새로운 아키텍처와 그에 발맞춘 API가 생겨났고, 더 작고 모듈화된 JUnit에 대한 요구가 생겨나 JUnit 5가 탄생했다.


JUnit 5 아키텍처

JUnit 5는 JUnit 4 jar 파일을 여러 개 작은 파일로 쪼개어 만들어졌다.

JUnit 5 모듈성

JUnit 5는 다양한 빌드 도구와 IDE를 사용하는 프로그램적 클라이언트와 상호작용할 수 있도록 설계되어야 했다.
따라서 아래와 같이 논리적으로 분리되었다.

  • 개발자가 주로 사용하는 테스트를 작성하기 위한 API
  • 테스트를 발견하고 실행하는 데 사용하는 메커니즘
  • IDE나 빌드 도구와 쉽게 상호작용하고 테스트를 구동할 수 있는 API

그 결과 JUnit 5 아키텍처는 아래 3가지 모듈로 나뉘었다.

  • JUnit Platform
    • JVM 위에서 테스트 프레임워크를 구동하기 위한 기반이 되는 플랫폼
    • 콘솔, IDE, 빌드 도구에서 테스트를 구동할수 있는 API도 제공
  • JUnit Jupiter
    • JUnit 5에서 테스트와 extension을 만들 수 있도록 프로그래밍 모델과 확장 모델을 결합한 것
    • 애노테이션, 클래스, 메서드를 비롯하여 JUnit 5 테스트를 작성하기 위한 프로그래밍 모델과 Extension API를 통해 JUnit extension을 작성하기 위한 확장 모델로 이루어져 있음
    • Jupiter의 하위 프로젝트에서는 TestEngine을 통해 JUnit Platform에서 Jupiter 기반 테스트를 실행
    • JUnit Jupiter의 확장 모델은 하나의 일관된 개념인 Extension API로만 구성됨

      JUnit 4에서는 runner나 rule 등 여러 개의 확장 지점이 있었던 것과 비교됨

  • JUnit Vintage
    • JUnit Platform에서 JUnit 3나 4 기반의 테스트를 실행하기 위한 엔진
    • 하위 호환성을 보장
    • JUnit Vintage에 포함된 유일한 아티팩트는 junit-vintage-engine으로, JUnit 3나 4로 작성된 테스트를 실행하기 위한 엔진 구현체
    • junit-vintage-engine은 JUnit 5를 활용한 테스트와 이전 버전의 테스트를 같이 사용하고자 할 때 매우 유용하게 쓰임

JUnit 5 내부 아키텍처 구성도

junit5-architecturehttps://freecontent.manning.com


정리

JUnit 4의 모놀리식 아키텍처는 단순하지만, IDE나 빌드 도구가 지나치게 결합되어 JUnit 4의 단점을 부각시킨다.

JUnit 4의 모놀리식 아키텍처를 runner와 rule로 확장할 수 있었는데, 레거시에 있으면 건들이겠지만 아니라면 JUnit5 기능을 사용할 것 같다.

아케텍처 요소를 쉽게 교체할 수 있도록 모듈화한 JUnit 5를 사용해서 JUnit 4 보다 결합도를 줄여 유연하게 테스트하면 좋을 것 같다.
가능하다면 JUnit 5를 사용하는 것이 가장 좋을 것 같다.

This post is licensed under CC BY 4.0 by the author.

© Yn3. Some rights reserved.