Junit은 자바의 단위테스트를 위한 프레임 워크 입니다. 거의 매일 사용하는데 정작 내부 동작이 어떻게 이루어 지는지 공부해본적 없는것 같아 향로님의 블로그 글을 참고하여 추석 연휴동안 만들어 보았습니다.

구현할 주요 기능들

  • Assert
  • 단위 테스트
  • 어노테이션

구현

Assert

Junit에는 단위테스트를 편리하게 하기위해 Assert 라는 유틸리티 클래스가 존재합니다. 인스턴스화를 방지하기 위해 생성자는 private으로 두고 구현을 진행합니다.

package myjunit;

public class Assert {

  private Assert() {}

  public static void isTrue(boolean expression, String message) {
    if (!expression) {
      throw new AssertionFailedException(message);
    }
  }

  public static void isTrue(boolean expression) {
    isTrue(expression, "[Assertion failed] - this expression must be true");
  }

}

isTrue 메서드에 true인 값이 전달되면 테스트가 통과하고, false값이 전달되면 AssertionFailedException을 발생시켜 테스트가 실패하도록 구현하였습니다.

public class AssertionFailedException extends RuntimeException {

  public AssertionFailedException(String message) {
    super(message);
  }

}

단위 테스트

단위테스트는 테스트가 서로 독립적이어야 하며 테스트를 어떤 순서로든 실행할 수 있어야 합니다. 때문에 하나의 인스턴스를 기반으로 구현하기 보다 각 테스트마다 독립적인 인스턴스를 갖도록 설계해야 합니다.

package myjunit;

