Keep an explicit JUnit owner when migrating a qualified assertTrue(x instanceof Y) - #1083
Draft
martinfrancois wants to merge 6 commits into
Draft
Conversation
A qualified assertTrue call was rewritten to an unqualified assertInstanceOf plus a static import. Java resolves a compatible same-class or inherited method before a static import, so a test that declares its own assertInstanceOf silently invoked application code instead of JUnit's assertion; the qualified input no longer established that JUnit owns the call. Keep an explicit owner whenever the input was qualified. A JUnit 5 selector is retained only when it names the Assertions class itself, spelled as a simple or fully qualified type name, since a subtype or an instance-typed selector can hide assertInstanceOf with a declaration of its own; those fall back to the unqualified call with a static import, byte-identical to what main emits today, and the selector's now-unused class import is removed. A qualified JUnit 4 call is rewritten to the fully qualified org.junit.jupiter.api.Assertions.assertInstanceOf, because a bare Assertions simple name can itself be shadowed by a member type, a field, or a same-package type. An input that was already unqualified stays unqualified, so a same-named local declaration can still capture it; that case is deliberately out of scope. No existing test expectation changes, because every upstream fixture uses a static import. New tests cover the hiding same-class, inherited, and subtype owners, the instance-typed selector, both JUnit 4 forms, and generic, nested, and array targets.
assertTrue(x instanceof Y)
4 tasks
4 tasks
martinfrancois
marked this pull request as draft
August 16, 2026 01:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Suggested review order: 37 of 52 (Score: 2)
Review first: openrewrite/rewrite-static-analysis#974
What's changed?
A call is qualified when the source writes a qualifier in front of the method name, as in
Assertions.assertTrue(...); the recipe's code calls that qualifier the owner. For such a call,AssertTrueInstanceofToAssertInstanceOfnow writes a qualifier in front of theassertInstanceOfcall it emits as well, so the emitted call resolves to JUnit'sassertInstanceOf, as the input call resolved to JUnit'sassertTrue.Before this change, the recipe emitted an unqualified
assertInstanceOfcall plus a static import oforg.junit.jupiter.api.Assertions.assertInstanceOffor every input call, qualified or not. An unqualified call binds to a method declared in or inherited by the enclosing class before a static import of that name is considered (JLS 6.5.7.1), so where the calling class has its ownassertInstanceOf, the emitted call runs that one.Before
Actual after the recipe
Using main today. It compiles, but the emitted call now runs the
assertInstanceOfmethod declared inTest, not JUnit's:Expected after the recipe
The emitted call runs JUnit's
assertInstanceOf:One rule now covers both the JUnit 4 and the JUnit 5 input:
assertInstanceOfplus a static import.Assertionsitself, as a simple or fully qualified name, keeps that qualifier. ThereassertInstanceOfnecessarily resolves to JUnit's declaration.org.junit.Assert, a subclass ofAssertions, a variable - is migrated to the fully qualifiedorg.junit.jupiter.api.Assertions.assertInstanceOf(...). Reusing such a qualifier is unsafe: a subclass can hideassertInstanceOfwith a static declaration of its own (JLS 8.4.8.2), and the simple nameAssertionscan resolve to a member type, a field, or a same-package type, whichqualifiedJUnit4NotCapturedByNestedAssertionsClassandqualifiedJUnit4DoesNotShadowSamePackageAssertionsHelperpin down. When dropping the qualifier leaves the import of the qualifier's own type unused, that import is now removed too.What's your motivation?
Recipe:
org.openrewrite.java.testing.junit5.AssertTrueInstanceofToAssertInstanceOf.The output that main produces today compiles, so nothing fails at build time; what changes is the behaviour at run time. Call
test("value")on the example above: against the input it returns normally, because"value" instanceof Stringis true and JUnit'sassertTruepasses, and against main's output it throwsAssertionError("wrong owner").Which of the two ways a real suite goes wrong depends on the calling class's own
assertInstanceOf. One that returns without checking makes the assertion vacuous, so the test keeps passing while no longer testing what it used to; one that throws, as here, fails the test with its own message instead of JUnit's. A migration must not change which method a call binds to. Reproduced on 3.43.0 and on a snapshot from main at 96ec8d6.Anything in particular you'd like reviewers to focus on?
No existing test expectation changed: the six existing tests are untouched, including the one marked
@DocumentExample, so the generatedMETA-INF/rewrite/examples.ymlneeds no edit. All six of those inputs callassertTruethrough a static import, with no qualifier, so the new handling of qualifiers never fires on them.Three limits:
assertInstanceOfdeclared in or inherited by the calling class still wins over the static import there. Qualifying those calls too is a separate decision, left out here.getAssertions().assertTrue(...), the receiver expression is dropped, so any side effect ofgetAssertions()is lost. The recipe already did that.orgis in scope at the call site, the fully qualified output does not compile, because the leadingorgthen resolves to that field or variable rather than to the package (JLS 6.5.2). This adds no new kind of failure: wherever such a name is in scope, every other fully qualified name starting withorg.is already unusable in the same way.Two behaviours here are inherited rather than new. A file carrying no type information matches neither of the recipe's two
MethodMatchers and is left alone, whichnoChangeWhenAssertTrueIsNotAttributedpins down; and a JUnit 5 call whose qualifier is not attributed fails theisAssertionsClassReferencecheck, so it takes the fully qualified path.Any additional context
This change adds 10 test methods to
AssertTrueInstanceofToAssertInstanceOfTest, taking the focused class from 6 to 16 executions. Those methods cover 16 scenarios. Without the code change, 8 methods fail and represent 14 failing scenarios.qualifiedCallsUseJupiterOwnercontains the same-class shadowing, JUnit 4, message, supplier, generic, nested and array cases in one source fixture. The other tests cover inherited shadowing, qualified subtypes and instances, same-package shadowing, stable output and missing attribution.This change was prepared with AI assistance (Claude Code). I reviewed the code, the tests and this description.
Checklist
./gradlew buildlocally, and committed any resulting changes torecipes.csv