In the competitive landscape of Java development, flakiness is the enemy of progress. You write a test, it passes locally, and then it fails intermittently once it hits CI — a different machine, a different thread schedule, the same code. These non-deterministic failures erode trust in the build and waste hours chasing a bug that was never really gone, just unlucky.
JUnit Jupiter’s answer to “does this actually work, or did it just work once” is @RepeatedTest: run the same test method N times and see what shakes loose. This post rebuilds the whole feature from scratch against real JUnit 6.1.3 on JDK 25 — what a repetition actually is, what JUnit injects into it, what the failureThreshold attribute really does when it fires, and three things the first version of this article got wrong or left out, each caught by compiling and running real code rather than trusting the prose.
Versions used in this post. JUnit Jupiter 6.1.3 (GA 2026-08-07, confirmed against Maven Central’smaven-metadata.xml), AssertJ 3.27.7, Maven Surefire 3.6.0, JDK 25 (Temurin). JUnit 6.0.0 shipped on 2025-09-30 and raised the minimum Java version to 17 — up from Java 8 for JUnit 5. None of that affects@RepeatedTestdirectly; the “what changed in JUnit 6” section near the end explains exactly why.
What @RepeatedTest actually does
A standard @Test method is one test: the engine calls it once, you get one pass or fail. @RepeatedTest changes what the method is. JUnit Jupiter stops treating it as a single test and starts treating it as a template — a recipe the engine invokes N times, producing N independent test executions that each get their own pass/fail result, their own lifecycle callbacks, and their own line in the test report.
That “own pass/fail outcome, own report line” distinction matters more than it looks. A for loop inside a single @Test stops at the first failed assertion and reports exactly one failure, with the loop’s remaining iterations never attempted. Five separate @RepeatedTest invocations keep going independently — which is the whole point when you are trying to find out how often something fails, not just whether it can.
The three rules the engine enforces
To discover and run a @RepeatedTest method, the engine needs it shaped a specific way:
- Visibility. The method must not be
privateorstatic. - Return type. The return type must be
void. - Exclusivity. It replaces
@Testoutright. Putting both annotations on the same method is not a combination — it is two conflicting instructions, and JUnit’s own@Testablemeta-annotation handling will run it under both execution models, which is confusing rather than useful.
The smallest working example
The simplest form takes just a number — how many times to run the method. Good enough for a first smoke test of something you suspect might be unstable:
package com.ankurm.tutorials.junit.repeated;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.TestInfo;
class ReliabilityTest {
@RepeatedTest(5)
void simpleRepeatTest(TestInfo testInfo) {
assertThat(true).isTrue();
DISPLAY_NAMES.add(testInfo.getDisplayName());
}
}
Full source, with the @AfterAll capture method that wrote the transcript below: ReliabilityTest.java.
Rather than assume what JUnit calls each repetition, the test asks: it injects TestInfo and records getDisplayName() for every run, then asserts the exact strings in @AfterAll. Running it produces this, verbatim from the committed transcript:
repetition 1 of 5
repetition 2 of 5
repetition 3 of 5
repetition 4 of 5
repetition 5 of 5
Captured output: 01-basic-repetition.txt. That default display-name pattern — "repetition {currentRepetition} of {totalRepetitions}" — is JUnit’s own RepeatedTest.SHORT_DISPLAY_NAME constant, not a guess; the next section shows how to override it, and where that override can go quietly wrong.
- Full module, including
pom.xmland every captured transcript: browse the repeated module. - Reference: RepeatedTest in the JUnit 5 Javadoc (the API is unchanged in 6.1.3 — see the migration section near the end).
Custom display names — and the placeholder that does not exist
The default "repetition N of M" name is fine for five runs in a terminal. It is useless scrolling through 50 repetitions in a Jenkins or GitHub Actions log, where every row needs to say what it actually tested. The name() attribute takes a pattern built from placeholders JUnit substitutes at run time.
There are exactly three real ones, confirmed by disassembling RepeatedTest.class with javap against the real 6.1.3 jar:
{displayName}— the method’s base display name, from@DisplayNameor the method name itself.{currentRepetition}— the 1-based index of the current invocation.{totalRepetitions}— the total count from the annotation.
package com.ankurm.tutorials.junit.repeated;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.TestInfo;
class CustomNameTest {
@RepeatedTest(value = 3, name = "{displayName} - Run {currentRepetition}/{totalRepetitions}")
@DisplayName("Critical API Integration")
void customNameTest(TestInfo testInfo) {
assertThat(1 + 1).isEqualTo(2);
DISPLAY_NAMES.add(testInfo.getDisplayName());
}
}
Full source: CustomNameTest.java. Real captured display names from that pattern:
Critical API Integration - Run 1/3
Critical API Integration - Run 2/3
Critical API Integration - Run 3/3
Captured output: 02-custom-display-name.txt.
There is no fourth placeholder. An earlier version of this post listed{shortDisplayname}as a real option, described as “a shortened version of the name.” It does not exist on the interface —javapagainst the 6.1.3 jar shows only the three constants above. The compiler does not catch this:name()takes a plainString, so an unrecognized token like{shortDisplayname}compiles cleanly and then appears completely unresolved, literally, in every repetition’s display name — proven by running it for real:{shortDisplayname} test(repetition 1),{shortDisplayname} test(repetition 2) — the literal braces and all, both times. Full transcript: 03-bogus-placeholder-scratch.txt.
- If you want the shortened method-only name without the class prefix, build it yourself from
{displayName}— there is no built-in placeholder for it. - Reference: the real constant names are
DISPLAY_NAME_PLACEHOLDER,CURRENT_REPETITION_PLACEHOLDER,TOTAL_REPETITIONS_PLACEHOLDERon RepeatedTest —SHORT_DISPLAY_NAMEandLONG_DISPLAY_NAMEon the same class are full pattern constants, not placeholders, which is an easy name to misread.
Making the test itself repetition-aware
Sometimes the test logic needs to know which repetition it is — log progress only on the last run, or feed a different input on each pass. JUnit injects a RepetitionInfo parameter for exactly this:
package com.ankurm.tutorials.junit.repeated;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.RepetitionInfo;
class MetadataTest {
@RepeatedTest(value = 3, failureThreshold = 2)
void testWithMetadata(RepetitionInfo info) {
int current = info.getCurrentRepetition();
int total = info.getTotalRepetitions();
assertThat(total).isEqualTo(3);
assertThat(current).isBetween(1, total);
assertThat(info.getFailureCount()).isZero();
assertThat(info.getFailureThreshold()).isEqualTo(2);
LINES.add("repetition " + current + " of " + total
+ " -- getFailureCount()=" + info.getFailureCount()
+ " getFailureThreshold()=" + info.getFailureThreshold());
}
}
Full source: MetadataTest.java. Real output across all three repetitions:
repetition 1 of 3 -- getFailureCount()=0 getFailureThreshold()=2
repetition 2 of 3 -- getFailureCount()=0 getFailureThreshold()=2
repetition 3 of 3 -- getFailureCount()=0 getFailureThreshold()=2
Captured output: 04-repetition-info-metadata.txt.
RepetitionInfo carries four things, not two.getCurrentRepetition()andgetTotalRepetitions()have been there since JUnit 5.5.getFailureCount()andgetFailureThreshold()arrived in JUnit 5.10 — the same release that addedfailureThreshold()itself — and let a test introspect its own threshold configuration instead of duplicating the number in two places.getFailureCount()stays at zero here because nothing in this test actually fails; the failure-threshold section below shows it climbing for real.
- Verified by
javap:RepeatedTest.classandRepetitionInfo.classare byte-for-byte identical across JUnit Jupiter 5.10.1 and 6.1.3 — nothing here changed going into JUnit 6. getFailureCount()/getFailureThreshold()are absent from 5.9.3’s class file and present from 5.10.1 onward, confirming the 5.10 introduction date precisely.
Lifecycle: what runs once, what runs every time
Each repetition is a distinct test execution, which means the standard JUnit lifecycle hooks apply per-repetition, not per-method. Get this wrong and “repetition 2” silently inherits state “repetition 1” left behind — a false pass or a false failure that has nothing to do with the thing you’re actually testing.
| Annotation | Frequency | Purpose |
|---|---|---|
@BeforeAll | Once, before the first repetition | Expensive one-time setup (e.g. starting a shared resource) |
@BeforeEach | Every repetition | Fresh setup for isolation |
@RepeatedTest | N times | The actual test logic |
@AfterEach | Every repetition | Cleanup of temporary data |
@AfterAll | Once, after the final repetition | Teardown of the shared resource |
Real captured order and totals from a @RepeatedTest(3) method, with RepetitionInfo injected into @BeforeEach and @AfterEach directly — not only into the test method itself:
@BeforeAll
@BeforeEach (repetition 1)
@RepeatedTest (repetition 1)
@AfterEach (repetition 1)
@BeforeEach (repetition 2)
@RepeatedTest (repetition 2)
@AfterEach (repetition 2)
@BeforeEach (repetition 3)
@RepeatedTest (repetition 3)
@AfterEach (repetition 3)
@AfterAll
Totals -- @BeforeAll: 1, @AfterAll: 1, @BeforeEach: 3, @AfterEach: 3
Full source and captured output: LifecycleTest.java, transcript at 05-lifecycle-order.txt.
RepetitionInfois not limited to the test method. It injects into@BeforeEachand@AfterEachthe same way, which is useful for setup that genuinely varies by repetition — seeding a different fixed value on repetition 3, say — without threading that logic through the test body itself.
- “Repetition 2 is not affected by anything repetition 1 left behind” only holds if your
@BeforeEach/@AfterEachactually reset the state you care about — a shared mutable static field initialized once in@BeforeAlldefeats this by design.
Detecting a flaky test for real, then failing fast with failureThreshold
The actual use case for repeating a test isn’t a fixed number picked out of thin air — it’s hunting an intermittent failure you can’t reproduce on demand. To keep this module reproducible rather than dependent on real timing, both demos below fail on fixed, known repetition numbers (4 and 8 of 10) instead of a real race condition — the deterministic failure stands in for whatever intermittent condition you’re actually chasing.
Run ten repetitions with no failureThreshold set, and all ten run to completion even though two of them fail:
โ โโ repetition 1 of 10 โ
โ โโ repetition 2 of 10 โ
โ โโ repetition 3 of 10 โ
โ โโ repetition 4 of 10 โ simulated race condition reproduced on repetition 4
โ โโ repetition 5 of 10 โ
โ โโ repetition 6 of 10 โ
โ โโ repetition 7 of 10 โ
โ โโ repetition 8 of 10 โ simulated race condition reproduced on repetition 8
โ โโ repetition 9 of 10 โ
โ โโ repetition 10 of 10 โ
[ 10 tests found ]
[ 0 tests skipped ]
[ 8 tests successful ]
[ 2 tests failed ]
Full transcript (console-tree output from a real run): 06-flaky-detected.txt. This is the actual point of detection — two failures out of ten runs is the fingerprint of a flaky test, not a broken one. A single @Test would have shown you one of these outcomes, at random, and told you nothing about the other nine.
Once you’ve confirmed a test is flaky rather than broken, running all ten every time wastes CI minutes on a result you already know. failureThreshold, added in JUnit 5.10, caps that:
@RepeatedTest(value = 10, failureThreshold = 2)
void intermittentRaceCondition(RepetitionInfo info) {
int current = info.getCurrentRepetition();
if (current == 4 || current == 8) {
fail("simulated race condition reproduced on repetition " + current);
}
}
This method and its no-threshold twin above are deliberately not part of the committed test tree — they exist only to produce the two transcripts quoted on this page, then get deleted so the permanent suite stays green. Their exact source is still real and still runnable: both are embedded verbatim in scripts/run-all.sh, which regenerates every transcript in this section on demand.
Real output from that exact class, with the threshold set:
โ โโ repetition 1 of 10 โ
โ โโ repetition 2 of 10 โ
โ โโ repetition 3 of 10 โ
โ โโ repetition 4 of 10 โ simulated race condition reproduced on repetition 4
โ โโ repetition 5 of 10 โ
โ โโ repetition 6 of 10 โ
โ โโ repetition 7 of 10 โ
โ โโ repetition 8 of 10 โ simulated race condition reproduced on repetition 8
โ โโ repetition 9 of 10 โท Failure threshold [2] exceeded
โ โโ repetition 10 of 10 โท Failure threshold [2] exceeded
[ 10 tests found ]
[ 2 tests skipped ]
[ 6 tests successful ]
[ 2 tests failed ]
Full transcript: 07-failure-threshold-skips-remaining.txt.
It skips the remaining repetitions — it does not just “stop.” An earlier version of this post described this as “JUnit stops further executions,” which undersells what actually happens: repetitions 9 and 10 above are never invoked at all, and the test report shows them as Skipped, distinct from both passed and failed. That distinction matters if your CI dashboard or a JUnit XML consumer treats skipped and failed differently — a threshold-tripped run shows 2 failures and 2 skips, not 10 failures, which is a meaningfully different signal when you’re triaging a build.
- This saves real CI time — once you know a test fails more than a handful of times out of N, running the rest tells you nothing new.
info.getFailureCount()(section above) lets the test itself see how close it is to that threshold, without duplicating the number.- Javadoc confirms the exact wording: once the configured number of failures is reached, “remaining repetitions will be automatically skipped.” Source: the
RepeatedTest.javaJavadoc in the 6.1.3 sources jar.
When to actually reach for this
It’s a precise tool, not a default. The cases where repeating a test earns its keep:
- Race conditions. Multi-threaded code —
CompletableFuture, parallel streams, thread pools — where timing differences cause intermittent failures that a single run can’t reveal. - Randomness. Logic built on
Math.random(),SecureRandom, or a fixture library, to confirm no particular generated value crashes it. - Idempotency checks. Confirming that calling an API or function repeatedly with the same input produces the same result, with no accumulating side effect.
- Resource leaks. Running a test 50+ times to see whether memory, file handles, or connections grow monotonically instead of staying flat.
What it’s not for: a substitute for fixing a genuinely broken test, or a way to paper over a non-deterministic production bug by hiding it behind “well, it passes most of the time.” Section above shows exactly why — failureThreshold exists to stop wasting CI time on a known-flaky test, not to make flakiness acceptable.
What actually changed going from JUnit 5 to JUnit 6
JUnit 6.0.0 shipped on 2025-09-30 as a deliberately disruptive major release, confirmed against the official release notes rather than a changelog summary. The two changes most likely to affect you:
| Change | Detail |
|---|---|
| Minimum Java version | 17, up from Java 8 for JUnit 5 — this alone blocks an in-place upgrade on an older project |
| Unified versioning | Platform, Jupiter and Vintage artifacts now share one release number — confirmed against the real maven-metadata.xml for junit-jupiter, junit-bom and junit-platform-launcher, all at 6.1.3 |
| Vintage engine | Deprecated, not yet removed — plan the JUnit 4 migration, don’t rush it |
| Removed APIs | junit-platform-runner, junit-platform-jfr, and MethodOrderer.Alphanumeric are gone outright; @CsvSource/@CsvFileSource moved to a FastCSV-backed implementation |
| Build tooling | Maven Surefire/Failsafe below 3.0.0 are no longer supported |
None of that touches @RepeatedTest or RepetitionInfo directly. Disassembling both class files with javap and diffing the output across Jupiter 5.10.1 and 6.1.3 shows them byte-for-byte identical. The entire “JUnit 5 → 6” story for this specific feature is a version-number bump plus whatever your build already needs for JDK 17+: bump junit.jupiter.version to 6.1.3 (or pull it from junit-bom, now that it shares the number), confirm maven.compiler.release is 17 or higher, and nothing in your @RepeatedTest code needs to change at all.
If your codebase has other JUnit 4 holdovers, or depends on Testcontainers alongside JUnit 6, see Migrating to Testcontainers 2.0: Artifact Renames, Package Moves and JUnit 6 for the parts of a JUnit 6 migration that are not this simple — specifically what happens when a dependency still assumes GenericContainer implements org.junit.rules.TestRule.
- Official source: JUnit 6.0.0 release notes.
- This module’s
pom.xmlpinsmaven.compiler.release=25andjunit.jupiter.version=6.1.3directly — see pom.xml.
Should you reach for this at all? Only once you have a specific suspicion — a race condition, a random-input edge case, a leak you want to see accumulate. Running everything 10 times “just in case” triples your CI time for no signal; running one specific suspect test 50 times with RepetitionInfo wired in to log what’s different on the failing runs is how you actually find the bug. And once you’ve found it and fixed the underlying cause, delete the repetition — it was a diagnostic tool, not a permanent feature of the test.
@RepeatedTest is more than a convenient loop: it’s a structured way to ask “how often,” not just “does it,” and RepetitionInfo plus failureThreshold turn that into something you can act on in CI without burning unlimited time on a test you already know is flaky.
Further reading
- Companion module for this post, including every transcript quoted above: repeated in the JUnit_Tutorials repository (download the whole repository as a zip).
- Migrating to Testcontainers 2.0: Artifact Renames, Package Moves and JUnit 6 — the other half of a real JUnit 6 migration, where things are not just a version bump.
- ArchUnit for Spring Boot — another test that runs against real compiled bytecode rather than mocked behavior.
- Official reference: Repeated Tests in the JUnit 5 User Guide.
- Official reference: JUnit 6.0.0 release notes.
No Comments yet!