Skip to content

feat(lang-java): Java test selection with JUnit 5, Maven and Gradle - #31

Open
itaywol wants to merge 2 commits into
mainfrom
feat/lang-java
Open

feat(lang-java): Java test selection with JUnit 5, Maven and Gradle#31
itaywol wants to merge 2 commits into
mainfrom
feat/lang-java

Conversation

@itaywol

@itaywol itaywol commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Adds testless-lang-java: JUnit 5 test selection for Maven and Gradle projects, plus the two core generalizations it needed.

Verified end to end on two real repos — google/gson (multi-module Maven) and spring-projects/spring-boot (460-module Gradle, 8,662 files, 17,720 tests).

Core changes

Package scope is now a key, not a directory. Language::package_scoped() -> bool becomes Language::package_key(file) -> Option<PathBuf>. A Java package is not a directory: Maven and Gradle split one package across src/main/java and src/test/java, which is exactly where a class and its unit test live, referencing each other with no import statement. Keying on the directory would miss the single most important edge in a Java graph. Go keys on its directory as before; TS and Rust stay file-scoped by default.

Runner selection no longer follows from the language id. Java is driven by Maven or Gradle depending on the nearest build file, so runner_for_lang(lang) becomes runner::runner_for(lang, module, repo). It sniffs pom.xml / build.gradle at the test's own module and then the repo root, with a java-runner override in testless.toml. Commands are scoped per build module (mvn -pl, gradle :mod:test), and Maven gets -DfailIfNoTests=false so a narrowed selection doesn't fail every other module in the reactor.

Java plugin

JUnit 5 (@Test, @ParameterizedTest, @RepeatedTest, @TestFactory, @Nested), classes/interfaces/enums/records, method invocations, new X(), and field/parameter type references — the last being the edge that makes dependency-injected code visible at all.

Three decisions worth reviewing, each forced by a real repo rather than by the fixtures:

  • Import resolution is a per-repo FQN index, not a probe loop. walk's Unknown(name) widening is scoped to the forward import closure, so an unresolved cross-module import silently drops — an under-select. Resolution therefore has to actually succeed. The first implementation stat-probed every source root per import; on spring-boot's ~900 roots that is hundreds of millions of syscalls and never completes. Now built once per repo and memoized: 8.7s to index spring-boot.

  • Framework-annotated members parent to their class; inert annotations do not. A Spring @Bean method is invoked by the container, never by name, so with no containment edge a change to it reached zero tests. Excluding @Override and friends is load-bearing, not cosmetic: counting @Override as reflective took a gson leaf edit from 6 to 1501 of 1534 tests.

  • Fields are defs, named Class#field. A JUnit class builds its fixture in a field initializer and every test reaches it via this.contextRunner; without a field def the chain never reaches the tests. The # matters because the indexer keys on the segment after the last .Class.field indexes as bare field, so two test classes each holding a gson field cross-linked.

Measured

Repo Change Selected
spring-boot leaf utility method 62 / 17,720
spring-boot @Bean body 257 / 17,720
gson peripheral class 1,420 / 1,534

The gson number is sound but imprecise, and the cause is not Java-specific: tier-1 resolution matches on short names, so iterator.next() binds to every next in scope. Java shows it worst because package scope plus ubiquitous method names collide constantly. Tracked in the follow-up issue; not addressed here.

Notes for review

  • Known limits, both marked in code: a wildcard import resolves to one directory, so a package split across main and test contributes only its main half; and Class.forName(..).getMethod("plainName") against an unannotated method is not seen (always-run is the escape hatch).
  • TestNG is out of scope; JUnit 5 only.
  • 211 tests pass, clippy -D warnings clean.

itaywol added 2 commits August 2, 2026 15:25
Adds `testless-lang-java` and the two core generalizations it needed.

Package scope is now a key, not a directory. `Language::package_scoped`
becomes `Language::package_key`, because a Java package is not a
directory: Maven and Gradle split one package across `src/main/java` and
`src/test/java`, which is precisely where a class and its unit test live,
referencing each other with no import statement. Keying on the directory
would have missed the single most important edge in a Java graph. Go keys
on its directory as before; TS and Rust stay file-scoped by default.

