Testing with JUnit & junitlauncher
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Apache Ant 1.10.18 and JUnit 6.1.3 (JUnit Platform, JUnit Jupiter and JUnit Vintage all share the
version 6.1.3). Every example marked as run was executed on JDK 26 on 2026-10-16 in a scratch directory; output that comes from a run
is shown as it was printed, with the long absolute path of the scratch directory shortened to <scratch>. Ant prints
the elapsed time with the machine’s decimal separator, which is why the timings below have a comma. Anything that could not be run
here is labelled "not run here".
Ant ships two tasks that run unit tests, and they belong to different generations of JUnit. The junit task runs JUnit 3 and 4.
The junitlauncher task, added in Ant 1.10.3, talks to the JUnit Platform and therefore runs anything that has a test engine:
JUnit Jupiter (the @Test of JUnit 5 and 6), JUnit Vintage (JUnit 3 and 4 tests) and third-party engines. This page covers both,
the junitreport task that turns their XML into an HTML report, and the build-file techniques that make test failures stop (or
deliberately not stop) a build. The running scenario is the tidepool tidepool-engine module described on the
section landing page. For compiling the test sources see
Compiling Java; for the structure of the build file see
Build File Structure.
Which task for which JUnit
junit |
junitlauncher |
|
|---|---|---|
Runs |
JUnit 3.0 to 3.8.2 and JUnit 4.x, including pure annotation-based JUnit 4 tests. It does not run JUnit 5 or 6 tests. |
Whatever the JUnit Platform engines on its classpath can run: Jupiter (JUnit 5 and 6), Vintage (JUnit 3 and 4), other engines. |
Added in |
Long-standing optional task, present in every Ant 1.x line. |
Ant 1.10.3. |
Optional jar |
|
|
Test selection |
|
|
Tags |
Not available. |
|
Result files |
|
|
Forking |
|
A nested |
Status |
Keeps working for JUnit 3 and 4 suites; no support for JUnit 5 or newer. |
The task to use for JUnit 5, JUnit 6 and new code. |
Source: Ant manual: junitlauncher
Source: Ant manual: junit
Both tasks are optional tasks. That has two consequences that cause most of the support questions: the task’s jar has to be visible to Ant before the build file is parsed, and the JUnit jars are not part of Ant at all. The next section is about exactly that.
Release history of the junitlauncher task
The task changed quite a lot during the 1.10 line. When a build file written for a recent release is run by an older
Ant, the symptom is an unsupported attribute error, so the table lists the first release that has each feature.
| Release | Change |
|---|---|
1.10.3 |
|
1.10.4 |
The |
1.10.6 |
The |
1.10.7 |
|
1.10.10 |
The task generates its own summary (closely matching the |
1.10.13 |
|
1.10.14 |
|
1.10.15 |
The |
The libraries the task needs
The junitlauncher task does not understand tests. It builds a JUnit Platform launcher request, hands it to the launcher and
reports what the engines did. So three groups of libraries have to be present, and they do not all live in the same place.
| Group | Jars (6.1.3 on the baseline) | Needed for |
|---|---|---|
The task |
|
The |
JUnit Platform |
|
Every run, whatever the engine. |
Jupiter engine |
|
Tests written with |
Vintage engine |
|
Tests written with |
The manual lists the first three groups (under JUnit Platform libraries, the Jupiter engine libraries and the Vintage engine libraries). Two things the manual does not state in this form deserve a sentence each. JUnit 6 requires Java 17 or newer at runtime, so the JVM running Ant (or the forked JVM) must be at least that, even when the code under test is compiled for an older release. And the Vintage engine is deprecated in the JUnit 6 line: it logs an INFO message about it every time it takes part in a run (the exact text is in the output further down).
The page for Ant’s own installation, Installation, explains the optional-library mechanics. For junitlauncher the manual gives three ways to make the jars visible.
-
Recommended since Ant 1.10.6. Leave
ant-junitlauncher.jarwhere it is, inANT_HOME/lib, and put every JUnit jar on theclasspathnested in thejunitlauncherelement. Nothing outside the project has to change, and different projects can use different JUnit versions with the same Ant installation. -
Put the JUnit jars into
ANT_HOME/libtogether withant-junitlauncher.jar. This pins every build on the machine to one JUnit. -
Keep
ant-junitlauncher.jarinANT_HOME/liband pass the JUnit jars with the command-line option-lib(see Running Ant).
When a jar is missing the build does not fail at one obvious place: the symptom differs with the jar. The errors are in Errors you will meet near the end of this page, each with the text Ant 1.10.18 really printed.
Source: Ant manual: recommended setup
Source: JUnit user guide
Getting the jars without a repository manager
Ant has no dependency management of its own. The examples on this page keep the jars in a lib directory next to the build
file, in four sub-directories (main for the libraries of the code under test, platform, jupiter and vintage), which were filled with curl from Maven
Central. The chapters that follow in this section show the two proper answers:
Ivy Ant Tasks and
Maven Resolver Ant Tasks, which resolve the same
coordinates (and their transitive dependencies) into a path for you. The difference between "download one jar" and "resolve
the dependency graph" is the subject of the first error you will meet below: commons-csv needs commons-io.
A first test run
The module has six classes under src/main/java (the model records and the engine) and three test classes. Two of them are JUnit
Jupiter tests, tagged so that the later sections can select by tag; one is a JUnit 4 test.
package io.tidepool.engine;
class StationReaderTest {
@Test
@Tag("fast")
void readsThreeStations() throws IOException {
try (Reader in = Files.newBufferedReader(Path.of("data/stations.csv"))) {
List<Station> stations = StationReader.read(in);
assertEquals(3, stations.size());
assertEquals("Boston", stations.get(1).name());
}
}
// firstStationHasThreeConstituents(): the same file, asserts 3 constituents for the first station
}
TideEngineTest has three methods: a @Tag("fast") test that checks a prediction is bounded by the sum of the amplitudes, a
@Tag("slow") test that scans one day minute by minute, and an untagged one that prints scanned Boston. LegacyHeightTest
is a JUnit 4 class (org.junit.Test) with one method. The import lines and the second test of StationReaderTest are omitted in the listing
above; all other listings on this page are complete.
The build file starts with the paths and the compile targets. Each library group is a path by itself (see
Paths, Filesets & Patterns), so that the
compile classpath and the runtime classpath of the tests can be assembled from the same pieces.
<?xml version="1.0" encoding="UTF-8"?>
<project name="tidepool-engine" default="test" basedir=".">
<property name="build.dir" value="${basedir}/build"/>
<property name="main.classes" value="${build.dir}/classes"/>
<property name="test.classes" value="${build.dir}/test-classes"/>
<property name="reports.dir" value="${build.dir}/test-reports"/>
<path id="main.libs">
<fileset dir="lib/main" includes="*.jar"/>
</path>
<path id="junit.platform.libs">
<fileset dir="lib/platform" includes="*.jar"/>
</path>
<path id="jupiter.libs">
<fileset dir="lib/jupiter" includes="*.jar"/>
</path>
<path id="vintage.libs">
<fileset dir="lib/vintage" includes="*.jar"/>
</path>
<path id="test.compile.classpath">
<pathelement location="${main.classes}"/>
<path refid="main.libs"/>
<path refid="jupiter.libs"/>
<path refid="vintage.libs"/>
</path>
<path id="test.runtime.classpath">
<path refid="test.compile.classpath"/>
<path refid="junit.platform.libs"/>
<pathelement location="${test.classes}"/>
</path>
<target name="init">
<mkdir dir="${reports.dir}"/>
</target>
<target name="compile">
<mkdir dir="${main.classes}"/>
<javac srcdir="src/main/java" destdir="${main.classes}" release="21"
includeantruntime="false">
<classpath refid="main.libs"/>
</javac>
</target>
<target name="compile-tests" depends="compile">
<mkdir dir="${test.classes}"/>
<javac srcdir="src/test/java" destdir="${test.classes}" release="21"
includeantruntime="false">
<classpath refid="test.compile.classpath"/>
</javac>
</target>
</project>
The init target exists for a reason that is easy to miss: junitlauncher does not create the directory its listeners write
into. The error you get otherwise is in Errors you will meet.
The test target is the heart of it. It is also the target the later snippets build on; every further target is added to the
same file before the closing </project> tag.
<target name="test" depends="compile-tests,init" description="Runs all unit tests">
<junitlauncher printSummary="true" haltOnFailure="true">
<classpath refid="test.runtime.classpath"/>
<testclasses outputdir="${reports.dir}">
<fileset dir="${test.classes}"/>
<listener type="legacy-brief" sendSysOut="true"/>
<listener type="legacy-xml" sendSysOut="true" sendSysErr="true"/>
</testclasses>
</junitlauncher>
</target>
Run with ant test on a clean tree (the directory names in the mkdir and javac lines are the scratch directory, shortened):
Buildfile: <scratch>/build.xml
compile:
[mkdir] Created dir: <scratch>/build/classes
[javac] Compiling 6 source files to <scratch>/build/classes
compile-tests:
[mkdir] Created dir: <scratch>/build/test-classes
[javac] Compiling 3 source files to <scratch>/build/test-classes
init:
[mkdir] Created dir: <scratch>/build/test-reports
test:
[junitlauncher] Running io.tidepool.engine.StationReaderTest
[junitlauncher] Tests run: 2, Failures: 0, Aborted: 0, Skipped: 0, Time elapsed: 0,038 sec
[junitlauncher] Running io.tidepool.engine.TideEngineTest
[junitlauncher] Tests run: 3, Failures: 0, Aborted: 0, Skipped: 0, Time elapsed: 0,004 sec
[junitlauncher] Running io.tidepool.legacy.LegacyHeightTest
[junitlauncher] Tests run: 1, Failures: 0, Aborted: 0, Skipped: 0, Time elapsed: 0,006 sec
BUILD SUCCESSFUL
Total time: 0 seconds
Four things to read out of this run. The task’s own JVM is Ant’s JVM (nothing is forked), so the run is fast. The testclasses
element was given every .class file under the test class directory and the JUnit Platform decided which of them were tests;
helper classes and nested classes were ignored without any pattern. The Jupiter engine ran the first two classes and the Vintage engine
ran LegacyHeightTest, with no configuration, because both engine jars are on the classpath. And scanned Boston,
which TideEngineTest prints to standard output, is absent from the console: with sendSysOut="true" on a listener the output
goes to that listener’s result file instead (the next section shows where). Without any listener that asks for it, the output is
written to Ant’s log, which a later run in this page shows.
The junitlauncher task in detail
Attributes of the task
| Attribute | Meaning | Default |
|---|---|---|
|
Comma-separated JUnit tags; only tests with at least one of them run. Since Ant 1.10.7. |
all tests |
|
Comma-separated JUnit tags; tests with any of them are skipped. Since Ant 1.10.7. |
none |
|
Stop the build when a test fails. JUnit 4 and later classify assertion failures and thrown exceptions together, and so does the task: it does not distinguish failures from errors. |
|
|
Name of a property to set when a test fails (exceptions in tests included). |
not set |
|
Print the statistics line after each test class. Since 1.10.10 the task itself generates the line instead of using the platform’s. |
|
Everything else is nested. The classpath element can be repeated and the parts are concatenated, which is what the
manual’s own example does (one classpath per library group).
Nested elements
| Element | Role |
|---|---|
|
A path-like structure. It is used to find the test classes, to find the test engines and, if configured that way, to find
every JUnit library. If the element is missing, Ant’s own classpath is used, which only works when everything was installed
in |
|
One test class: |
|
Any number of resource collections; every resource whose name ends in |
|
A result formatter or any |
|
Run the tests in a new JVM; see the section on forking. |
The attributes haltOnFailure and failureProperty can be given on junitlauncher and are overridden by the same attribute
on a nested test or testclasses. Note the spelling: the manual writes outputDir and Ant’s attribute matching is case
insensitive, which is why the examples on this page can spell it outputdir.
Listeners and result files
A listener receives the events of a run. The task defines three formatters, all of them named after the output of the junit
task whose formatters they imitate:
type |
Output |
|---|---|
|
A short statistics line for every test case, written to a |
|
Details only for tests that failed or were skipped, written to a |
|
The statistics and the output of the tests in XML, written to an |
Instead of type a listener can name a classname: a class implementing the platform’s TestExecutionListener, or the
Ant-specific sub-interface TestResultFormatter for listeners that want a file name and the captured output. The attributes of
listener:
| Attribute | Meaning | Default |
|---|---|---|
|
One of |
not set |
|
A listener class. |
not set |
|
File name of the result, relative to |
|
|
Extension of the result file. Since Ant 1.10.13. |
|
|
Directory of the result, for this listener only. Since Ant 1.10.6. |
the |
|
Hand the captured standard output (error) of the tests to the listener. Only meaningful for |
|
|
Use the listener only when the named property is (not) set. |
none |
|
|
|
The run above wrote three files per test class into build/test-reports. This is the whole legacy-brief file for
TideEngineTest and the structure of the legacy-xml file (the properties element, which has one entry for every property of the Ant
project and the JVM, is abbreviated):
Testcase: io.tidepool.engine.TideEngineTest
Tests run: 3, Failures: 0, Skipped: 0, Aborted: 0, Time elapsed: 4 milli sec(s)
------------- Standard Output ---------------
scanned Boston
------------- ---------------- ---------------
<?xml version="1.0" ?>
<testsuite name="io.tidepool.engine.TideEngineTest" time="0.005" timestamp="2026-10-16T09:34:47" tests="3" failures="0" skipped="0" aborted="0">
<properties>...</properties>
<testcase classname="io.tidepool.engine.TideEngineTest" name="scanningADayStaysWithinBounds()" time="0.003">
</testcase>
<testcase classname="io.tidepool.engine.TideEngineTest" name="predictionIsBoundedByTheSumOfAmplitudes()" time="0.0">
</testcase>
<testcase classname="io.tidepool.engine.TideEngineTest" name="printsAProgressLine()" time="0.0">
</testcase>
<system-out>scanned Boston
</system-out>
</testsuite>
Two details matter when other tools read this XML. The test names carry the parentheses of the Java method signature, because
useLegacyReportingName is true. And time uses a dot in the XML and a comma on the console because the console line is
formatted with the locale and the file is not.
Selecting what runs
Tags are the platform’s mechanism for categories (@Tag("fast")), so they replace the old habit of keeping slow tests in a
differently named class and picking them with a batchtest pattern. Add a target that runs only the fast tests:
<target name="test-fast" depends="compile-tests,init" description="Only tests tagged fast">
<junitlauncher includeTags="fast" printSummary="true" haltOnFailure="true">
<classpath refid="test.runtime.classpath"/>
<testclasses outputdir="${reports.dir}">
<fileset dir="${test.classes}"/>
<listener type="legacy-plain" sendSysOut="true"/>
</testclasses>
</junitlauncher>
</target>
…
test-fast:
[junitlauncher] Running io.tidepool.engine.StationReaderTest
[junitlauncher] Tests run: 2, Failures: 0, Aborted: 0, Skipped: 0, Time elapsed: 0,034 sec
[junitlauncher] Running io.tidepool.engine.TideEngineTest
[junitlauncher] Tests run: 1, Failures: 0, Aborted: 0, Skipped: 0, Time elapsed: 0,002 sec
[junitlauncher] oct 17, 2026 12:02:42 P. M. org.junit.platform.launcher.core.DiscoveryIssueNotifier logIssues
[junitlauncher] INFO: TestEngine with ID 'junit-vintage' encountered a non-critical issue during test discovery:
[junitlauncher]
[junitlauncher] (1) [INFO] The JUnit Vintage engine is deprecated and should only be used temporarily while migrating tests to JUnit Jupiter or another testing framework with native JUnit Platform support.
BUILD SUCCESSFUL
Total time: 0 seconds
TideEngineTest ran one of its three methods, and LegacyHeightTest did not run at all because JUnit 4 tests have no tags.
This is also where the Vintage deprecation notice is printed: the engine takes part in the discovery whether or not it finds
anything. excludeTags="slow" is the mirror image and runs everything but the scan (the test-not-slow target, run: StationReaderTest 2 tests,
TideEngineTest 2 tests, LegacyHeightTest 1 test). The Ant attributes take a plain comma-separated list of tag names.
To restrict a run to one engine, use includeEngines (or excludeEngines) on test or testclasses. With
includeEngines="junit-jupiter" on the test target’s testclasses the Vintage test is silently left out: the run printed
the two Jupiter classes (2 and 3 tests) and no line for LegacyHeightTest. The engine ids are junit-jupiter and junit-vintage.
A property can switch a whole test or listener on or off with if and unless, which is the Ant way to make a target
configurable from the command line without a new target for every combination, as the next section shows.
Running one test
During development the most useful target is the one that runs a single class, or a few methods of it, taking both from the
command line. The fail task checks the mandatory property, and two test elements, one with if and one with unless, cover
the two cases (see Conditions & Control Flow
for fail).
<target name="test-one" depends="compile-tests,init" description="-Dtest.class=... -Dtest.methods=...">
<fail unless="test.class" message="Set -Dtest.class=fully.qualified.TestClass"/>
<junitlauncher printSummary="true" haltOnFailure="true">
<classpath refid="test.runtime.classpath"/>
<test name="${test.class}" methods="${test.methods}" if="test.methods"
outputdir="${reports.dir}">
<listener type="legacy-plain"/>
</test>
<test name="${test.class}" unless="test.methods" outputdir="${reports.dir}">
<listener type="legacy-plain"/>
</test>
</junitlauncher>
</target>
ant test-one -Dtest.class=io.tidepool.engine.TideEngineTest -Dtest.methods="scanningADayStaysWithinBounds,printsAProgressLine"
…
test-one:
[junitlauncher] Running io.tidepool.engine.TideEngineTest
[junitlauncher] scanned Boston
[junitlauncher] Tests run: 2, Failures: 0, Aborted: 0, Skipped: 0, Time elapsed: 0,031 sec
BUILD SUCCESSFUL
Total time: 0 seconds
Without -Dtest.methods the same class ran all three tests (Tests run: 3), and without -Dtest.class the build stopped at the
first line of the target with Set -Dtest.class=fully.qualified.TestClass. Because this target has no sendSysOut, scanned Boston is now
printed to the console. The -D properties and why they win over the build file are explained in
Properties.
Forking
By default the tests run in the JVM that runs Ant. That is fast but it shares the process: a test that calls System.exit() ends
the build (Java 18+ has no SecurityManager to catch it, since Ant 1.10.14), a leaked thread keeps Ant alive, static state leaks
between test classes, and the JVM options cannot be chosen. A nested fork element (Ant 1.10.6 and later) starts a new
JVM instead.
| Attribute | Meaning | Default |
|---|---|---|
|
Working directory of the forked JVM. |
the project’s base directory |
|
Maximum run time in milliseconds; the JVM is killed when it is exceeded. With |
none |
|
Add the JUnit Platform jars that Ant knows about to the classpath of the forked JVM; when |
|
|
Add Ant’s own runtime jars to the classpath of the forked JVM; when |
|
|
The command that starts the JVM, to test on a different JDK than the one running Ant. Since Ant 1.10.14. |
the current JVM |
|
|
one JVM for all classes |
Nested elements: jvmarg, sysproperty, syspropertyset, env, modulepath and upgrademodulepath, with the same attributes as for the
java task.
<target name="test-forked" depends="compile-tests,init" description="One JVM per test class">
<junitlauncher printSummary="true" haltOnFailure="true">
<classpath refid="test.runtime.classpath"/>
<testclasses outputdir="${reports.dir}">
<fileset dir="${test.classes}"/>
<fork forkMode="perTestClass" timeout="60000">
<sysproperty key="tidepool.zone" value="UTC"/>
<jvmarg value="-Xmx128m"/>
</fork>
</testclasses>
</junitlauncher>
</target>
…
test-forked:
[junitlauncher] Running io.tidepool.engine.StationReaderTest
[junitlauncher] Tests run: 2, Failures: 0, Aborted: 0, Skipped: 0, Time elapsed: 0,032 sec
[junitlauncher] Running io.tidepool.engine.TideEngineTest
[junitlauncher] scanned Boston
[junitlauncher] Tests run: 3, Failures: 0, Aborted: 0, Skipped: 0, Time elapsed: 0,028 sec
[junitlauncher] Running io.tidepool.legacy.LegacyHeightTest
[junitlauncher] Tests run: 1, Failures: 0, Aborted: 0, Skipped: 0, Time elapsed: 0,021 sec
[junitlauncher] oct 17, 2026 12:02:43 P. M. org.junit.platform.launcher.core.DiscoveryIssueNotifier logIssues
[junitlauncher] INFO: TestEngine with ID 'junit-vintage' encountered a non-critical issue during test discovery:
[junitlauncher]
[junitlauncher] (1) [INFO] The JUnit Vintage engine is deprecated and should only be used temporarily while migrating tests to JUnit Jupiter or another testing framework with native JUnit Platform support.
BUILD SUCCESSFUL
Total time: 0 seconds
In the first, non-forked run the first class paid for loading the platform (46 ms, then 5 and 8 ms for the others). In the forked run
every class pays for its own JVM (37, 38 and 26 ms). In a real project that overhead is the price of isolation, so the common compromise is the default
single forked JVM (no forkMode), or perTestClass only for the integration tests. The timeout is the safety net against a hanging test
that would block a CI job forever; the timeout was not triggered in this run. The same options exist in the other tools as Surefire’s forkCount and
reuseForks and Gradle’s forkEvery and maxParallelForks.
Source: Ant manual: junitlauncher, fork
Making failures fail the build
By default a failing test does not fail the Ant build. The task records the failure in its output and goes on, and the build ends
with BUILD SUCCESSFUL. There are two ways to change that, and they serve different needs.
Stop at once. haltOnFailure="true" (on the task, or per test/testclasses) throws a BuildException as soon as a test
class has a failure. The next test classes do not run. With a deliberately broken test (BrokenTest.addsUp asserts that 2 + 3 equals 4)
placed before the good one, this is what Ant 1.10.18 printed:
<property name="failing.classes" value="${build.dir}/failing-classes"/>
<target name="compile-failing" depends="compile-tests">
<mkdir dir="${failing.classes}"/>
<javac srcdir="src/test-failing/java" destdir="${failing.classes}" release="21"
includeantruntime="false">
<classpath refid="test.compile.classpath"/>
</javac>
</target>
<target name="test-failing-halt" depends="compile-failing,init" description="Stops at the first failure">
<junitlauncher haltOnFailure="true" printSummary="true">
<classpath refid="test.runtime.classpath"/>
<classpath location="${failing.classes}"/>
<test name="io.tidepool.engine.BrokenTest" outputdir="${reports.dir}">
<listener type="legacy-brief" sendSysOut="true"/>
</test>
<test name="io.tidepool.engine.TideEngineTest" outputdir="${reports.dir}"/>
</junitlauncher>
</target>
…
test-failing-halt:
[junitlauncher] Running io.tidepool.engine.BrokenTest
[junitlauncher] Tests run: 2, Failures: 1, Aborted: 0, Skipped: 0, Time elapsed: 0,036 sec
BUILD FAILED
<scratch>/build.xml:127: Failed to launch in-vm tests
Total time: 0 seconds
Two lessons. TideEngineTest never ran. And the message, Failed to launch in-vm tests, is the same text Ant prints when the
launcher itself could not be created (see the table of errors), so for the real reason read the line above it, or the .txt file
that the legacy-brief listener wrote:
Testcase: io.tidepool.engine.BrokenTest
Test: addsUp() took 11 milli sec(s) FAILED: two plus three ==> expected: <4> but was: (5)
org.opentest4j.AssertionFailedError: two plus three ==> expected: <4> but was: (5)
at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:601)
at io.tidepool.engine.BrokenTest.addsUp(Unknown Source)
Tests run: 2, Failures: 1, Skipped: 0, Time elapsed: 18 milli sec(s)
(The stack trace says Unknown Source because the test classes were compiled without debug="true" on the javac task; see
Compiling Java.)
Run everything, then fail. The usual CI need is different: run all the tests, collect the results, do the clean-up that has to
happen anyway (stop a database or an application server), and only then fail. For that, set failureProperty instead of
haltOnFailure, do the clean-up, and finish with a fail task that tests the property:
<target name="test-failing-deferred" depends="compile-failing,init"
description="Runs everything, cleans up, then fails">
<junitlauncher failureProperty="tests.failed" printSummary="true">
<classpath refid="test.runtime.classpath"/>
<classpath location="${failing.classes}"/>
<testclasses outputdir="${reports.dir}">
<fileset dir="${failing.classes}"/>
<fileset dir="${test.classes}"/>
<listener type="legacy-brief"/>
<listener type="legacy-xml"/>
</testclasses>
</junitlauncher>
<echo message="cleaning up (a real build would stop a database or server here)"/>
<fail if="tests.failed" message="There were test failures; see build/test-reports"/>
</target>
…
test-failing-deferred:
[junitlauncher] Running io.tidepool.engine.BrokenTest
[junitlauncher] Tests run: 2, Failures: 1, Aborted: 0, Skipped: 0, Time elapsed: 0,034 sec
[junitlauncher] Running io.tidepool.engine.StationReaderTest
[junitlauncher] Tests run: 2, Failures: 0, Aborted: 0, Skipped: 0, Time elapsed: 0,008 sec
[junitlauncher] Running io.tidepool.engine.TideEngineTest
[junitlauncher] scanned Boston
[junitlauncher] Tests run: 3, Failures: 0, Aborted: 0, Skipped: 0, Time elapsed: 0,004 sec
[junitlauncher] Running io.tidepool.legacy.LegacyHeightTest
[junitlauncher] oct 17, 2026 12:02:44 P. M. org.junit.platform.launcher.core.DiscoveryIssueNotifier logIssues
[junitlauncher] INFO: TestEngine with ID 'junit-vintage' encountered a non-critical issue during test discovery:
[junitlauncher]
[junitlauncher] (1) [INFO] The JUnit Vintage engine is deprecated and should only be used temporarily while migrating tests to JUnit Jupiter or another testing framework with native JUnit Platform support.
[junitlauncher] Tests run: 1, Failures: 0, Aborted: 0, Skipped: 0, Time elapsed: 0,007 sec
[echo] cleaning up (a real build would stop a database or server here)
BUILD FAILED
<scratch>/build.xml:150: There were test failures; see build/test-reports
Total time: 0 seconds
All four classes ran, the clean-up message was printed, and the build failed with the message of your choice. The tempting
mistake is to set neither attribute. The same build file with failureProperty and the fail task removed ended with
BUILD SUCCESSFUL although Tests run: 2, Failures: 1 was printed for BrokenTest: a green build with a red test, which is
the most common way for Ant builds to hide regressions. Targets that always run after a build, such as clean-up, are the subject of
Targets & Extension Points.
Source: Ant manual: junitlauncher
HTML reports with junitreport
junitreport merges the XML files of many test classes into one document (TESTS-TestSuites.xml) and optionally applies
an XSL stylesheet to produce a report that can be opened in a browser. It reads the files of the junit task’s XML formatter, and
the legacy-xml listener of junitlauncher was designed to write the same schema, so one task serves both.
| Element or attribute | Meaning | Default |
|---|---|---|
|
Name of the merged file and the directory it is written to. |
|
|
The |
required |
|
|
|
|
A directory with your own |
the embedded stylesheets |
|
Where the report is written. |
current directory |
|
An XSL parameter ( |
none |
|
Choose the XSLT processor, as for the |
the JDK’s processor |
<target name="report" depends="test" description="HTML report from the legacy-xml files">
<junitreport todir="${reports.dir}">
<fileset dir="${reports.dir}" includes="TEST-*.xml"/>
<report format="noframes" todir="${build.dir}/reports/html"/>
</junitreport>
</target>
…
report:
[junitreport] Processing <scratch>/build/test-reports/TESTS-TestSuites.xml to <scratch>/build/reports/html/junit-noframes.html
[junitreport] Loading stylesheet file:<ant-home>/lib/ant-junit.jar!/org/apache/tools/ant/taskdefs/optional/junit/xsl/junit-noframes.xsl
[junitreport] Transform time: 100ms
BUILD SUCCESSFUL
Total time: 0 seconds
(<ant-home> stands for the Ant installation directory: the default stylesheet is read from ant-junit.jar.) The manual lists
xalan.jar as the library of the junitreport task. On JDK 26 the run above needed no additional jar, because the task uses the JDK’s own XSLT processor
when none is configured; if you ever see a transformer error, the classpath and factory elements are the way to select one.
Note the fileset pattern: TEST-*.xml does not match TESTS-TestSuites.xml, so the merged file is not read back as an input
the next time the target runs. The report is static HTML; for trend lines and test history that is the job of the CI server, see
CI & IDE Integration.
Source: Ant manual: junitreport
The legacy junit task (JUnit 3 and 4)
The junit task is still the right tool in exactly one situation: a code base that is on JUnit 3 or 4 and has no reason to
move. It needs ant-junit.jar (and ant-junit4.jar, both are in ANT_HOME/lib) and junit.jar, which the manual allows you to supply in several ways, of which the
simplest is to include junit.jar in the classpath passed to the task (since Ant 1.7). The FAQ entry "Why do I get a ClassNotFoundException for JUnit"
describes the older ways.
Attributes
| Attribute | Meaning | Default |
|---|---|---|
|
|
|
|
Run in a separate JVM. Required when the tests or their libraries call |
|
|
|
|
|
As in |
|
|
The same for errors only. |
|
|
Strip JUnit and Ant frames from stack traces. |
|
|
Cancel the tests after the given milliseconds. Ignored if |
none |
|
Settings of the forked JVM. |
ignored if |
|
Send the test output to Ant’s log ( |
|
|
A new class loader for each test case. Ignored if forked. |
|
|
Number of threads for the tests; requires |
none |
|
Add the Ant classes and JUnit to the classpath of a forked JVM. |
|
|
Where temporary files go (since 1.6), whether each failing test is logged (since 1.8.0) and fine-grained test events at verbose level (since 1.8.2). |
project base dir, true, false |
Nested elements are classpath, jvmarg, sysproperty, syspropertyset, env, bootclasspath, permissions, assertions,
modulepath and upgrademodulepath (both need fork), formatter, test and batchtest. The permissions element is
no longer supported on Java 18 and newer (the reason is the removal of the SecurityManager; see
Running Programs).
Formatters: plain (statistics for every test case), brief (details only for failures), xml and failure (since 1.8.0; it collects the failing test
methods into a new test case class). A formatter can use classname for a custom class together with the mandatory extension, can be silenced with
usefile="false" so that it prints to the console, and takes if and unless.
batchtest is the counterpart of testclasses. It takes any resource collection and turns each resource into a class name, so
here the fileset has to select the classes with a pattern. skipNonTests="true" drops classes that have no tests (identified
by @Test annotations, methods whose names start with test in TestCase subclasses, @RunWith/@SuiteClasses or a suite() method), which prevents
helpers from appearing as test errors.
An example with JUnit 4
<?xml version="1.0" encoding="UTF-8"?>
<project name="tidepool-legacy" default="junit-report" basedir=".">
<property name="build.dir" value="${basedir}/build"/>
<property name="legacy.reports" value="${build.dir}/legacy-reports"/>
<path id="junit4.classpath">
<pathelement location="${build.dir}/classes"/>
<pathelement location="${build.dir}/test-classes"/>
<fileset dir="lib/vintage" includes="junit-4*.jar,hamcrest-core-*.jar"/>
</path>
<target name="legacy-test">
<mkdir dir="${legacy.reports}/raw"/>
<junit printsummary="yes" fork="yes" forkmode="once" haltonfailure="no"
failureproperty="legacy.failed" errorproperty="legacy.failed">
<classpath refid="junit4.classpath"/>
<formatter type="plain"/>
<formatter type="xml"/>
<batchtest todir="${legacy.reports}/raw">
<fileset dir="${build.dir}/test-classes">
<include name="io/tidepool/legacy/*Test.class"/>
</fileset>
</batchtest>
</junit>
</target>
<target name="junit-report" depends="legacy-test">
<junitreport todir="${legacy.reports}">
<fileset dir="${legacy.reports}/raw" includes="TEST-*.xml"/>
<report format="noframes" todir="${legacy.reports}/html"/>
</junitreport>
<fail if="legacy.failed" message="legacy tests failed"/>
</target>
</project>
Run after ant test has compiled the classes (the long junitreport lines are shortened as above):
Buildfile: <scratch>/build-legacy.xml
legacy-test:
[mkdir] Created dir: <scratch>/build/legacy-reports/raw
[junit] Running io.tidepool.legacy.LegacyHeightTest
[junit] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0,031 sec
junit-report:
[junitreport] Processing <scratch>/build/legacy-reports/TESTS-TestSuites.xml to <scratch>/build/legacy-reports/html/junit-noframes.html
[junitreport] Transform time: 95ms
BUILD SUCCESSFUL
Total time: 0 seconds
The task found the junit and hamcrest jars through its own classpath, with ant-junit.jar already in ANT_HOME/lib. The
plain file next to the XML contains the testcase line (Testcase: heightKeepsItsValue took 0,001 sec), and the HTML report was
generated from the XML exactly like in the previous section.
The classic trap is to point junit at JUnit 5 or 6 tests because the task name looks right. This run is the same classpath with
the Jupiter test class:
…
t:
[mkdir] Created dir: <scratch>/build/wrong
[junit] Running io.tidepool.engine.StationReaderTest
[junit] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0,002 sec
[junit] Test io.tidepool.engine.StationReaderTest FAILED
[echo] after junit
BUILD SUCCESSFUL
Total time: 0 seconds
Because haltonfailure was off, the build was green. The plain result explains it: the class is package-private, not a JUnit 4 test,
and JUnit 4’s runner rejects it.
Testsuite: io.tidepool.engine.StationReaderTest
Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0,002 sec
Testcase: initializationError took 0 sec
Caused an ERROR
Invalid test class 'io.tidepool.engine.StationReaderTest':
1. The class io.tidepool.engine.StationReaderTest is not public.
2. Test class should have exactly one public constructor
3. No runnable methods
…
The junit task therefore needs no migration to run an existing JUnit 4 suite, and needs junitlauncher plus the Vintage engine
the day the first Jupiter test is added: the JUnit Platform runs both kinds in one launcher, as the first section showed.
Source: Ant manual: junit
Source: Ant FAQ: ant-junit and junit.jar
Errors you will meet
Every message below was produced by Ant 1.10.18 in the scratch project, by taking the working build file and breaking one thing.
The task jar is missing from Ant (a copy of the installation without ant-junitlauncher.jar; this is also what an installation from a Linux
distribution package that splits the optional jars can look like). Adding the jar with -lib made the same build succeed.
BUILD FAILED
<scratch>/build.xml:53: Problem: failed to create task or type junitlauncher
Cause: the class org.apache.tools.ant.taskdefs.optional.junitlauncher.confined.JUnitLauncherTask was not found.
This looks like one of Ant's optional components.
Action: Check that the appropriate optional JAR exists in
-<scratch>/ant-nolauncher/lib
-<home>/.ant/lib
-a directory added on the command line with the -lib argument
Do not panic, this is a common problem.
The commonest cause is a missing JAR.
This is not a bug; it is a configuration problem
No engine on the classpath (the Jupiter engine jar left out of the runtime path). The visible message is only
Failed to launch in-vm tests; the cause appears with ant -v:
BUILD FAILED
<scratch>/noengine.xml:55: Failed to launch in-vm tests
...
Caused by: org.junit.platform.commons.PreconditionViolationException: Cannot create Launcher without at least one TestEngine; consider adding an engine implementation JAR to the classpath
The platform launcher jar is missing (junit-platform-launcher left out): Failed to launch in-vm tests, and with -v
Caused by: java.lang.NoClassDefFoundError: org/junit/platform/launcher/core/LauncherFactory.
The listener directory does not exist. Without the init target, with build/test-reports absent:
BUILD FAILED
<scratch>/build.xml:61: Failed to launch in-vm tests
and with -v the cause is Caused by: java.nio.file.NoSuchFileException: <scratch>/build/test-reports/TEST-io.tidepool.engine.StationReaderTest.txt.
The task creates the files but not the directory.
A library of the code under test is missing, because a dependency of the dependency was not downloaded. commons-csv-1.14.1.jar alone was in
lib/main. The tests ran (and failed, so the build stopped with haltOnFailure); the report has the cause:
Testcase: io.tidepool.engine.StationReaderTest
Test: firstStationHasThreeConstituents() took 12 milli sec(s) FAILED: org/apache/commons/io/output/AppendableOutputStream
java.lang.NoClassDefFoundError: org/apache/commons/io/output/AppendableOutputStream
at io.tidepool.engine.StationReader.read(Unknown Source)
at io.tidepool.engine.StationReaderTest.firstStationHasThreeConstituents(Unknown Source)
Caused by: java.lang.ClassNotFoundException: org.apache.commons.io.output.AppendableOutputStream
at org.apache.tools.ant.AntClassLoader.findClassInComponents(AntClassLoader.java:1402)
at org.apache.tools.ant.AntClassLoader.findClass(AntClassLoader.java:1357)
at org.apache.tools.ant.AntClassLoader.loadClass(AntClassLoader.java:1112)
at org.apache.tools.ant.taskdefs.optional.junitlauncher.confined.JUnitLauncherTask$TaskConfiguredPathClassLoader.loadClass(JUnitLauncherTask.java:533)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:502)
... 2 more
Adding commons-io-2.20.0.jar moved the failure to org/apache/commons/codec/binary/Base64OutputStream, and commons-codec-1.19.0.jar finished the job:
the dependency graph of commons-csv 1.14.1 has two more runtime libraries, and the compile worked all along because javac only needed commons-csv. This
is the exact problem Ivy and the Resolver tasks exist for.
| Symptom | Cause and fix |
|---|---|
|
|
|
A launcher problem (see |
|
Neither |
|
The class is not a test for any engine on the classpath, a tag filter excluded it, or the engine jar is missing. |
|
A runtime dependency (often transitive) of the code under test is missing from the task’s classpath. |
|
The tests ran in Ant’s JVM; use |
The same job in Maven and Gradle
| Concern | Ant | Maven (Surefire 3.6.0) | Gradle 9.8.1 |
|---|---|---|---|
Engine and launcher |
Jars on the nested |
Surefire inspects the test dependencies and picks its JUnit Platform provider; the engine is a |
|
Select by tag |
|
|
|
Fork a JVM |
|
|
|
Fail the build |
|
fails by default ( |
fails by default ( |
Reports |
|
|
|
The Maven side is covered by Testing with Surefire and
Integration Testing with Failsafe, the
Gradle side by Testing JVM Projects. The structural difference is that
Ant leaves every decision on this page to the build author, and that Maven and Gradle make failing the build the default, which
is why the Ant targets above spend so many lines on haltOnFailure and fail. Test coverage and static analysis, which Maven and Gradle get
from plugins with the same defaults for failure, are the same kind of explicit wiring in Ant; a build that mixes the tools is
described in Migrating Between Tools.
Where the book is outdated
Java Development with Ant (Hatcher and Loughran, Manning, 2002) was written for Ant 1.5, JDK 1.3 and 1.4 and JUnit 3. Its chapter on
testing is built around the junit task and around TestCase subclasses, and the task itself is still there and still works
for exactly that. What the book cannot know: JUnit 4 (annotation-based tests, which the junit task does run), JUnit 5 and 6 (supported only by
junitlauncher, from Ant 1.10.3), tags, engines, fork for junitlauncher, listeners instead of formatters, the removal of the
SecurityManager (Java 18+, which makes the permissions element of junit non-functional and System.exit() in a
non-forked test fatal for the build), and the need for Java 8 or newer for Ant 1.10. Its practice of installing every
optional jar into one lib directory corresponds to the second way in the list of ways to make the jars visible, now not the recommended one.
For the full list of statements in the book that no longer hold see
Deprecations & Migration. The book is
a publication of Manning: the second edition, Ant in Action, is on the publisher’s page.
For the release-by-release changes of Ant itself see Versions & Release History.