JUnit Test Listener: TestExecutionListener and RunListener

JUnit test listener examples for JUnit 6 and 4, a TestExecutionListener registered via ServiceLoader, a TestWatcher extension and a JUnit 4 RunListener.

The JUnit Platform launcher sends events for all engines to TestExecutionListener implementations found through ServiceLoader, the Jupiter engine calls TestWatcher extensions of registered classes, and JUnitCore calls RunListener for JUnit 4 tests

A JUnit test listener is a class that JUnit calls when test events happen, such as a test starting, passing, failing or being skipped. In JUnit 5 and JUnit 6, it implements TestExecutionListener from the JUnit Platform, and in JUnit 4 it extends RunListener.

A test listener fits work that belongs around the tests rather than inside them, for example logging slow tests, sending a chat message when the nightly run fails, or writing a custom report for a dashboard. For per-class reactions, Jupiter offers the TestWatcher extension.

The following example is a JUnit 6 listener method that prints the result of every test, registered through a META-INF/services file.

@Override
public void executionFinished(TestIdentifier testIdentifier, TestExecutionResult result) {
    Long start = startTimes.remove(testIdentifier.getUniqueId());
    if (!testIdentifier.isTest() || start == null) {
        return;
    }
    long millis = (System.nanoTime() - start) / 1_000_000;
    System.out.println("[listener] " + testIdentifier.getDisplayName() + " " + result.getStatus()
            + (millis > SLOW_MILLIS ? " SLOW " + millis + " ms" : ""));   // [listener] priceOfThreeNights() SUCCESSFUL
}

Notice that the method gets a TestIdentifier for every node of the test plan, including engines and classes, so it checks isTest() first. We build this JUnit 6 listener and register it for mvn test, compare it with a TestWatcher extension, and look at the JUnit 4 RunListener for projects that still run on JUnit 4.

1. Which Listener to Use

JUnit has three listener types, and they differ in scope. A Platform listener sees every test of every engine in the run, whereas a TestWatcher sees only the classes that register it. The JUnit 4 RunListener works only with JUnit 4 runners such as JUnitCore.

The JUnit Platform launcher sends events for all engines to TestExecutionListener implementations found through ServiceLoader, the Jupiter engine calls TestWatcher extensions of registered classes, and JUnitCore calls RunListener for JUnit 4 tests
A TestExecutionListener sees the whole run, a TestWatcher sees its test class, and RunListener belongs to JUnit 4
TestExecutionListenerTestWatcherRunListener
JUnit versionJUnit 5 and 6 (Platform)JUnit 5 and 6 (Jupiter)JUnit 4
Packageorg.junit.platform.launcherorg.junit.jupiter.api.extensionorg.junit.runner.notification
ScopeEvery test of every engine in the runTest methods of classes that register itTests run by the JUnit 4 runner
RegistrationMETA-INF/services file or the Launcher API@ExtendWith or @RegisterExtensionJUnitCore.addListener(), Surefire JUnit 4 provider
Sees containersYes, engines and classesNoRun start and end only
Typical useLogging, reports, timing of the whole runScreenshots or logs for a failed testLegacy JUnit 4 runners

When the reaction depends only on the result, we write a TestExecutionListener, and when it needs the test instance or the extension store, we write a TestWatcher. For example, a UI test class that saves a browser screenshot on failure needs the driver field of the test instance, so it uses a watcher.

2. Writing a TestExecutionListener

The interface TestExecutionListener has only default methods, so we override the events we need. The launcher calls them in this order.

  • testPlanExecutionStarted(TestPlan) once, before the first test.
  • executionStarted(TestIdentifier) for each engine, class and test that starts.
  • executionSkipped(TestIdentifier, String reason) for a disabled test or class, instead of started and finished.
  • executionFinished(TestIdentifier, TestExecutionResult) with the status SUCCESSFUL, FAILED or ABORTED.
  • testPlanExecutionFinished(TestPlan) once, after the last test.

The interface also has dynamicTestRegistered() for tests created at runtime and reportingEntryPublished() for entries from TestReporter. The following example is a hotel booking app whose test suite runs on every merge. The team wants to see slow tests in the build log, so the listener measures each test and marks it when it takes more than 200 ms.

public class SlowTestListener implements TestExecutionListener {

    private static final long SLOW_MILLIS = 200;

    private final Map<String, Long> startTimes = new ConcurrentHashMap<>();

    @Override
    public void testPlanExecutionStarted(TestPlan testPlan) {
        long tests = testPlan.countTestIdentifiers(TestIdentifier::isTest);
        System.out.println("[listener] test plan with " + tests + " tests started");
    }

