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

ant-junit.jar (and ant-junit4.jar) in ANT_HOME/lib, plus junit.jar.

ant-junitlauncher.jar in ANT_HOME/lib, plus the JUnit Platform jars and the engines.

Test selection

test (one class), batchtest (a resource collection that is turned into class names).

test (one class, optionally some methods), testclasses (a resource collection of .class files).

Tags

Not available.

includeTags and excludeTags (since Ant 1.10.7).

Result files

formatter elements: plain, brief, xml, failure or a custom class.

listener elements: legacy-plain, legacy-brief, legacy-xml or any JUnit Platform TestExecutionListener.

Forking

fork, forkmode (perTest, perBatch, once), threads and many more attributes.

A nested fork element (since Ant 1.10.6), forkMode="perTestClass" since 1.10.14.

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.

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

junitlauncher is added, and junitreport can use Saxon for its XSL transformation.

1.10.4

The printSummary attribute is added.

1.10.6

The fork element, the outputDir attribute of listener, and the recommended setup with ant-junitlauncher.jar in ANT_HOME/lib and the other jars in a nested classpath. The task’s classes were renamed and moved to a new package; build files that only use the junitlauncher element are not affected.

1.10.7

includeTags and excludeTags.

1.10.10

The task generates its own summary (closely matching the junit task’s), and listener gets useLegacyReportingName.

1.10.13

listener gets extension.

1.10.14

fork gets java and forkMode="perTestClass"; outputDir of test and listener is now resolved against the project’s base directory instead of the current directory (Bugzilla 66504). The same release made Java 18 and newer run without a SecurityManager, so a non-forked System.exit() in a test ends the build.

1.10.15

The legacy-xml reporter stops using an unbuffered writer.

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

ant-junitlauncher.jar, delivered with Ant in ANT_HOME/lib.

The junitlauncher element itself.

JUnit Platform

junit-platform-commons, junit-platform-engine, junit-platform-launcher, opentest4j.

Every run, whatever the engine.

Jupiter engine

junit-jupiter-api, junit-jupiter-engine (and apiguardian-api, which the API jar refers to).

Tests written with org.junit.jupiter.api. The API jar is also on the compile classpath of the tests.

Vintage engine

junit-vintage-engine, junit (4.x; 4.13.2 here) and hamcrest-core (JUnit 4.13.2 depends on it).

Tests written with org.junit.Test.

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.

  1. Recommended since Ant 1.10.6. Leave ant-junitlauncher.jar where it is, in ANT_HOME/lib, and put every JUnit jar on the classpath nested in the junitlauncher element. Nothing outside the project has to change, and different projects can use different JUnit versions with the same Ant installation.

  2. Put the JUnit jars into ANT_HOME/lib together with ant-junitlauncher.jar. This pins every build on the machine to one JUnit.

  3. Keep ant-junitlauncher.jar in ANT_HOME/lib and pass the JUnit jars with the command-line option -lib (see Running Ant).

flowchart LR subgraph home["ANT_HOME/lib, loaded when Ant starts"] task["ant-junitlauncher.jar: defines the junitlauncher element"] end subgraph cp["Nested classpath of the junitlauncher element"] plat["JUnit Platform: commons, engine, launcher, opentest4j"] eng["Engines: jupiter api and engine, vintage engine, junit 4"] sut["Test classes, classes under test, their libraries"] end task -->|"builds a launcher request, loads classes from"| cp plat --> disc["ServiceLoader finds every engine on the classpath"] eng --> disc disc -->|"engines discover and run the tests in"| sut

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.

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

includeTags

Comma-separated JUnit tags; only tests with at least one of them run. Since Ant 1.10.7.

all tests

excludeTags

Comma-separated JUnit tags; tests with any of them are skipped. Since Ant 1.10.7.

none

haltOnFailure

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.

false

failureProperty

Name of a property to set when a test fails (exceptions in tests included).

not set

printSummary

Print the statistics line after each test class. Since 1.10.10 the task itself generates the line instead of using the platform’s.

false

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

classpath

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 ANT_HOME/lib.

test

One test class: name (required, fully qualified), methods (comma-separated method names to run), haltOnFailure, failureProperty, outputDir, if, unless, includeEngines, excludeEngines. Its own listener and fork children apply to this test.

testclasses

Any number of resource collections; every resource whose name ends in .class is passed to the platform. Attributes: haltOnFailure, failureProperty, outputDir, if, unless, includeEngines, excludeEngines. All the classes of one testclasses run in one launcher (and, when forked, in one JVM unless forkMode says otherwise).

listener

A result formatter or any TestExecutionListener; see the next section. Allowed inside junitlauncher (as a default for every test), test and testclasses.

fork

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

legacy-plain

A short statistics line for every test case, written to a .txt file.

legacy-brief

Details only for tests that failed or were skipped, written to a .txt file.

legacy-xml

The statistics and the output of the tests in XML, written to an .xml file, using the schema of the junit task’s XML formatter. That is what makes it usable as input of junitreport.

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

type

