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.

| TestExecutionListener | TestWatcher | RunListener | |
|---|---|---|---|
| JUnit version | JUnit 5 and 6 (Platform) | JUnit 5 and 6 (Jupiter) | JUnit 4 |
| Package | org.junit.platform.launcher | org.junit.jupiter.api.extension | org.junit.runner.notification |
| Scope | Every test of every engine in the run | Test methods of classes that register it | Tests run by the JUnit 4 runner |
| Registration | META-INF/services file or the Launcher API | @ExtendWith or @RegisterExtension | JUnitCore.addListener(), Surefire JUnit 4 provider |
| Sees containers | Yes, engines and classes | No | Run start and end only |
| Typical use | Logging, reports, timing of the whole run | Screenshots or logs for a failed test | Legacy 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.
| Listener | What it does |
|---|---|
| SummaryGeneratingListener | Counts found, started, passed and failed tests and prints a summary |
| LoggingListener | Logs every event through a logger or a BiConsumer |
| UniqueIdTrackingListener | Writes the unique IDs of executed tests to a file |
| LegacyXmlReportGeneratingListener | Writes JUnit 4 style XML reports, as used by the Console Launcher |
| OpenTestReportGeneratingListener | Writes 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
- TestExecutionListener Javadoc (JUnit 6.1.3)
- TestWatcher Javadoc (JUnit 6.1.3)
- JUnit 6.1.3 User Guide – Launcher API
- JUnit 6.1.3 User Guide – Using Listeners and Interceptors
- RunListener Javadoc (JUnit 4.13)
Happy Learning !!
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?
This is the expected behavior. Please check the official documentation of main() method and runClasses() method in JUnitCore.
If you are on JUnit 5, consider using listeners.
OnFinished and OnFailure methods are not working for me..
onStart method is executing for me…
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.
Maybe https://ant.apache.org/manual/Tasks/junit.html#enabletestlistenerevents can help you.
How you can have the result (true/false/fail) in testFinished for each atomic test ?
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.