    @Override
    public void executionStarted(TestIdentifier testIdentifier) {
        if (testIdentifier.isTest()) {
            startTimes.put(testIdentifier.getUniqueId(), System.nanoTime());
        }
    }

    @Override
    public void executionSkipped(TestIdentifier testIdentifier, String reason) {
        System.out.println("[listener] skipped " + testIdentifier.getDisplayName() + ": " + reason);
    }

    @Override
    public void testPlanExecutionFinished(TestPlan testPlan) {
        System.out.println("[listener] test plan finished");
    }
}

The start times go into a ConcurrentHashMap keyed by the unique ID, because tests can run in parallel and the same listener instance receives events from several threads. The executionFinished() method from the intro completes the class. The TestExecutionListener interface is in junit-platform-launcher, which the project declares with test scope.

<dependency>
  <groupId>org.junit.platform</groupId>
  <artifactId>junit-platform-launcher</artifactId>
  <scope>test</scope>
</dependency>

2.1. Registering the Listener with ServiceLoader

The launcher loads listeners with Java’s ServiceLoader. We create a file named after the interface in src/test/resources/META-INF/services and write the fully qualified class name into it. Maven Surefire, Gradle and the IDEs all pick it up, because all of them use the launcher.

$ cat src/test/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener
com.howtodoinjava.junit.listener.SlowTestListener

The test class has two passing tests and one disabled test. The mvn test output shows the listener lines next to the Surefire lines.

@Test
void priceOfThreeNights() {
    assertEquals(360, service.price(LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 4)));
}

@Test
@Disabled("Seasonal rates are not implemented yet")
void highSeasonRate() {
}
[listener] test plan with 3 tests started
[INFO] Running com.howtodoinjava.junit.booking.BookingServiceTest
[listener] skipped highSeasonRate(): Seasonal rates are not implemented yet
[listener] sameDayCheckOutIsRejected() SUCCESSFUL
[listener] priceOfThreeNights() SUCCESSFUL
[WARNING] Tests run: 3, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.165 s -- in com.howtodoinjava.junit.booking.BookingServiceTest
[listener] test plan finished

We can see that the disabled test only reaches executionSkipped(), never executionStarted(). A failed test arrives with the status FAILED, and result.getThrowable() returns the AssertionFailedError.

[listener] weekendPriceWithWrongExpectation() FAILED

A listener can also be passed to the Launcher API in code, as shown in running JUnit tests programmatically. That variant fits custom runners, while the META-INF/services file fits every build.

2.2. Turning a Listener Off

A listener that a library registers through ServiceLoader runs in every build. The configuration parameter junit.platform.execution.listeners.deactivate takes a class name pattern and switches matching listeners off. The parameter works only as a JVM system property or in junit-platform.properties, not in a discovery request, because the launcher registers the listeners before any request exists.

mvn test -Djunit.platform.execution.listeners.deactivate=com.howtodoinjava.junit.listener.SlowTestListener
[INFO] Running com.howtodoinjava.junit.booking.BookingServiceTest
[watcher] disabled highSeasonRate(): Seasonal rates are not implemented yet
[watcher] passed sameDayCheckOutIsRejected()
[watcher] passed priceOfThreeNights()

The [listener] lines are gone, while the [watcher] lines stay, because the watcher is a Jupiter extension and not a launcher listener. The value is a comma-separated list of patterns in which * matches one or more characters, and a lone * turns off all ServiceLoader listeners.

3. Reacting to Results per Class with TestWatcher

The Jupiter extension TestWatcher gets one callback per test method after the test has run or was skipped. It receives the ExtensionContext, so it knows the test class, the method and the instance. JUnit logs exceptions thrown from a watcher at WARNING level instead of failing the test.

public class FailureReportWatcher implements TestWatcher {

    @Override
    public void testSuccessful(ExtensionContext context) {
        System.out.println("[watcher] passed " + context.getDisplayName());
    }

    @Override
    public void testFailed(ExtensionContext context, Throwable cause) {
        System.out.println("[watcher] FAILED " + context.getRequiredTestMethod().getName()
                + " in " + context.getRequiredTestClass().getSimpleName() + ": " + cause.getMessage());
    }

    @Override
    public void testDisabled(ExtensionContext context, Optional<String> reason) {
        System.out.println("[watcher] disabled " + context.getDisplayName() + ": " + reason.orElse("no reason"));
    }