import java.lang.reflect.Method;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class TestUnit {

  private final Logger logger;
  private final String name;

  public TestUnit(String name) {
    this.name = name;
    this.logger = LoggerFactory.getLogger(name);
  }

  public void execute() {
    test();
  }

  public void test() {
    try {
      Method method = this
          .getClass()
          .getMethod(name);
      method.invoke(this);
      logger.info("Test passed");
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }

}
  • Logger의 경우 습관적으로 private static final 을 붙여 작성하곤 하지만, 각각의 단위 테스트의 통과여부를 이름으로 구분할 수 있도록 정적으로 선언하지 않았습니다. (클래스당 하나의 로거가 필요한 경우라면 static으로 선언하는것이 맞습니다)
  • test() 메서드의 경우 리플렉션을 활용하여 메서드의 이름을 통해 테스트 메서드를 호출하도록 구현하였습니다. 또한 리플렉션을 사용하였을때 발생하는 CheckedException은 RuntimeException으로 전환하도록 구현하였습니다.

만든 단위 테스트를 수행해보겠습니다.

public class DefaultTestUnit extends TestUnit {

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

  public void passTest() {
    Assert.isTrue(true);
  }

  public void failTest() {
    Assert.isTrue(false);
  }

  public static void main(String[] args) {
    new DefaultTestUnit("passTest").execute();
    new DefaultTestUnit("failTest").execute();
  }

}
Caused by: java.lang.reflect.InvocationTargetException

테스트 결과 InvocationTargetException 예외가 발생하여 다른 테스트 결과를 확인할 수 없었습니다. invoke()로 호출한 메서드 내에서 예외가 발생하면 해당 예외를 InvocationTargetException으로 wrapping하게 됩니다. 때문에 어떤 이유로 테스트가 실패하였는지 구분하기 위해 예외를 try-catch문으로 처리 해주어야 합니다.

테스트시 발생하는 에러와 실패를 구분하고 테스트 결과를 확인하기 위해 TestFailure, TestError, TestResult 클래스를 구현하였습니다.

package myjunit;

public class TestFailure {

  private final TestUnit testUnit;

  public TestFailure(TestUnit testUnit) {
    this.testUnit = testUnit;
  }

  public String getTestName() {
    return testUnit.getName();
  }

}
package myjunit;

public class TestError extends TestFailure {

  private final Exception exception;

  public TestError(
      TestUnit testUnit,
      Exception exception
  ) {
    super(testUnit);
    this.exception = exception;
  }

  public Exception getException() {
    return exception;
  }

}
package myjunit;

import java.util.ArrayList;
import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class TestResult {

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

  private int runTestCount;
  private final List<TestError> errors;
  private final List<TestFailure> failures;

  public TestResult() {
    this.runTestCount = 0;
    this.errors = new ArrayList<>();
    this.failures = new ArrayList<>();
  }

  public synchronized void startTest() {
    this.runTestCount++;
  }

  public void addError(
      TestUnit testUnit,
      Exception e
  ) {
    errors.add(new TestError(testUnit, e));
  }

  public void addFailure(TestUnit testUnit) {
    failures.add(new TestFailure(testUnit));
  }

  public void printResult() {
    logger.info("Total Test Count: {}", runTestCount);
    logger.info("Total Test Success Count: {}", runTestCount - failures.size() - errors.size());
    logger.info("Total Test Failure Count: {}", failures.size());
    logger.info("Total Test Error Count: {}", errors.size());
  }

}

그리고 TestResult 에 테스트 수행결과를 저장할 수 있도록 TestUnit 을 수정하였습니다.

package myjunit;

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

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class TestUnit {

  private final Logger logger;
  private final String name;

  public TestUnit(String name) {
    this.name = name;
    this.logger = LoggerFactory.getLogger(name);
  }

  public String getName() {
    return name;
  }

  public void execute(TestResult testResult) {
    testResult.startTest();
    try {
      test();
    } catch (InvocationTargetException e) {
      if (isAssertionFailed(e)) {
        logger.info("Test failed");
        testResult.addFailure(this);
      } else {
        logger.info("Test error occur");
        testResult.addError(this, e);
      }
    } catch (Exception e) {
      logger.info("Test failed");
      testResult.addError(this, e);
    }
  }

  public void test() throws Exception {
    Method method = this
        .getClass()
        .getMethod(name);
    method.invoke(this);
    logger.info("Test passed");
  }

  private boolean isAssertionFailed(InvocationTargetException ite) {
    return ite.getTargetException() instanceof AssertionFailedException;
  }

}

만든 UnitTest 를 통해 테스트를 진행하고 결과를 확인하였습니다

package myjunit;

public class DefaultTestUnit extends TestUnit {

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

  public void passTest() {
    Assert.isTrue(true);
  }

  public void failTest() {
    Assert.isTrue(false);
  }

  public static void main(String[] args) {
    TestResult testResult = new TestResult();
    new DefaultTestUnit("passTest").execute(testResult);
    new DefaultTestUnit("failTest").execute(testResult);

    testResult.printResult();
  }

}

결과

19:37:19.625 [main] INFO passTest - Test passed
19:37:19.626 [main] INFO failTest - Test failed
19:37:19.626 [main] INFO myjunit.TestResult - Total Test Count: 2
19:37:19.626 [main] INFO myjunit.TestResult - Total Test Success Count: 1
19:37:19.626 [main] INFO myjunit.TestResult - Total Test Failure Count: 1
19:37:19.626 [main] INFO myjunit.TestResult - Total Test Error Count: 0

어노테이션

테스트를 수행하기 위해선 일일이 테스트 메서드의 이름을 통해 DefaultTestUnit 인스턴스를 생성해야합니다. 이러한 불편함을 줄이기 위해 단순히 어노테이션만을 붙이는것으로 변경해 보겠습니다.

Test.java

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Test {

}

CaseOne.java

public class CaseOne {

  @Test
  void passTest() {
    Assertion.isTrue(true);
  }

  @Test
  void failTest() {
    Assertion.isTrue(false);
  }
}

다음으로 해당 어노테이션이 붙은 메서드만 모아서 실행시켜야 합니다.

Java의 런타임 메타데이터 분석 라이브러리 Reflections를 활용하여 테스트하고자 하는 패키지의 모든 @Test 어노테이션이 붙은 메서드를 스캔하는 스캐너를 만들었습니다.

public class TestScanner {

  public Set<Method> scanTestMethods(String packageName) {
    Reflections reflections = new Reflections(packageName, Scanners.MethodsAnnotated);
    return reflections.getMethodsAnnotatedWith(Test.class);
  }

}

그리고 Reflection을 통해 가져온 Method를 수행시키도록 TestUnit 클래스를 변경하였습니다.

public class TestUnit {

  private final Logger logger;
  private final Method method;

  public TestUnit(Method method) {
    this.method = method;
    this.logger = LoggerFactory.getLogger(method.getName());
  }

  public String getName() {
    return method.getName();
  }

  public void execute(TestResult testResult) {
    testResult.startTest();
    try {
      test();
    } catch (InvocationTargetException e) {
      if (isAssertionFailed(e)) {
        logger.info("Test failed");
        testResult.addFailure(this);
      } else {
        logger.info("Test error occur");
        testResult.addError(this, e);
      }
    } catch (Exception e) {
      logger.info("Test failed");
      testResult.addError(this, e);
    }
  }

  public void test() throws Exception {
    Object newInstance = getNewInstanceOfDeclaringClass(method);
    method.setAccessible(true);
    method.invoke(newInstance);
    logger.info("Test passed");
  }

  private boolean isAssertionFailed(InvocationTargetException ite) {
    return ite.getTargetException() instanceof AssertionFailedException;
  }

  private Object getNewInstanceOfDeclaringClass(Method method) throws Exception {
    return method
        .getDeclaringClass()
        .getDeclaredConstructor()
        .newInstance();
  }

}

이때 메서드를 선언한 클래스의 새로운 인스턴스를 생성하고, 이를 통해 Method의 invoke()함수를 호출하도록 구현하였습니다. 만약 메서드가 선언되지 않은 클래스를 통해 호출하게 되면 IllegalArgumentException이 발생하게 됩니다.

또한 setAccessible(true)를 통해 해당 메서드의 접근지정자를 무시하고 호출할 수 있도록 하였습니다. 기본 접근지정자가 private이기에 번거롭게 접근지정자를 public으로 두는 수고를 덜기 위해서입니다.

다음으로 스캐너를 통해 가져온 테스트대상 메서드를 실행시키는 테스트 러너를 구현하였습니다.

public class TestRunner {

  private static final String testPackage = "com.myjunit.testCase";

  public static void main(String[] args) {
    TestResult testResult = new TestResult();
    TestScanner testScanner = new TestScanner();
    Set<Method> methods = testScanner.scanTestMethods(testPackage);

    methods.forEach(method -> new TestUnit(method).execute(testResult));

    testResult.printResult();
  }

}

TestScanner를 통해 모든 @Test 어노테이션이 붙은메서드를 가져오고 TestUnit을 통해 해당 메서드들에 대한 단위테스트를 수행하도록 로직을 구현하였습니다.

결과

23:40:03.992 [main] INFO org.reflections.Reflections - Reflections took 33 ms to scan 1 urls, producing 1 keys and 2 values
23:40:03.998 [main] INFO failTest - Test failed
23:40:03.999 [main] INFO passTest - Test passed
23:40:03.999 [main] INFO com.myjunit.core.TestResult - Total Test Count: 2
23:40:03.999 [main] INFO com.myjunit.core.TestResult - Total Test Success Count: 1
23:40:03.999 [main] INFO com.myjunit.core.TestResult - Total Test Failure Count: 1
23:40:03.999 [main] INFO com.myjunit.core.TestResult - Total Test Error Count: 0

소스코드

https://github.com/waterfogSW/make-junit

참고

https://jojoldu.tistory.com/231

https://github.com/ronmamo/reflections

+ Recent posts