Skip to content

[#771] Fix @RepeatedTest running N*N times in container mode#846

Draft
rhusar wants to merge 2 commits into
arquillian:mainfrom
rhusar:771-fix-repeated-test-container-mode
Draft

[#771] Fix @RepeatedTest running N*N times in container mode#846
rhusar wants to merge 2 commits into
arquillian:mainfrom
rhusar:771-fix-repeated-test-container-mode

Conversation

@rhusar
Copy link
Copy Markdown
Member

@rhusar rhusar commented May 19, 2026

Fixes #771

Summary by Sourcery

Ensure JUnit 5 @RepeatedTest methods execute container lifecycle only once per logical test rather than N×N times in container mode.

Bug Fixes:

  • Fix repeated test template handling so container-mode execution is triggered only for the first template invocation instead of all repetitions.

Tests:

  • Update JUnit 5 container integration tests to verify SPI lifecycle methods are invoked the correct number of times for @RepeatedTest scenarios.

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented May 19, 2026

Reviewer's guide (collapsed on small PRs)

Reviewer's Guide

Ensures JUnit 5 @RepeatedTest methods in container mode execute Arquillian SPI lifecycles exactly once per repetition instead of N*N, by correcting context storage for templates, skipping duplicate template invocations, wiring run mode in the test harness, and tightening tests around lifecycle invocations rather than JUnit’s test counters.

Sequence diagram for intercepting JUnit5 test templates in container mode

sequenceDiagram
    participant JUnitJupiter as JUnitJupiter
    participant ArquillianExtension as ArquillianExtension
    participant ContextStore as ContextStore
    participant Invocation as Invocation

    JUnitJupiter->>ArquillianExtension: interceptTestTemplateMethod(invocation, invocationContext, extensionContext)
    ArquillianExtension->>ContextStore: isRegisteredTemplate(invocationContext.getExecutable())
    ContextStore-->>ArquillianExtension: boolean

    alt template not registered
        ArquillianExtension->>ArquillianExtension: interceptInvocation(invocation, extensionContext)
    else template already registered
        ArquillianExtension->>Invocation: skip()
    end

    ArquillianExtension->>ArquillianExtension: throwError(result)
Loading

File-Level Changes

Change Details Files
Adjust container-mode JUnit 5 repeated test to correctly configure run mode and verify exact SPI lifecycle invocations.
  • Mock TestRunnerAdaptor.fireCustomLifecycle to handle RunModeEvent and force runAsClient=false in the container test harness.
  • Change the repeated test assertion to focus on lifecycle invocations instead of JUnit’s test counters.
  • Replace custom assertCycle helper usage with Mockito verify calls that assert the exact number of before/after/test lifecycle invocations for @RepeatedTest(3).
junit5/container/src/test/java/org/jboss/arquillian/junit5/container/JUnitJupiterRepeatedTestCase.java
Fix template-scoped context store so repeated tests share the correct store instead of nesting, avoiding N*N lifecycle execution in container mode.
  • Derive the template store from the parent ExtensionContext when present instead of always using the current context.
  • Keep using a dedicated namespace combining NAMESPACE_KEY and INTERCEPTED_TEMPLATE_NAMESPACE_KEY, but scoped to the template’s parent context.
junit5/core/src/main/java/org/jboss/arquillian/junit5/ContextStore.java
Prevent multiple container-mode executions of the same test template by skipping already-registered template invocations.
  • In the test template interceptor, after checking registration, call invocation.skip() for templates that are already registered.
  • Ensure only the first template invocation is intercepted and executed in container mode, with subsequent repetitions skipped at this interception layer.
junit5/core/src/main/java/org/jboss/arquillian/junit5/ArquillianExtension.java

Assessment against linked issues

Issue Objective Addressed Explanation
#771 Ensure that a JUnit 5 @RepeatedTest annotated test using ArquillianExtension executes only the requested number of repetitions (N), and not N*N times, when run in container mode.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • In ArquillianExtension.interceptTestTemplateMethod, once you detect a registered template and call invocation.skip(), consider returning immediately instead of falling through to throwError(result) so the control flow for the skipped path is clearer and doesn’t depend on the prior value of result.
  • The new fireCustomLifecycle stub in JUnitJupiterRepeatedTestCase mutates all RunModeEvents to runAsClient=false; consider scoping this behavior more narrowly or documenting why it is safe for all events in this test to avoid surprising interactions if additional event types are introduced in future test setups.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `ArquillianExtension.interceptTestTemplateMethod`, once you detect a registered template and call `invocation.skip()`, consider returning immediately instead of falling through to `throwError(result)` so the control flow for the skipped path is clearer and doesn’t depend on the prior value of `result`.
- The new `fireCustomLifecycle` stub in `JUnitJupiterRepeatedTestCase` mutates all `RunModeEvent`s to `runAsClient=false`; consider scoping this behavior more narrowly or documenting why it is safe for all events in this test to avoid surprising interactions if additional event types are introduced in future test setups.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@rhusar
Copy link
Copy Markdown
Member Author

rhusar commented May 19, 2026

Opening as draft, as I am not fully confident.

But in general, before the fix, getTemplateStore() used context.getStore(...) — the per-repetition context.
So repetition 1 registers the template in its own store, but repetitions 2 and 3 each have a fresh store and don't see it. Result: isRegisteredTemplate() returns false three times, adaptor.test() fires three times, and each server-side execution re-runs all 3 repetitions so 9 total.

After the fix, context.getParent().orElse(context) goes up to the method context, which is shared by all three repetitions. Repetition 1 registers there, repetitions 2 and 3 see it → adaptor.test() fires once.

Concerns that need to be evalauted: getParent() returning empty, @ParameterizedTest, @nested (tests already added and are passing) and invocation.skip() addition — without it, it would throw "Chain of InvocationInterceptors never called invocation" for the skipped repetitions

@jamezp @jasondlee @nlisker @mskacelik if you want to review/test/etc

@mskacelik
Copy link
Copy Markdown
Contributor

mskacelik commented May 19, 2026

Tested if beforeEach and afterEach behaved correctly with the fix, and they do. Regarding the @ParameterizedTest, I could try creating a new test PR if you want. Maybe there is the same issue as with the @RepeatedTest (given that its utilizing the TestTemplateMethod).

@rhusar
Copy link
Copy Markdown
Member Author

rhusar commented May 19, 2026

@mskacelik You know what that sounds good. You can just open a PR against my branch (rhusar:771-fix-repeated-test-container-mode).

verify(adaptor, times(1)).afterClass(any(Class.class), any(LifecycleMethodExecutor.class));
verify(adaptor, times(3)).before(any(Object.class), any(Method.class), any(LifecycleMethodExecutor.class));
verify(adaptor, times(3)).after(any(Object.class), any(Method.class), any(LifecycleMethodExecutor.class));
verify(adaptor, times(1)).test(any(TestMethodExecutor.class));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would the test only be executed once? Maybe I don't understand this test framework though.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jamezp Good question, my understanding is that in container mode - which was the reason why the test was passing before as it was in the client mode - adaptor.test() sends the test to the container via the ARQ protocol. The container executes the test method remotely - once.

So what is the alternative - the client side would call adaptor.test() for each of the 3 repetitions, and the container would execute just the method body once per call and NOT expand to the @RepeatedTest template. The problem we have now is that both are expanding so we do get N*N.

@mskacelik
Copy link
Copy Markdown
Contributor

mskacelik commented May 19, 2026

Ok I think I found some issue when I started to work on the ParameterizedTest test branch, and that is when the RepeatedTest purposely fails, for simplicity lets say three times in a row, it would have this format:

[INFO] Running org.example.SimpleRepeatedArqTest
...
>>> JUnit5 @RepeatedTest FAILING - thread: qtp1926673338-46
>>> JUnit5 @RepeatedTest FAILING - thread: qtp1926673338-46
>>> JUnit5 @RepeatedTest FAILING - thread: qtp1926673338-46
[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.876 s <<< FAILURE! -- in org.example.SimpleRepeatedArqTest
[ERROR] org.example.SimpleRepeatedArqTest.failingRepeatedTest -- Time elapsed: 0.091 s <<< FAILURE!
org.opentest4j.AssertionFailedError: Intentionally failing the test.
	...

May 19, 2026 8:45:53 PM org.jboss.arquillian.container.jetty.embedded_11.JettyEmbeddedContainer stop
INFO: Stopping Jetty Embedded Server [id:1758624236]
[INFO]
[INFO] Results:
[INFO]
[ERROR] Failures:
[ERROR] org.example.SimpleRepeatedArqTest.failingRepeatedTest
[ERROR]   Run 1: SimpleRepeatedArqTest.failingRepeatedTest:28 Intentionally failing the test.
[INFO]   Run 2: PASS
[INFO]   Run 3: PASS
[INFO]
[INFO]
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
...

Notice that RUN 2,3 are PASSING (maybe because of the skip?), before they were all failing:

[ERROR] Failures:
[ERROR] org.example.SimpleRepeatedArqTest.failingRepeatedTest
[ERROR]   Run 1: SimpleRepeatedArqTest.failingRepeatedTest:28 Intentionally failing the test.
[ERROR]   Run 2: SimpleRepeatedArqTest.failingRepeatedTest:28 Intentionally failing the test.
[ERROR]   Run 3: SimpleRepeatedArqTest.failingRepeatedTest:28 Intentionally failing the test.
[INFO]
[INFO]
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0

You can actually reproduce this if you put a failing test in the ClassWithArquillianExtensionAndRepeatedTest, than you would actually assert Assertions.assertEquals(3, result.getTestsFailedCount()); but the actual result is result.getTestsFailedCount() := 1.

rhusar added 2 commits May 20, 2026 10:59
Signed-off-by: Radoslav Husar <radosoft@gmail.com>
ContextStore.getTemplateStore() used the per-invocation ExtensionContext, so each repetition had its own template registry and isRegisteredTemplate() never saw prior registrations. Use the parent context store which is shared across repetitions. Store the TestResult from the first invocation and replay it for subsequent repetitions so that failures are correctly reported for all repetitions. Also skip the JUnit5 invocation for already-registered templates and enable the integration test.

Signed-off-by: Radoslav Husar <radosoft@gmail.com>
@rhusar rhusar force-pushed the 771-fix-repeated-test-container-mode branch from fa9b623 to 8056add Compare May 20, 2026 09:03
@rhusar
Copy link
Copy Markdown
Member Author

rhusar commented May 20, 2026

@mskacelik Nice catch! The results needs to be propagated in this scenario better, updated the branch with attempt to replay the result

@mskacelik
Copy link
Copy Markdown
Contributor

mskacelik commented May 21, 2026

@mskacelik Nice catch! The results needs to be propagated in this scenario better, updated the branch with attempt to replay the result

It's almost there. It seems that when at least one of the repeated tests fails, all attempts are marked as failures. However, that is incorrect. See:

@ExtendWith(ArquillianExtension.class)
class SimpleRepeatedArqTest {
    static int i = 0;

    @Deployment
    public static WebArchive createDeployment() {
        return ShrinkWrap.create(WebArchive.class, "repeated-test.war");
    }

    @RepeatedTest(3)
    void failingRepeatedTest() {
        boolean result = i++ % 2 == 0;
        System.out.println(">>> Result: " + result);
        Assertions.assertTrue(result);
    }
}

Test output:

[INFO] Running org.example.SimpleRepeatedArqTest
...
>>> Result: true
>>> Result: false
>>> Result: true
[ERROR] Tests run: 3, Failures: 3, Errors: 0, Skipped: 0, Time elapsed: 0.894 s <<< FAILURE! -- in org.example.SimpleRepeatedArqTest
[ERROR] org.example.SimpleRepeatedArqTest.failingRepeatedTest -- Time elapsed: 0.092 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
	...

[ERROR] org.example.SimpleRepeatedArqTest.failingRepeatedTest -- Time elapsed: 0.002 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
	...

[ERROR] org.example.SimpleRepeatedArqTest.failingRepeatedTest -- Time elapsed: 0.002 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
	...

May 21, 2026 1:52:03 PM org.jboss.arquillian.container.jetty.embedded_11.JettyEmbeddedContainer stop
INFO: Stopping Jetty Embedded Server [id:351877391]
[INFO]
[INFO] Results:
[INFO]
[ERROR] Failures:
[ERROR] org.example.SimpleRepeatedArqTest.failingRepeatedTest
[ERROR]   Run 1: SimpleRepeatedArqTest.failingRepeatedTest:24 expected: <true> but was: <false>
[ERROR]   Run 2: SimpleRepeatedArqTest.failingRepeatedTest:24 expected: <true> but was: <false>
[ERROR]   Run 3: SimpleRepeatedArqTest.failingRepeatedTest:24 expected: <true> but was: <false>
[INFO]
[INFO]
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0

Without ARQ (comment out @ExtendWith and Deployment block):

[INFO] Running org.example.SimpleRepeatedArqTest
>>> Result: true
>>> Result: false
>>> Result: true
[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.044 s <<< FAILURE! -- in org.example.SimpleRepeatedArqTest
[ERROR] org.example.SimpleRepeatedArqTest.failingRepeatedTest -- Time elapsed: 0.006 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
	...

[INFO]
[INFO] Results:
[INFO]
[ERROR] Failures:
[ERROR] org.example.SimpleRepeatedArqTest.failingRepeatedTest
[INFO]   Run 1: PASS
[ERROR]   Run 2: SimpleRepeatedArqTest.failingRepeatedTest:24 expected: <true> but was: <false>
[INFO]   Run 3: PASS
[INFO]
[INFO]
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0

@mskacelik
Copy link
Copy Markdown
Contributor

Created a testing repo with the reproducer, if you would like to test it out: https://github.com/mskacelik/arquillian-jetty-examples/

@mskacelik
Copy link
Copy Markdown
Contributor

mskacelik commented May 21, 2026

The IdentifiedTestException returned from the container (1st adaptor.test()) already contains a map of only the failed repetitions (and they should be identified with an index [test-template-invocation#N]). So maybe tracking which client-side repetition we're on and then looking up whether that specific index exists in the failure map could help?

@rhusar
Copy link
Copy Markdown
Member Author

rhusar commented May 21, 2026

@mskacelik Can you have a look at this comment above? #846 (comment) I believe that's what you are describing here. Basically, to get the behavior you are describing we would need to rework this to expand the template on the client, not the server.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[JUnit 5+] @RepeatedTest runs more times than required

4 participants