단위테스트를 좀 더 잘, 예쁘고 멋지고 타이핑 덜 하게 만들어 보자.

테스트 대상

간단한 클래스 하나를 가정한다. 이 클래스는 어디까지나 테스트를 만들기 위한 것으로, 내부 상태를 가지고 있다. 테스트할 메서드는 public boolean someMethod(String text)로, hidden 필드의 값(상태)에 따라 동작이 달라진다.

package kr.lul.pages.junitassertjlambda;

import java.util.Random;

public class SomeClass {
  private String hidden;

  public SomeClass() {
    this.hidden = "";
  }

  public void setAsRandom() {
    final int length = new Random().nextInt(100) - 1;

    if (0 <= length) {
      StringBuilder sb = new StringBuilder();
      for (int i = 0; i < length; i++) {
        sb.append(i % 10);
      }
      this.hidden = sb.toString();
    }
  }

  public boolean someMethod(String text) {
    if (null == text) {
      throw new NullPointerException("text");
    } else if (3 > text.length()) {
      throw new IllegalArgumentException("text length : " + text.length());
    }

    return this.hidden.length() < text.length();
  }
}

기본

그리고 이 메서드를 테스트할 단위 테스트를 만들자. JUnit4를 사용한 가장 기본적인 테스트 코드는 다음과 같은 형태가 될 것이다.

package kr.lul.pages.junitassertjlambda;

import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.endsWith;
import static org.hamcrest.Matchers.startsWith;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.fail;

import org.junit.Before;
import org.junit.Test;

import kr.lul.pages.junitassertjlambda.SomeClass;

/**
 * 기본 테스트 코드.
 */
public class SomeClassTest1Basic {
  private SomeClass sc;

  @Before
  public void setUp() throws Exception {
    this.sc = new SomeClass();
  }

  @Test
  public void testSomeMethod() throws Exception {
    this.sc.someMethod("123");
  }

  /**
   * 단순히 {@link NullPointerException}이 발생했다는 사실만 알 수 있을 뿐, 왜 예외가 발생했는지 알 수 없다.
   * 이 경우, {@link SomeClass}의 <code>hidden</code> 필드가 <code>null</code>일 경우에도 NPE가 발생할 수 있다.
   */
  @Test(expected = NullPointerException.class)
  public void testSomeMethodWithNull() {
    this.sc.someMethod(null);
  }

  /**
   * 예외를 대하는 바른 자세.
   */
  @Test
  public void testSomeMethodWithEmpty() throws Exception {
    try {
      this.sc.someMethod("");
      fail();
    } catch (IllegalArgumentException e) {
      assertEquals("text length : 0", e.getMessage());
    }
  }

  /**
   * 예외를 대하는 바른 자세..?
   */
  @Test
  public void testSomeMethodWithLength1() throws Exception {
    try {
      this.sc.someMethod("1");
      fail();
    } catch (IllegalArgumentException e) {
      assertEquals("text length : 1", e.getMessage());
    }
  }

  /**
   * 벌써 3번째 반복이다.
   * {@link SomeClass}는 예외가 발생하는 범위가 0, 1, 2의 3가지 경우의 수로 전체 테스트 코드 작성이 가능하다.
   * 하지만 10 ~ 30 정도의 테스트 케이스를 전부 작성하기엔 지저분하고 하지 않기엔 찝찝한 경우를 만날 수도 있다.
   * 예외에 대한 테스트를 좀 더 세분화했다.
   */
  @Test
  public void testSomeMethodWithLength2() throws Exception {
    try {
      this.sc.someMethod("12");
      fail();
    } catch (IllegalArgumentException e) {
      assertThat(e.getMessage(), allOf(startsWith("text length"), endsWith("" + "12".length())));
    }
  }
}

AsserJ를 도입하자

AssertJ를 사용하면 플루언트API(메서드체인)를 사용해 테스트를 작성할 수 있다.

package kr.lul.pages.junitassertjlambda;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.junit.Before;
import org.junit.Test;

import kr.lul.pages.junitassertjlambda.SomeClass;

/**
 * AssertJ 도입.
 * JUnit을 테스트케이스를 실행하는 실행환경으로 사용하고, 실재 테스트(assertion)는 AssertJ에 위임한다.
 */
public class SomeClassTest2AssertJ {
  private SomeClass sc;

  @Before
  public void setUp() throws Exception {
    this.sc = new SomeClass();
  }

  /**
   * AssertJ를 도입해 테스트를 만들었다.
   */
  @Test
  public void testSomeMethod() throws Exception {
    assertThat(this.sc.someMethod("123")).isEqualTo(true);
  }

  /**
   * AssertJ는 예외도 다각도로 테스트할 수 있도록 도와준다.
   * 예외를 테스트하기 위해 {@link ThrowingCallable}로 메서드 실행을 캡슐화 한다.
   */
  @Test
  public void testSomeMethodWithNull() {
    assertThatThrownBy(new ThrowingCallable() {
      @Override
      public void call() throws Throwable {
        SomeClassTest2AssertJ.this.sc.someMethod(null);
      }
    }).isInstanceOf(NullPointerException.class)
        .hasMessage("text");
  }

