💡 자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들은 사용해야 한다.
애너테이션으로 할 수 있는 일을 명명 패턴으로 처리할 이유는 없다.
📌 명명 패턴의 단점
✒️ 명명 패턴 : 메서드 이름을 특정 문자열로 시작하게 만들어, 해당 문자열이 들어가면 실행하도록 하는 패턴
- 오타가 나면 안 된다.
- JUnit3가 무시하고 지나쳐서 테스트가 통과되었다고 오해할 수 있다.
- 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다.
- 클래스 이름에 Test를 붙여도 JUnit3은 관심도 없으므로, 개발자가 의도한 테스트는 수행되지 않는다.
- 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다.
- 특정 예외를 던져야 성공하는 테스트에서, 예외 타입을 테스트에 매개변수로 전달해야 하는 상황 가정
- 예외 이름을 테스트 메서드 이름에 덧붙이는 방법은 보기도 나쁘고 깨지기 쉽다. (Item 62)
- 컴파일러는 메서드 이름에 덧붙인 문자열이 예외를 가리키는지 알 방법이 없다.
- 테스트 실행 전까지는 해당 이름의 클래스 존재 여부와 예외가 맞는지조차 알 수 없다.
📌 마커(marker) 애너테이션(@) 타입 선언
동작 방식을 이해하기 위해서 직접 제작한 작은 테스트 프레임워크를 사용한다.
아무 매개변수 없이 단순히 대상에 마킹(marking)한다는 뜻에서 marker annotation이라 한다.
import java.lang.annotation.*;
/**
* 테스트 메서드임을 선언하는 애너테이션
* 매개변수 없는 정적 메서드 전용
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
- 메타 애너테이션(meta-annotation) : 애너테이션 타입 선언에 있는 애터네이션
- @Retention(RetentionPlicy.RUNTIME) : @Test가 런타임에도 유지되어야 한다는 표시
- @Target(ElementType.METHOD) : @Test가 반드시 메서드 선언에서만 사용되어야 함
- "매개변수 없는 정적 메서드 전용이다"라고 주석을 달기는 했으나, 실제로 컴파일러에서 강제하고 싶다면 javax.annotation.processing API 문서를 참고하여 직접 구현해야 한다.
- 구현하지 않고 인스턴스 메서드나 매개변수가 있는 메서드에 달면 컴파일은 되지만, 테스트 도구를 실행할 때 문제가 발생한다.
📌 @Test 적용
public class Main {
@Test public static void m1() {} // 성공해야 한다.
public static void m2() {}
@Test public static void m3() { // 실패해야 한다.
throw new RuntimeException("실패");
}
public static void m4() {}
@Test public void m5() {} // 잘못 사용한 예: 정적 메서드가 아니다.
public static void m6() {}
@Test public static void m7() { // 실패해야 한다.
throw new RuntimeException("실패");
}
public static void m8() {}
}
- 7개의 정적 메서드와 1개의 일반 메서드
- m2, m4, m6, m8은 테스트 되지 않는다.
- m1은 성공, m3, m7은 실패, m5의 경우 정적 메서드가 아니므로 잘못 사용했다.
- @Test는 Sample 클래스 의미에 직접적 영향을 주지 않는다.
- 대상 코드의 의미는 그대로 둔 채, 애너테이션에 관심 있는 도구에서 특별한 처리할 기회를 줄 뿐이다.
📌 마커 애너테이션을 처리하는 프로그램
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class RunTests {
public static void main(String[] args) throws Exception {
int tests = 0;
int passed = 0;
Class<?> testClass = Class.forName(args[0]);
for (Method m : testClass.getDeclaredMethods()) {
if (m.isAnnotationPresent(Test.class)) {
tests++;
try {
m.invoke(null);
passed++;
} catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
System.out.println(m + " 실패: " + exc);
} catch (Exception exc) {
System.out.println("잘못 사용한 @Test: " + m);
}
}
}
System.out.printf("성공: %d, 실패: %d%n",
passed, tests - passed);
}
}
- Class.forname(args[0]) : 명령줄로부터 완전 정규화된 클래스 이름을 받는다.
- testClass.getDeclaredMethods() : 클래스에 선언된 메서드를 차례대로 호출한다.
- if (m.isAnnotationPresent(Test.class)) : @Test 애너테이션이 달린 메서드를 판단한다.
- InvocationTargetException : 테스트 메서드가 예외를 던지면, reflection 메커니즘이 예외를 감싸서 다시 던진다.
- wrappedExc.getCause() : InvocationTargetException을 잡아 원래 예외에 담긴 실패 정보를 추출해 출력
- Exception : InvocationTargetException 외의 예외가 발생하면, @Test를 잘못 사용한 것이다.
📌 특정 예외를 던져야 성공하는 @TestException
1️⃣ 매개변수 하나를 받는 애너테이션 타입
/**
* 명시한 예외를 던져야만 성공하는 테스트 메서드용 애너테이션
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
- Class<? extends Throwable> : 이 애너테이션의 매개변수 타입. 모든 예외와 오류 타입을 수용한다.
2️⃣ 적용 사례
public class Main {
@ExceptionTest(ArithmeticException.class)
public static void m1() { // 성공해야 한다.
int i = 0;
i = i / i;
}
@ExceptionTest(ArithmeticException.class)
public static void m2() { // 실패해야 한다. (다른 예외 발생)
int[] a = new int[0];
int i = a[1];
}
@ExceptionTest(ArithmeticException.class)
public static void m3() {} // 실패해야 한다. (예외가 발생하지 않음)
}
3️⃣ 처리 프로그램
public class RunTests {
public static void main(String[] args) throws Exception {
...
try {
m.invoke(null);
passed++;
} catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
Class<? extends Throwable> excType = m.getAnnotation(
ExceptionTest.class).value();
if (excType.isInstance(exc)) {
passed++;
} else {
System.out.println(m + " 실패: " + exc);
}
} catch (Exception exc) {
System.out.println("잘못 사용한 @Test: " + m);
}
...
}
- 애너테이션 매개변수 값을 추출하여, 테스트 메서드가 올바른 예외를 던지는지 확인한다.
- 테스트 프로그램이 문제없이 컴파일되면 애너테이션 매개변수가 가리키는 예외가 올바른 타입이라는 뜻이다.
- 단, 해당 예외 클래스 파일이 런타임에는 존재하지 않을 수 있다. 이런 경우 테스트 러너가 TypeNotPresentException을 던진다.
📌 배열 매개변수를 받아 여러 예외를 처리하는 @TestExeption
위에서 한 단계 더 나아가, 예외를 여러 개 명시하고 그 중 하나가 발생하면 성공하게 만들 수도 있다.
1️⃣ 매개변수 여러 개를 받는 애너테이션 타입
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable>[] value();
}
- 매개변수 타입을 Class 객체 배열로 수정한다.
2️⃣ 적용 사례
public class Main {
@ExceptionTest({ IndexOutOfBoundsException.class,
NullPointerException.class })
public static void doublyBad() {
List<String> list = new ArrayList<>();
// 자바 API 명세에 따르면 다음 메서드는 IndexOutOfBoundsException이나
// NullPointerException을 던질 수 있다.
list.addAll(5, null);
}
}
- 이전의 ExceptionTest들도 모두 수정 없이 수용한다.
- 원소가 여럿인 배열을 지정할 때는 원소들을 중괄호로 감싸고 쉼표로 구분해주면 끝난다.
3️⃣ 처리 프로그램
public class RunTests {
public static void main(String[] args) throws Exception {
...
try {
m.invoke(null);
passed++;
} catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
int oldPassed = passed;
Class<? extends Throwable>[] excType = m.getAnnotation(
ExceptionTest.class).value();
for (Class<? extends Throwable> excTypes : excType) {
if (excTypes.isInstance(exc)) {
passed++;
break;
}
}
if (passed == oldPassed)
System.out.printf("테스트 %s 실패: %s %n", m, exc);
} catch (Exception exc) {
System.out.println("잘못 사용한 @Test: " + m);
}
...
}
📌 반복 가능 애너테이션 @Repeatable
자바 8에서는 여러 개의 값을 받는 애너테이션을 @Repeatable meta-annotation을 다는 방식도 있다.
@Repeatable 애너테이션은 하나의 프로그램 요소에 여러 번 달 수 있지만 주의할 점이 있다.
- @Repeatable을 단 애너테이션을 반환하는 '컨테이너 애너테이션'을 하나 더 정의하고, @Repeatable에 이 컨테이너 애너테이션의 class 객체를 매개변수로 전달해야 한다.
- 컨테이너 애너테이션은 내부 애너테이션 타입의 배열을 반환하는 value 메서드를 정의해야 한다.
- 컨테이너 애너테이션 타입에는 적절한 보존 정책(@Retention)과 적용 대상(@Target)을 명시해야 한다.
1️⃣ 반복 가능 애너테이션 타입
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
// 컨테이너 애너테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface ExceptionTestContainer {
ExceptionTest[] value();
}
2️⃣ 적용 사례
@ExceptionTest(IndexOutOfBoundsException.class)
@ExceptionTest(NullPointerException.class)
public static void doublyBad() { ... }
- 반복 가능 애너테이션은 하나만 달았을 때와 구분하기 위해 해당 '컨테이너' 애너테이션 타입이 적용된다.
3️⃣ 처리 프로그램
public class RunTests {
public static void main(String[] args) throws Exception {
...
if (m.isAnnotationPresent(ExceptionTest.class)
|| m.isAnnotationPresent(ExceptionTestContainer.class)) {
tests++;
try {
m.invoke(null);
passed++;
} catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
int oldPassed = passed;
ExceptionTest[] excTests = m.getAnnotationsByType(ExceptionTest.class);
for (ExceptionTest excTest : excTests) {
if (excTest.value().isInstance(exc)) {
passed++;
break;
}
}
if (passed == oldPassed)
System.out.printf("테스트 %s 실패: %s %n", m, exc);
} catch (Exception exc) {
System.out.println("잘못 사용한 @Test: " + m);
}
}
...
}
- getAnnotationByType()은 이 둘을 구분하지 못하나, isAnnotationPresent()는 둘을 명확히 구분한다.
- 반복 가능 애너테이션을 한 번만 달고 isAnnotationPresent로 컨테이너 애너테이션이 달렸는지 검사하면 무시한다. (반복 가능 애너테이션이 걸렸으므로)
- 반복 가능 애너테이션을 여러번 달고 isAnnotationPresent로 반복 가능 애너테이션이 달렸는지 검사하면 무시한다. (컨테이너가 달렸으므로)
- 따라서, 달려있는 수와 상관없이 모두 검사하려면 둘을 따로따로 확인해야 한다.