How to get annotations of JUnit test suite class?

I have such an example of test suite class to run many test classes at once in JUnit 4.13.

@RunWith(Suite.class)
@SuiteClasses({
    FirstTest.class,
    SecondTest.class
})
@TestSuiteAnnotation
public class TestSuite {
}

These are my test classes.

@FirstAnnotation
public class FirstTest extends ExtTest {

  @Test
  public void test() {
  }

}

@SecondAnnotation
public class SecondTest extends ExtTest {

  @Test
  public void test() {
  }

}


public class ExtTest {

  @Before
  public void beforeMethod() {
    System.out.println("Annotations from " + this.getClass());
    Arrays.asList(this.getClass().getAnnotations()).forEach(System.out::println);
  }

}

When I run test from TestSuite.class, the console output is:

Annotations from class FirstTest
@FirstAnnotation()

Annotations from class SecondTest
@SecondAnnotation()

Currently, this.getClass().getAnnotations() returns annotations from test classes (i.e. FirstTest.class, SecondTest.class). I want to obtain annotation @TestSuiteAnnotation, when I run tests from TestSuite.class.

The expected output should be:

Annotations from class FirstTest
@FirstAnnotation()
@TestSuiteAnnotation()

Annotations from class SecondTest
@SecondAnnotation()
@TestSuiteAnnotation()

Can I somehow obtain annotation @TestSuiteAnnotation, when I run tests from TestSuite.class?

Answer

You have multiple options:

JUnit 4 run listener

On JUnit 4, you can register a RunListener, like @nrainer said. If you build with Maven, it is easy to register a run listener like this:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-surefire-plugin</artifactId>
  <version>2.22.2</version>
  <configuration>
    <includes>
      <include>org.acme.TestSuite</include>
    </includes>
    <properties>
      <property>
        <name>listener</name>
        <value>org.acme.SuiteRunListener</value>
      </property>
    </properties>
  </configuration>
</plugin>

The run listener can override the events testSuiteStarted and testSuiteFinished and either directly log the annotations you are interested in or assign them to a static thread-local variable like private static ThreadLocal<List<Annotation>> currentSuiteAnnotations in testSuiteStarted, then unassign it again in testSuiteFinished.

This works nicely from Maven, I tested it. Unfortunately, there is no direct support for running tests with run listeners from IDEs like IntelliJ IDEA or Eclipse. So if you want to avoid running the tests manually from a class with a main method as shown here, because it would take away all the nice IDE test reporting with drill-down from suite to test class to test method, this is not an option.

JUnit 5 test execution listener

Similar to JUnit 4’s run listener, you can register a TestExecutionListener for your JUnit 5 tests. The advantage in JUnit 5 is that you can register it globally via Java’s s ServiceLoader mechanism, i.e. it will be picked up when bootstrapping JUnit and should also work in IDEs. I did something similar with another type of extension, and it worked nicely in IntelliJ IDEA and of course also in Maven.

JUnit 4 with custom suite runner

Coming back to JUnit 4, we can extend the first approach with the run listener by declaring a special type of suite. You simply use that suite instead of org.junit.runners.Suite and can enjoy the working run listener in both Maven and the IDE. It works like that, see also my MCVE on GitHub for your convenience:

package org.acme;

import org.junit.runner.Description;
import org.junit.runner.RunWith;
import org.junit.runner.notification.RunListener;
import org.junit.runners.Suite;
import org.junit.runners.Suite.SuiteClasses;

import java.lang.annotation.Annotation;
import java.util.List;
import java.util.stream.Collectors;

public class SuiteRunListener extends RunListener {
  private static ThreadLocal<String> currentSuiteName = new ThreadLocal<String>();
  private static ThreadLocal<List<Annotation>> currentSuiteAnnotations = new ThreadLocal<>();

  @Override
  public void testSuiteStarted(Description description) throws Exception {
    super.testSuiteStarted(description);
    final RunWith runWith = description.getAnnotation(RunWith.class);
    if (runWith != null && runWith.value().equals(SuiteWithListener.class)) {
      currentSuiteName.set(description.getDisplayName());
      currentSuiteAnnotations.set(
        description.getAnnotations().stream()
          .filter(annotation -> {
            final Class<? extends Annotation> annotationType = annotation.annotationType();
            return !(annotationType.equals(RunWith.class) || annotationType.equals(SuiteClasses.class));
          })
          .collect(Collectors.toList())
      );
    }
  }

  @Override
  public void testSuiteFinished(Description description) throws Exception {
    super.testSuiteFinished(description);
    final RunWith runWith = description.getAnnotation(RunWith.class);
    if (runWith != null && runWith.value().equals(SuiteWithListener.class)) {
      currentSuiteName.set(null);
      currentSuiteAnnotations.set(null);
    }
  }

  public static String getCurrentSuiteName() {
    return currentSuiteName.get();
  }

  public static List<Annotation> getCurrentSuiteAnnotations() {
    return currentSuiteAnnotations.get();
  }
}
package org.acme;

import org.junit.runner.Runner;
import org.junit.runner.notification.RunNotifier;
import org.junit.runners.Suite;
import org.junit.runners.model.InitializationError;
import org.junit.runners.model.RunnerBuilder;

import java.util.List;

public class SuiteWithListener extends Suite {
  public SuiteWithListener(Class<?> klass, RunnerBuilder builder) throws InitializationError {
    super(klass, builder);
  }

  public SuiteWithListener(RunnerBuilder builder, Class<?>[] classes) throws InitializationError {
    super(builder, classes);
  }

  protected SuiteWithListener(Class<?> klass, Class<?>[] suiteClasses) throws InitializationError {
    super(klass, suiteClasses);
  }

  protected SuiteWithListener(RunnerBuilder builder, Class<?> klass, Class<?>[] suiteClasses) throws InitializationError {
    super(builder, klass, suiteClasses);
  }

  protected SuiteWithListener(Class<?> klass, List<Runner> runners) throws InitializationError {
    super(klass, runners);
  }

  @Override
  public void run(RunNotifier notifier) {
    notifier.addListener(new SuiteRunListener());  // !!!
    super.run(notifier);
  }
}
package org.acme;

import org.junit.runner.RunWith;
import org.junit.runners.Suite.SuiteClasses;

@RunWith(SuiteWithListener.class)  // !!!
@SuiteClasses({
  FirstTest.class,
  SecondTest.class
})
@TestSuiteAnnotation
public class TestSuite {}
package org.acme;

import org.junit.Before;

import java.util.Arrays;

public class ExtTest {
  @Before
  public void beforeMethod() {
    String currentSuiteName = SuiteRunListener.getCurrentSuiteName();
    if (currentSuiteName != null) {
      System.out.println("Annotations from suite " + currentSuiteName);
      SuiteRunListener.getCurrentSuiteAnnotations().forEach(System.out::println);
    }
    System.out.println("Annotations from class " + this.getClass());
    Arrays.asList(this.getClass().getAnnotations()).forEach(System.out::println);
    System.out.println();
  }
}

Now when running your suite, you should see output like this:

Annotations from suite org.acme.TestSuite
@org.acme.TestSuiteAnnotation()
Annotations from class class org.acme.FirstTest
@org.acme.FirstAnnotation()

Annotations from suite org.acme.TestSuite
@org.acme.TestSuiteAnnotation()
Annotations from class class org.acme.SecondTest
@org.acme.SecondAnnotation()

Please note: I was assuming that you really need access to the current suite from each single test method, not just at the test class or suite level. If you do not need that and it is enough to let the run listener do something when a suite is started and/or finished, of course you do not need the getter methods for current suite name and suite annotations. I just extended your own example.