Skip to main content

JUnit @RepeatedTest in JUnit 6: RepetitionInfo, failureThreshold, and Real Flaky-Test Detection

JUnit’s @RepeatedTest runs the same method N times, each with its own pass/fail result. Rebuilt here against real JUnit 6.1.3 on JDK 25: all four RepetitionInfo accessors (not just two), RepetitionInfo injected into @BeforeEach/@AfterEach, what failureThreshold actually does when it fires (skips, not stops), a real flaky-test detection demo, and a placeholder the original version of this post claimed existed but never did.

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’s maven-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 @RepeatedTest directly; 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.

One template, five invocations @RepeatedTest(5) void simpleRepeatTest() rep 1 of 5 rep 2 of 5 rep 3 of 5 rep 4 of 5 rep 5 of 5 independent test execution own pass/fail outcome own @BeforeEach / @AfterEach own line in the test report Five rectangles, not a loop inside one test — each repetition can fail on its own without hiding the other four.

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 private or static.
  • Return type. The return type must be void.
  • Exclusivity. It replaces @Test outright. Putting both annotations on the same method is not a combination — it is two conflicting instructions, and JUnit’s own @Testable meta-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.xml and 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 @DisplayName or 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 — javap against the 6.1.3 jar shows only the three constants above. The compiler does not catch this: name() takes a plain String, 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_PLACEHOLDER on RepeatedTest — SHORT_DISPLAY_NAME and LONG_DISPLAY_NAME on 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() and getTotalRepetitions() have been there since JUnit 5.5. getFailureCount() and getFailureThreshold() arrived in JUnit 5.10 — the same release that added failureThreshold() 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.class and RepetitionInfo.class are 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.

AnnotationFrequencyPurpose
@BeforeAllOnce, before the first repetitionExpensive one-time setup (e.g. starting a shared resource)
@BeforeEachEvery repetitionFresh setup for isolation
@RepeatedTestN timesThe actual test logic
@AfterEachEvery repetitionCleanup of temporary data
@AfterAllOnce, after the final repetitionTeardown of the shared resource
Call order across 3 repetitions @BeforeAll repetition 1 @BeforeEach @RepeatedTest @AfterEach repetition 2 @BeforeEach @RepeatedTest @AfterEach repetition 3 @BeforeEach @RepeatedTest @AfterEach (start) @BeforeAll/@AfterAll bracket the whole method once; @BeforeEach/@AfterEach repeat with it — 1 + 1 + (3 × 2) = 8 lifecycle calls for 3 repetitions.

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.

RepetitionInfo is not limited to the test method. It injects into @BeforeEach and @AfterEach the 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/@AfterEach actually reset the state you care about — a shared mutable static field initialized once in @BeforeAll defeats 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.

failureThreshold = 2, ten repetitions 1 2 3 4 FAIL #1 5 6 7 8 FAIL #2 9 10 skipped — never invoked The 2nd failure (repetition 8) reaches the threshold; 9 and 10 are reported Skipped, not run and not failed.

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.java Javadoc 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:

  1. Race conditions. Multi-threaded code — CompletableFuture, parallel streams, thread pools — where timing differences cause intermittent failures that a single run can’t reveal.
  2. Randomness. Logic built on Math.random(), SecureRandom, or a fixture library, to confirm no particular generated value crashes it.
  3. Idempotency checks. Confirming that calling an API or function repeatedly with the same input produces the same result, with no accumulating side effect.
  4. 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:

ChangeDetail
Minimum Java version17, up from Java 8 for JUnit 5 — this alone blocks an in-place upgrade on an older project
Unified versioningPlatform, 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 engineDeprecated, not yet removed — plan the JUnit 4 migration, don’t rush it
Removed APIsjunit-platform-runner, junit-platform-jfr, and MethodOrderer.Alphanumeric are gone outright; @CsvSource/@CsvFileSource moved to a FastCSV-backed implementation
Build toolingMaven 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.

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

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.