    @Override
    public void testAborted(ExtensionContext context, Throwable cause) {
        System.out.println("[watcher] aborted " + context.getDisplayName());
    }
}

A class opts in with @ExtendWith. The following test fails on purpose, and the build log shows the watcher line before the Platform listener line, because the Jupiter engine calls its extensions before it reports the result to the launcher.

@ExtendWith(FailureReportWatcher.class)
class BookingFailingTest {

    @Test
    void weekendPriceWithWrongExpectation() {
        BookingService service = new BookingService(120);
        assertEquals(300, service.price(LocalDate.of(2026, 7, 3), LocalDate.of(2026, 7, 5)));
    }
}
[listener] test plan with 1 tests started
[INFO] Running com.howtodoinjava.junit.booking.BookingFailingTest
[watcher] FAILED weekendPriceWithWrongExpectation in BookingFailingTest: expected: <300> but was: <240>
[listener] weekendPriceWithWrongExpectation() FAILED

A watcher reports only @Test and @TestTemplate methods, such as parameterized tests. A failure in a class-level method like @BeforeAll produces no watcher callback, so that case needs a TestExecutionListener. Resources stored in the extension store are already closed when the watcher runs.

4. RunListener in JUnit 4

In JUnit 4, a RunListener subclass overrides the events it needs. JUnitCore calls it for every test of the run. The method names are close to the Platform listener, but there is no test plan and no container events.

public class LoggingRunListener extends RunListener {

    @Override
    public void testRunStarted(Description description) {
        System.out.println("Tests to run: " + description.testCount());
    }

    @Override
    public void testStarted(Description description) {
        System.out.println("Started:  " + description.getMethodName());
    }

    @Override
    public void testFailure(Failure failure) {
        System.out.println("Failed:   " + failure.getDescription().getMethodName() + " -> " + failure.getMessage());
    }

    @Override
    public void testIgnored(Description description) {
        System.out.println("Ignored:  " + description.getMethodName());
    }

    @Override
    public void testFinished(Description description) {
        System.out.println("Finished: " + description.getMethodName());
    }

    @Override
    public void testRunFinished(Result result) {
        System.out.println("Run: " + result.getRunCount() + ", failed: " + result.getFailureCount()
                + ", ignored: " + result.getIgnoreCount());
    }
}
JUnitCore core = new JUnitCore();
core.addListener(new LoggingRunListener());
core.run(BookingJUnit4Test.class);
Tests to run: 3
Ignored:  highSeasonRate
Started:  priceOfOneNight
Finished: priceOfOneNight
Started:  priceOfTwoNightsIsWrong
Failed:   priceOfTwoNightsIsWrong -> expected:<200> but was:<240>
Finished: priceOfTwoNightsIsWrong
Run: 2, failed: 1, ignored: 1

We can see that testFinished() runs for passed and failed tests alike, and the ignored test never starts. Maven Surefire accepts a RunListener through its listener property only with its JUnit 4 provider. Once the project uses the JUnit Platform, the listener becomes a TestExecutionListener, and the JUnit 4 classes run through the Vintage engine, as described in JUnit 5 vs JUnit 4.

5. Listeners That Come with JUnit

Before we write a listener, we check the built-in listeners in junit-platform-launcher and junit-platform-reporting, because one of them may already do the job.

ListenerWhat it does
SummaryGeneratingListenerCounts found, started, passed and failed tests and prints a summary
LoggingListenerLogs every event through a logger or a BiConsumer
UniqueIdTrackingListenerWrites the unique IDs of executed tests to a file
LegacyXmlReportGeneratingListenerWrites JUnit 4 style XML reports, as used by the Console Launcher
OpenTestReportGeneratingListenerWrites an Open Test Reporting XML file when enabled by a configuration parameter

The XML report listeners are the base of the files covered in JUnit XML reports.

6. JUnit Test Listener FAQs

Registration and the choice between a listener and a watcher cause most of the questions about JUnit listeners.

6.1. How Do I Add a Listener in JUnit 5 or JUnit 6?

We implement TestExecutionListener and list the class in src/test/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener, with junit-platform-launcher as a test dependency. Maven, Gradle and the IDEs load it on the next run, as shown in section 2.

6.2. What Is the Difference Between TestExecutionListener and TestWatcher?

A TestExecutionListener belongs to the JUnit Platform and receives events for all tests, classes and engines of the run. A TestWatcher is a Jupiter extension that a class registers, and it receives one callback per test method with access to the ExtensionContext.

6.3. Can a Listener Fail or Skip a Test?