  /**
   * 람다를 사용하면 코드가 깔끔하게 정리된다.
   */
  @Test
  public void testSomeMethodWithShortText() throws Exception {
    // 이랬던 코드가
    assertThatThrownBy(new ThrowingCallable() {
      @Override
      public void call() throws Throwable {
        SomeClassTest2AssertJ.this.sc.someMethod("");
      }
    }).isInstanceOf(IllegalArgumentException.class)
        .hasMessageStartingWith("text length")
        .hasMessageEndingWith("" + "".length());

    // 이렇게 정리된다.
    // 2개의 테스트 케이스를 실행하지만, 줄 수는 익명 클래스를 만든 경우와 비슷하다.
    assertThatThrownBy(() -> this.sc.someMethod("1"))
        .isInstanceOf(IllegalArgumentException.class)
        .hasMessageStartingWith("text length")
        .hasMessageEndingWith("" + "1".length());
    assertThatThrownBy(() -> this.sc.someMethod("12"))
        .isInstanceOf(IllegalArgumentException.class)
        .hasMessageStartingWith("text length")
        .hasMessageEndingWith("" + "12".length());
  }
}

가변 변수는 좋은 것이다

AssertJ와 Java8의 람다를 사용하면 코드가 상당히 깔끔해진다. 하지만 테스트 코드(isInstanceOf~hasMessageEndingWith)의 반복이 있다. 이것도 줄여보자.

package kr.lul.pages.junitassertjlambda;

import static org.assertj.core.api.Assertions.assertThatThrownBy;

import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.junit.Before;
import org.junit.Test;

import kr.lul.pages.junitassertjlambda.SomeClass;

public class SomeClassTest3Varargs {
  private SomeClass sc;

  @Before
  public void setUp() throws Exception {
    this.sc = new SomeClass();
  }

  /**
   * 이 테스트 코드를 좀 더 깔끔하게 만들어보자.
   */
  @Test
  public void testSomeMethodWithShortText() throws Exception {
    assertThatThrownBy(() -> this.sc.someMethod(""))
        .isInstanceOf(IllegalArgumentException.class)
        .hasMessageStartingWith("text length")
        .hasMessageEndingWith("" + "".length());
    assertThatThrownBy(() -> this.sc.someMethod("1"))
        .isInstanceOf(IllegalArgumentException.class)
        .hasMessageStartingWith("text length")
        .hasMessageEndingWith("" + "1".length());
    assertThatThrownBy(() -> this.sc.someMethod("12"))
        .isInstanceOf(IllegalArgumentException.class)
        .hasMessageStartingWith("text length")
        .hasMessageEndingWith("" + "12".length());
  }

  /**
   * 가변 변수로 메서드 실행을 객체화한 {@link ThrowingCallable} 목록을 받고, 메서드 호출을 실행한 후 발생한 예외를 검증한다.
   */
  private void assertHelper(ThrowingCallable... callables) {
    for (ThrowingCallable callable : callables) {
      assertThatThrownBy(callable).isInstanceOf(IllegalArgumentException.class)
          .hasMessageMatching("text length : [0-2]");
    }
  }

  /**
   * 테스트 헬퍼({@link #assertHelper(ThrowingCallable...)})를 호출해 단위 테스트를 실행하자.
   */
  @Test
  public void testSomeMethodWithShortTextViaHelper() throws Exception {
    this.assertHelper(
        () -> this.sc.someMethod(""),
        () -> this.sc.someMethod("1"),
        () -> this.sc.someMethod("12"));
  }
}

코드 길이 줄어든다고 좋은 코드는 아니다

공통 테스트 코드를 assertHelper 메서드로 위임하면서 반복 코드를 없앴지만, 테스트 케이스(@Test 메서드)와 실재 검증(assertHelper) 코드가 따로 존재한다. 이래선 코드가 길어질 경우 가독성이 떨어진다.

package kr.lul.pages.junitassertjlambda;

import static org.assertj.core.api.Assertions.assertThatThrownBy;

import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.junit.Test;

import kr.lul.pages.junitassertjlambda.SomeClass;

public class SomeClassTest4VarargsAndLambda {
  private SomeClass sc = new SomeClass();

  /**
   * 내가 람다다.
   */
  public static interface AssertLambda {
    public void exec(ThrowingCallable callable);
  }

  /**
   * 내가 헬퍼다.
   * 헬퍼의 기능이 람다에 메서드 실행을 하나씩 넘겨주는 것으로 단순해졌다.
   * 그와 동시에 어떤 테스트를 해야 할 것인지 알 필요가 없어졌기 때문에, 범용성이 좋아졌다.
   * 범용성이 좋아졌기 때문에, 어디서든 접근, 사용할 수 있도록 접근 제한자를 <code>public static</code>으로 변경한다.
   */
  public static void assertHelper(AssertLambda assertion, ThrowingCallable... callables) {
    for (ThrowingCallable callable : callables) {
      assertion.exec(callable);
    }
  }

  /**
   * 실재 테스트 내용을 별도의 메서드에 작성한 {@link SomeClassTest3Varargs}에 비교하면,
   * 테스트(assertion) 내용을 테스트 케이스({@link Test})에 작성하는 방식으로 바뀌었다.
   *
   * 테스트 케이스의 주석, 메서드 이름, 테스트 내용, 테스트 대상(메서드 실행)이 짧은 코드로 한곳에 모였다.
   * 이 정도면 상당히 가독성 좋은 코드라고 생각한다.
   */
  @Test
  public void testSomeMethodWithShortTextViaHelper() throws Exception {
    SomeClassTest4VarargsAndLambda.assertHelper(
        (callable) -> assertThatThrownBy(callable)
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageMatching("text length : [0-2]"),
        () -> this.sc.someMethod(""),
        () -> this.sc.someMethod("1"),
        () -> this.sc.someMethod("12"));
  }
}