Runner selection no longer follows from the language id. Java is driven
by Maven or Gradle depending on the nearest build file, so
`runner_for_lang(lang)` becomes `runner::runner_for(lang, module, repo)`,
sniffing `pom.xml` / `build.gradle` at the test's own module and then the
repo root, with a `java-runner` override in `testless.toml`. Commands are
scoped per build module (`mvn -pl`, `gradle :mod:test`), and Maven gets
`-DfailIfNoTests=false` so a narrowed selection doesn't fail every other
module in the reactor.

Two Java-specific decisions worth recording:

- Methods are parented to `<module>`, not to their class. `walk`
  reverse-propagates `Contains` as behavioral embedding, so parenting a
  method to its class would make any method-body edit imply "the class
  changed", pulling in every test that so much as writes `new Calc()`.
  Where a class genuinely runs its own method (a field initializer, a
  static block) that is already an ordinary `Calls` ref, so nothing real
  is lost.
- `resolve_import` genuinely resolves rather than giving up outside the
  current module. `Unknown(name)` widening is scoped to the forward
  import closure, so an unresolved cross-module import silently drops
  instead of widening: an under-select, which the selection contract does
  not allow. Source roots are discovered once per repo and memoized.

Verified on google/gson (multi-module Maven, 264 files, 1534 tests):
editing a private helper in `JavaVersion` selects 6 tests, all in
`JavaVersionTest`; editing `LinkedTreeMap.get` widens to 1433.
Running the plugin against two real repos surfaced one performance
failure and two selection defects that the fixture tests could not.

Import resolution was quadratic. `resolve_import` stat-probed every
source root per import. spring-boot is ~8.7k Java files across ~460
Gradle modules, so ~900 roots, which is hundreds of millions of syscalls
and never completes. Replaced with a fully-qualified-name index built
once per repo and memoized behind an `Arc`: indexing spring-boot now
takes 8.7s.

A Spring `@Bean` change selected zero of 17,720 tests. The previous
commit parented methods to `<module>` rather than to their class, to
avoid "any method edit implies the class changed". That is unsound for
container-invoked code: a `@Bean` method is called by the container, never
by name, so nothing reached it. Members carrying a *framework* annotation
now parent to their class. Inert annotations are excluded, and that
exclusion is load-bearing rather than cosmetic: `@Override` alone, treated
as reflective, took a gson leaf edit from 6 to 1501 of 1534 tests.

The class was still not enough, because the dependency lives on a field.
A JUnit class builds its fixture in a field initializer and every test
reaches it through `this.contextRunner`; with no def for the field, the
initializer's refs landed on the class, `Contains` only walks child to
parent, and no test was reached. Fields now get defs, and `this.<field>`
and bare-receiver reads are extracted. Field defs are named `Class#field`,
not `Class.field`, because the indexer keys on the segment after the last
`.`: the dotted form indexes as bare `field`, so two test classes each
holding a `gson` field cross-linked. A bare field name now resolves in its
own class, with a bare-name fallback so an inherited field is still found.

Also corrects the previous commit's message: the "6 of 1534" it cites for
gson was an under-select, not a success. The chain broke at the static
field `majorJavaVersion`, so `isJava9OrLater` reading it produced no edge.
`JavaVersion` really does feed `ReflectionAccessFilterHelper`, and so
nearly all of Gson's serialization; the wide answer is the correct one.

Measured after these fixes:
  spring-boot  leaf utility edit      62 of 17,720
  spring-boot  @bean body edit       257 of 17,720 (was 0)
  gson         peripheral class    1,420 of 1,534

The gson number is sound but imprecise, and the cause is not Java-specific:
tier-1 resolution matches on short names, so `iterator.next()` binds to
every `next` in scope. Java shows it worst because package scope plus
ubiquitous method names collide constantly. Fixing it needs qualifier-aware
resolution in core, which is left for follow-up.
@itaywol

itaywol commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Precision limitation measured on gson is tracked separately in #32 — not addressed in this PR.

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.

1 participant