One of legacy-xml, legacy-plain, legacy-brief. Exactly one of type and classname is required.

not set

classname

A listener class.

not set

resultFile

File name of the result, relative to outputDir; only used for TestResultFormatter implementations.

TEST-testname.extension

extension

Extension of the result file. Since Ant 1.10.13.

xml for legacy-xml, txt otherwise

outputDir

Directory of the result, for this listener only. Since Ant 1.10.6.

the outputDir of the enclosing test/testclasses

sendSysOut, sendSysErr

Hand the captured standard output (error) of the tests to the listener. Only meaningful for TestResultFormatter implementations.

false

if, unless

Use the listener only when the named property is (not) set.

none

useLegacyReportingName

true reports JUnit 4 style test names, false reports the identifiers of the platform. Since Ant 1.10.10.

true

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

dir

Working directory of the forked JVM.

the project’s base directory

timeout

Maximum run time in milliseconds; the JVM is killed when it is exceeded. With forkMode="perTestClass" it is the time all classes use together, not one.

none

includeJUnitPlatformLibraries

Add the JUnit Platform jars that Ant knows about to the classpath of the forked JVM; when false the task’s classpath must contain them.

true

includeAntRuntimeLibraries

Add Ant’s own runtime jars to the classpath of the forked JVM; when false the task’s classpath must contain them.

true

java

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

forkMode

perTestClass starts one JVM per test class, one after the other. Since Ant 1.10.14.

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.

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.

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

tofile, todir

Name of the merged file and the directory it is written to.

TESTS-TestSuites.xml, current directory

fileset

The TEST-*.xml files to merge.

required

report format

frames (several files, index.html and friends) or noframes (one file, junit-noframes.html).

frames

report styledir

A directory with your own junit-frames.xsl or junit-noframes.xsl (the -saxon variants for Saxon 9 and newer).

the embedded stylesheets

report todir

Where the report is written.

current directory

param (nested in report)

An XSL parameter (name, expression, if, unless). The built-in stylesheets understand TITLE.

none

classpath, factory

Choose the XSLT processor, as for the xslt task. Since Ant 1.9.5.

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.

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

printsummary

on, off or withOutAndErr: a one-line statistic per test case.

off

fork

Run in a separate JVM. Required when the tests or their libraries call System.exit().

off

forkmode

perTest (a JVM per test class), perBatch (a JVM per batchtest and one for all test`s) or `once (a single JVM). Since Ant 1.6.2. Only tests that share the same filtertrace, haltonerror, haltonfailure, errorproperty and failureproperty can share a JVM.

perTest

haltonfailure, failureproperty

As in junitlauncher; failures include errors.

off, not set

haltonerror, errorproperty

The same for errors only.

off, not set

filtertrace

Strip JUnit and Ant frames from stack traces.

on

timeout

Cancel the tests after the given milliseconds. Ignored if fork is off.

none

maxmemory, jvm, dir, newenvironment, clonevm

Settings of the forked JVM.

ignored if fork is off

showoutput, outputtoformatters

Send the test output to Ant’s log (showoutput) and to the formatters (outputtoformatters).

false, true

reloading

A new class loader for each test case. Ignored if forked.

true

threads

Number of threads for the tests; requires fork with perTest. Since Ant 1.9.4.

none

includeantruntime

Add the Ant classes and JUnit to the classpath of a forked JVM.

true

tempdir, logfailedtests, enableTestListenerEvents

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.

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

Problem: failed to create task or type junitlauncher

ant-junitlauncher.jar not visible; put it in ANT_HOME/lib, ~/.ant/lib or -lib.

Failed to launch in-vm tests

A launcher problem (see -v for the cause: no engine, no launcher jar, missing output directory) or haltOnFailure after a failing test. Read the .txt report first.

BUILD SUCCESSFUL with Failures: 1 in the summary

Neither haltOnFailure nor failureProperty plus fail.

Tests run: 0, or the class is not mentioned

The class is not a test for any engine on the classpath, a tag filter excluded it, or the engine jar is missing. junit rejects Jupiter tests with initializationError.

NoClassDefFoundError inside a test

A runtime dependency (often transitive) of the code under test is missing from the task’s classpath.

System.exit() ends the build

The tests ran in Ant’s JVM; use fork.

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 classpath, listed by hand or resolved with Ivy or the Resolver tasks.

Surefire inspects the test dependencies and picks its JUnit Platform provider; the engine is a test-scope dependency.

tasks.test { useJUnitPlatform() } and the engine in testImplementation/testRuntimeOnly.

Select by tag

includeTags, excludeTags

groups, excludedGroups

useJUnitPlatform { includeTags("fast") }

Fork a JVM

fork, forkMode

forkCount, reuseForks

forkEvery, maxParallelForks

Fail the build

haltOnFailure, or failureProperty plus fail

fails by default (testFailureIgnore disables it)

fails by default (ignoreFailures disables it)

Reports

legacy-xml and junitreport

target/surefire-reports

build/test-results (XML) and build/reports/tests (HTML)

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.