Skip to content

Use assertTimeoutPreemptively to preserve JUnit 4 timeout semantics - #2

Open
mattdepaula wants to merge 8 commits into
mainfrom
fix/update-test-annotation-timeout-preemptive
Open

Use assertTimeoutPreemptively to preserve JUnit 4 timeout semantics#2
mattdepaula wants to merge 8 commits into
mainfrom
fix/update-test-annotation-timeout-preemptive

Conversation

@mattdepaula

@mattdepaula mattdepaula commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Summary

UpdateTestAnnotation migrated @Test(timeout=N) to @Timeout(N), which silently changes failure semantics:

  • JUnit 4 (@Test(timeout=N)): test runs on a separate thread; runner unblocks after N ms via FutureTask.get(timeout) — a hanging test fails and the suite continues
  • JUnit 5 (@Timeout(N), default SAME_THREAD): sends Thread.interrupt() to the test thread; if the test never checks for interruption (infinite loop, deadlock), the runner thread blocks forever

This PR replaces the @Timeout annotation with assertTimeoutPreemptively(Duration.ofMillis(N), () -> { body }), which runs the body on a separate thread and preemptively fails — preserving JUnit 4's original semantics. The pattern mirrors the existing @Test(expected=...)assertThrows(...) body-wrap already implemented in this recipe.

Edge case handled: @Test(timeout=0) means "no timeout" in JUnit 4 (the default). The recipe skips the assertTimeoutPreemptively wrapping when timeout=0 and migrates the annotation only.

Upstream issue

openrewrite#1073

Behavior change

Before

// @Timeout SAME_THREAD — hanging test blocks runner thread forever
@Timeout(500)
public void test() { }

After

// assertTimeoutPreemptively — body on separate thread, preemptively fails after 500ms
@Test
public void test() {
    assertTimeoutPreemptively(Duration.ofMillis(500), () -> {
    });
}

Changes

  • UpdateTestAnnotation: replace @Timeout emission with two-step assertTimeoutPreemptively body-wrap (mirrors assertThrows pattern); skip wrapping when timeout=0
  • UpdateTestAnnotationTest:
    • annotationWithTimeout: updated expected output; added @Issue("#1073")
    • annotationWithTimeoutAndStatements: new test — non-empty method body is wrapped correctly
    • timeoutZeroIsNoOpInJUnit4SoAnnotationOnlyMigrated: new test — timeout=0 produces plain @Test, no body wrapping
    • annotationWithTimeoutAndException: updated expected output (nested assertThrows inside assertTimeoutPreemptively); added @Issue("#1073")

Test plan

  • annotationWithTimeout — empty body, 500ms timeout
  • annotationWithTimeoutAndStatements — non-empty body, 500ms timeout
  • timeoutZeroIsNoOpInJUnit4SoAnnotationOnlyMigratedtimeout=0 skips wrapping
  • annotationWithTimeoutAndException — combined expected + timeout, correct nesting
  • All pre-existing UpdateTestAnnotationTest tests continue to pass

…emantics

@test(timeout=N) in JUnit 4 runs the test on a separate thread and preemptively
aborts it via FutureTask.get(timeout). @timeout without threadMode defaults to
SAME_THREAD, which only sends Thread.interrupt() — a non-interruptible test
(infinite loop, deadlock) blocks the runner thread indefinitely.

Replace the @timeout annotation with assertTimeoutPreemptively(Duration.ofMillis(N),
() -> { body }), mirroring the existing @test(expected=...) → assertThrows pattern.
assertTimeoutPreemptively runs the body on a separate thread and preemptively
fails, preserving JUnit 4's preemptive-abort semantics.

Fixes openrewrite#1073
@timtebeek

Copy link
Copy Markdown

did you intend to open this as an upstream contribution as an alternative to

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.

2 participants