No. A TestExecutionListener only observes, and exceptions from a TestWatcher are logged instead of failing the test. To change the outcome, we use an extension such as ExecutionCondition to skip a test or TestExecutionExceptionHandler to handle an exception.

6.4. Why Does My ServiceLoader Listener Not Run?

In most cases the file name or the class name in it is wrong. The file name must match org.junit.platform.launcher.TestExecutionListener, sit in META-INF/services on the test classpath, and contain the fully qualified name of a public class with a public no-argument constructor.

7. Conclusion

A JUnit test listener observes test events without touching the tests. In JUnit 5 and JUnit 6, we implement TestExecutionListener, register it in a META-INF/services file, and switch it off with junit.platform.execution.listeners.deactivate when needed.

For reactions that need the test instance, a TestWatcher extension on the class is the better fit, and the JUnit 4 RunListener stays for code that still runs through JUnitCore. The other extension points are listed in the JUnit tutorial.

8. References

Happy Learning !!

Source Code on Github

Leave a Comment

  1. I tried to use your example but replaced the runner.run(TestFeatureOne.class, TestFeatureTwo.class); with runner.main(args); so I could pass in the test application name and make the ExecutionListener static inside ExecuteWithRunListener so this is all in one file. The test application was run but I don’t see anything from ExecutionListener class. There were 15 tests cases but all it see is the Time: 3.572 and the OK (15 tests).

    This is my code:

    package jhuapl.fact;
    
    import org.junit.runner.JUnitCore;
    import org.junit.runner.Description;
    import org.junit.runner.Result;
    import org.junit.runner.notification.Failure;
    import org.junit.runner.notification.RunListener;
    
    public class ExecuteWithRunListener
    {
        public static class ExecutionListener extends RunListener
        {
            /**
             * Called before any tests have been run.
             * */
            public void testRunStarted(Description description) throws java.lang.Exception
            {
                System.out.println("Number of tests to execute : " + description.testCount());
            }
    
            /**
             *  Called when all tests have finished
             * */
            public void testRunFinished(Result result) throws java.lang.Exception
            {
                System.out.printf("Test ran: %s, Failed: %s, Run Time: %s ms%n",  
                   result.getRunCount(), result.getFailureCount(), result.getRunTime());
            }
    
            /**
             *  Called when an atomic test is about to be started.
             * */
            public void testStarted(Description description) throws java.lang.Exception
            {
                System.out.println("Entering test case "+ description.getMethodName());
            }
    
            /**
             *  Called when an atomic test has finished, whether the test succeeds or fails.
             * */
            public void testFinished(Description description) throws java.lang.Exception
            {
                System.out.println("Leaving test case : "+ description.getMethodName());
            }
    
            /**
             *  Called when an atomic test fails.
             * */
            public void testFailure(Failure failure) throws java.lang.Exception
            {
                System.out.printf("Test case failed ran: %s, Failed: %s%n", failure.getMessage());
            }
    
            /**
             *  Called when a test will not be run, generally because a test method is annotated with Ignore.
             * */
            public void testIgnored(Description description) throws java.lang.Exception
            {
                System.out.println("Execution of test case ignored : "+ description.getMethodName());
            }
        }
    /*
     * javac -cp .:/usr/share/java/junit.jar jhuapl.fact.ExecuteWithRunListener.java
     * java jhuapl.fact.ExecuteWithRunListener jhuapl.fact.osi.common.decider.DeciderUnitTest
     */
        public static void main(String[] args)
        {
            JUnitCore runner = new JUnitCore();
            runner.addListener(new ExecutionListener());    // Adding listener here
            System.out.println("I am calling main : " + args);
            runner.main(args);
        }
    }

    What I want to do in the end is print out the run time per test case like ant-unit does vs. this sum value for the suite. So want am I doing wrong here?

  2. I am executing my junit tests from ant build.xml. I have created a Listener which should listen to all executing tests. How can I add this listener in my build.xml, as I don’t have ant junitcore object to execute my tests.

  3. how and where to add run listener in an existing suite . I have some thousands of test case in an existing frame work . I want to add listen which should run at the end of test to teardown external test data created in third party system

    • As you can see that listeners do not take input parameters which bring you some data used in testcase. so it is logically not possible for me to write a teardown method in interceptor.
      Also, writing teardown code for thousands tests in one interceptor, seems bad to me.
      Cleaning up the data should be responsibility of testcase itself, because it knows what is that it is making dirty for others. Use interceptors for logging; or report generation.

Comments are closed.

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.