Skip to content

[refactor] Replace XSuite with a JUnit Platform engine, remove JUnit 4, move test support to the test-jar - #6791

Merged
line-o merged 13 commits into
refactor/junit5-migrationfrom
refactor/exist-core-test-scope
Oct 6, 2026
Merged

line-o merged 13 commits into
refactor/junit5-migrationfrom
refactor/exist-core-test-scope

Conversation

@duncdrum

@duncdrum duncdrum commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #6790 (JUnit 4 to 5 migration); review that first.

Summary

Part of #6037. After #6790 the only thing keeping JUnit 4 alive was XSuite, the runner for the XQSuite and XML tests (a JUnit 4 ParentRunner). This PR replaces it with a JUnit Platform engine, converts all 37 suites, then removes JUnit 4 and the Vintage engine from the build and bans them. It also moves the test support code (org.exist.test.*) out of exist-core's production jar into its test-jar, so test libraries stop leaking to consumers and into the server distribution.

What changed

  1. Test libraries provided in exist-core, so the distribution stops shipping hamcrest, jupiter-api, platform-commons and opentest4j .
  2. TestEvents sink. The six XQuery callbacks report to a framework-neutral interface instead of a JUnit 4 RunNotifier.
  3. XQSuiteTestEngine and @XQSuite. One test per test function. A suite that cannot be discovered fails instead of vanishing; a test that was discovered but never reported fails instead of being silently dropped.
  4. All 37 suites converted (24 in exist-core, 14 extension modules); XSuite deleted.
  5. Parallel files and hang detection (below).
  6. JUnit 4 removed from the build, with an enforcer ban (a new JUnit 4 test would otherwise compile but never run). exist-ant gets a small helper instead of Ant's JUnit 4 BuildFileRule.
  7. Test support moved into the exist-core test-jar. The server fixtures, assertion helpers and test runners (26 files, moved with history) leave the production jar; the four test libraries become plain test scope. 15 modules gain the test-jar dependency (the two JMH modules at compile scope, since their benchmarks are main code), and exist-xqts gets it at runtime for the external XQTS runner, which also needs junit-jupiter-api and junit declared.
  8. Review and CI follow-ups: Codacy PMD findings resolved, assertTrue(x instanceof y) converted to assertInstanceOf with the static imports tidied, and the identical junit-platform.properties removed from lucene and restxq (their copy and the one from the exist-core test-jar made surefire warn that only the first of two files is used).
  9. JMH benchmarks compile again with -Pperf-tests. exist-core-jmh declared junit-jupiter-api with test scope and exist-indexes-jmh not at all, but the benchmarks (main code) use ExistEmbeddedServer, which now implements the Jupiter extension callbacks. CI does not build that profile, so it went unnoticed. Both modules now declare the API at compile scope, with the dependency analyzer ignores and the reason; checked with mvn compile dependency:analyze-only@analyze -pl exist-core-jmh,exist-indexes-jmh -am -Pperf-tests.

Same tests as the old runner

By console totals: exist-core 3,023 tests (39 skipped); the 14 extension modules 1,471 (61 skipped); 0 failures; identical module by module. Comparing the two found mismatches JUnit 4 had hidden, because it only counts tests that start: same-named test functions collapsed into one, hyphenated module prefixes, raw <task> text in XML test names, and modules that cannot be compiled without a database. All fixed.

Parallelism: what we found

Update: #6796 (stacked on this PR) addresses these findings: it gives test files their own collections and makes CoreTests and XQuery3Tests parallel, and records what still blocks the other suites.

@XQSuite(parallel = true) runs a suite's files concurrently (tests within a file stay sequential), capped by exist.xqsuite.parallelism. It is opt-in, because parallel is only safe for suites whose files do not share database state, and almost none are:

Suite Under parallel = true
NumericOpTests (12 files, no database access) Stable: 40 runs, 72/72 every time, up to 10 files at once. Now enabled.
CoreTests 147 to 150 failures
RangeTests 63 to 67 failures
LuceneTests 2 to 5 failures and errors per run
XQuery3Tests files drop and recreate the same collection
  • There is almost nothing to gain. The safe suites finish in about a second. And 150 of XQuery3Tests' 157 seconds are one file (transform/catalog.xql); the other 100 files take about 7 seconds together, so per-file parallelism cannot speed it up. Why that one file is slow is a separate question.
  • NumericOpTests is enabled to keep the feature exercised on a real suite in CI, not for speed.

Hang detection

Applies to every suite. A file that reports nothing for exist.xqsuite.hang.threshold.minutes (default 5) is failed with its name and the tests that were running; the others carry on (the old runner cancelled everything). The old runner's test never actually hung anything; the new ones use fixtures that really do.

Limit: giving up on a hung file is reliable, stopping its query is not. eXist only stops a query where it checks for being killed, and some loops never do. Such a thread keeps the database from shutting down, so the engine bounds the shutdown, fails the suite and skips the ones after it, instead of hanging as before.

Behaviour changes

  • XSuite and @XSuiteFiles are gone; use @XQSuite from the exist-core test-jar.
  • org.exist.test.* (ExistEmbeddedServer, ExistWebServer, ...) is no longer in the exist-core jar; external users need the exist-core test-jar (<type>test-jar</type>). The only external user found, the XQTS runner, is covered through exist-xqts.
  • Surefire now groups results under the suite class (for example XQSuiteTests) instead of xqts.org.exist-db.... Test names are unchanged.
  • Settings are JUnit configuration parameters: exist.xqsuite.parallelism, .hang.threshold.minutes, .hang.watcher.interval.seconds, .hang.grace.seconds.

Not in this PR

  • xmlunit-legacy: tests in exist-core, lucene and ngram still use XMLUnit 1 assertions, which extend JUnit 3's Assert, so those three modules keep junit:junit at test scope (the one documented exception to the ban). Migrating about 100 assertions changes comparison semantics and deserves its own review.

Test plan

  • Full reactor verify passes (compile, license, dependency analyzer, JUnit 4 ban)
  • All 37 suites: same counts as the old runner, 0 failures, re-verified from a clean build after the move (macOS)
  • exist-core integration tests and the XQTS runner's start-up checked on the moved code (isolated build)
  • 22 engine tests: scheduling, hang handling, bounded shutdown, settings
  • Test and documentation workflow green on Linux, macOS and Windows
  • XQTS workflow green
  • Rebased onto develop at 9c639a0ce6 on 2026-10-05 (no conflicts; VectorSearchTests keeps the query-vector-k.xqm that Fix ft:query-vector k being ignored / throwing past 341 docs #6741 added); reactor test-compile, and the Lucene module (951 tests) and the engine, runner, CoreTests and XQuery3Tests runs (2106 tests), all with 0 failures, run at the top of the stack ([test] Isolate XQSuite test files, run CoreTests and XQuery3Tests in parallel #6796)
  • mvn compile dependency:analyze-only@analyze -pl exist-core-jmh,exist-indexes-jmh -am -Pperf-tests passes

close #6037

🤖 Generated with Claude Code

https://claude.ai/code/session_01KqkMrHZx1uvZFuwvMrc3gj

@duncdrum
duncdrum added this pull request to stack #6792 October 1, 2026 10:20
@duncdrum duncdrum changed the title [refactor] Make exist-core's test-support libraries provided scope [refactor] Replace XSuite with a JUnit Platform engine; keep test libraries out of exist-core's runtime Oct 1, 2026
@duncdrum
duncdrum force-pushed the refactor/exist-core-test-scope branch from 8dfbf1a to 3c2102e Compare October 1, 2026 10:35
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

📊 XQTS result comparison

Comparison of this run against develop.

Metric develop this run Change
➖ Passed 28,867 (90.73%) 28,867 (90.73%) 0 (0.00 pp)
➖ Failures 1,547 1,547 0
➖ Errors 134 134 0
➖ Skipped 1,267 1,267 0
🧪 Total tests 31,815 31,815 0

Relative to develop: 0 newly passing, 0 newly failing, 0 new errors, 0 newly skipped — counting only tests recorded in both runs whose outcome changed.

Runtime: 439.7s (-60.66s vs develop).

@duncdrum duncdrum added this to v7.0.0 Oct 1, 2026
@duncdrum duncdrum added this to the eXist-7.0.0 milestone Oct 1, 2026
@duncdrum duncdrum added dependencies Pull requests that update a dependency file java Issues or pull requests that change Java code or are related to the JVM labels Oct 1, 2026
@duncdrum duncdrum changed the title [refactor] Replace XSuite with a JUnit Platform engine; keep test libraries out of exist-core's runtime [refactor] Replace XSuite with a JUnit Platform engine and remove JUnit 4 Oct 1, 2026
@duncdrum
duncdrum force-pushed the refactor/exist-core-test-scope branch from 6bbf1d7 to 41cbbc8 Compare October 1, 2026 13:11
@duncdrum duncdrum changed the title [refactor] Replace XSuite with a JUnit Platform engine and remove JUnit 4 [refactor] Replace XSuite with a JUnit Platform engine, remove JUnit 4, move test support to the test-jar Oct 1, 2026
@duncdrum
duncdrum marked this pull request as ready for review October 1, 2026 20:26
@duncdrum
duncdrum requested a review from a team as a code owner October 1, 2026 20:26
@duncdrum
duncdrum force-pushed the refactor/exist-core-test-scope branch from c55bb6c to 5b8b541 Compare October 1, 2026 20:45
@duncdrum
duncdrum force-pushed the refactor/exist-core-test-scope branch from 2d8ddb2 to 723f422 Compare October 5, 2026 12:14
@dizzzz
dizzzz requested review from a team, line-o and reinhapa October 5, 2026 19:08
duncdrum and others added 7 commits October 6, 2026 08:54
org.exist.test (server fixtures, assertion helpers, the XQSuite runner)
lives in exist-core's main source tree, so exist-core declared junit,
hamcrest, junit-jupiter-api, opentest4j and xmlunit-core at compile
scope. That leaked them transitively to every consumer and into the
server distribution.

Declare them provided instead: exist-core still compiles against them,
but they are no longer transitive. No production code outside
org.exist.test uses them, and modules that run tests already declare
what they use (the reactor build and dependency analyzer confirm it).
xmlunit-core stays in the distribution only via exist-xmldiff, which
declares it directly.

The distribution no longer ships hamcrest, junit-jupiter-api,
junit-platform-commons or opentest4j.

exist-xqts is the one consumer that relied on the transitive libraries
at runtime: the external exist-xqts-runner loads ExistEmbeddedServer
(which implements the Jupiter extension callbacks) and JUnit classes at
startup without declaring them, so exist-xqts now declares
junit-jupiter-api and junit explicitly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqkMrHZx1uvZFuwvMrc3gj
The XQuery side of an XQSuite or XML test run reports each test's outcome
by calling six small Java functions (started, finished, ignored, failure,
error, assumption-failed). They talked directly to a JUnit 4 RunNotifier
and built JUnit 4 Description and Failure objects.

Introduce a TestEvents sink and have the functions report to it instead.
RunNotifierTestEvents adapts it back to the RunNotifier, so XSuite behaves
exactly as before. This is the seam a JUnit Platform engine plugs into,
and removes the last JUnit 4 types from the callback functions.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqkMrHZx1uvZFuwvMrc3gj
Add XQSuiteTestEngine, a JUnit Platform TestEngine that runs XQSuite and
XML test files, as the replacement for the JUnit 4 XSuite runner. Classes
are marked with @XQSuite(paths...) instead of @RunWith(XSuite.class) plus
@XSuiteFiles. The engine reuses the existing XQuery runners through the
TestEvents sink, so test execution itself is unchanged.

The tree is suite class -> test file -> test. Each test function is its
own test, including functions that share a name within a file (the first
version collapsed them and reported 67 instead of 91 tests for
XQSuiteTests; the old runner and the engine now report identical test
names). Anything that would otherwise vanish is reported: a suite whose
files cannot be discovered fails instead of disappearing, and tests the
XQuery side never reports are failed rather than silently dropped.

XQSuiteTests is the first suite converted. XQSuiteTestEngineTest drives
the engine with EngineTestKit for a pass, assertion and error failures,
node results in failure messages, an empty file and a discovery failure.
The engine does not yet support parallel suites, the hang watcher, or
JUnit 4 style class rules; the remaining suites still use XSuite.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqkMrHZx1uvZFuwvMrc3gj
Convert the 37 suite classes in exist-core and 14 extension modules from
@RunWith(XSuite.class) to @XQSuite, and give the extension modules the
exist-core test-jar that carries the annotation and the engine.
HttpClientXqueryTests manages its WireMock server in @BeforeAll/@afterall
instead of a JUnit 4 class rule.

Comparing the engine with the old runner suite by suite found two places
where the old runner's discovery disagreed with the names the XQSuite
runtime reports. JUnit 4 hid this because it only counts tests that
start; the engine fails any test it discovered but never saw reported:

- A test without %test:name keeps its module prefix unless the prefix is
  purely word characters (xqsuite.xql, test:get-test-name), so a hyphenated
  prefix gives "prefix:name". Discovery now applies the same rule, in the
  compile path and in xquery-discovery.xq (which also ignored %test:name).
- XML tests are named from the raw text of the task element; discovery
  trimmed it. Discovery now keeps the raw text so test IDs are unchanged.

Discovery also runs through an embedded database (started only when a file
is not yet cached), as before, because some modules cannot be compiled
without one.

Result: exist-core 3023 tests, 39 skipped; the 14 extension modules 1471
tests, 61 skipped; zero failures. These are identical, module by module,
to the totals of the old runner.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqkMrHZx1uvZFuwvMrc3gj
All suites run on the JUnit Platform engine, so delete XSuite and the
adapter that bridged the XQuery callbacks to a JUnit 4 RunNotifier.
AbstractTestRunner and its XQuery and XML implementations are now plain
classes that report to TestEvents; InitializationError is replaced by
TestInitializationException, and TestRunners creates the runner for a file.

XSuite's parallel mode and hang watcher are not carried over: no suite
used @XSuiteParallel, only the runner's own test fixtures did.

The runner's self-tests move to the new API: suite and test names,
discovery through the database and by compiling giving identical names
(including a hyphenated module prefix), raw task text for XML test names,
and the engine tests now also cover navigating from a failure to the
XQuery file or the Java code.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqkMrHZx1uvZFuwvMrc3gj
Nothing runs on JUnit 4 any more, so remove it from the build and make
sure it cannot come back unnoticed. With the Vintage engine gone a new
JUnit 4 test would compile but silently never run.

- Remove the junit:junit test dependencies, the Vintage engine from the
  surefire plugin, and the 52 blanket junit exclusions that the migration
  recipe had spread over 42 poms. Exclusions remain only where a library
  really brings JUnit 4: ws-commons-util (compile scope, would otherwise
  ship in the distribution), greenmail and xmlunit-legacy.
- Add a ban-junit4 enforcer rule: junit:junit at compile, provided or test
  scope, the Vintage engine and junit-toolbox fail the build. Two
  documented exceptions override it per module: exist-xqts (the external
  XQTS runner brings junit through ant-junit and needs it at runtime) and
  exist-core, lucene and ngram, whose tests use xmlunit-legacy, whose
  XMLAssert and XMLTestCase extend junit.framework.Assert. Migrating those
  tests to XMLUnit 2 changes comparison semantics and is left for its own
  change.
- exist-ant: replace Ant's BuildFileRule (a JUnit 4 ExternalResource) with
  a small extension that configures the project, executes targets and runs
  the tearDown target as the rule did, and drop ant-testutil.
- Declare junit-platform-commons for the engine and suppress the
  supertype-only junit:junit in the dependency analyzer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqkMrHZx1uvZFuwvMrc3gj
Bring back the two features of the old XSuite runner, redesigned.

@XQSuite(parallel = true) runs the test files of a suite concurrently
against the one embedded database, at most exist.xqsuite.parallelism at a
time (default: the processor count, capped by the broker pool, between 2
and 32). The tests within a file stay sequential. It is opt-in because the
files of a suite share the database: XQuery3Tests, whose files drop and
recreate the same collection, fails with the option set and is not faster,
so no suite uses it yet.

Hang detection now covers every suite, sequential or not. Each file runs on
a thread of its own and a watcher fails a file that has reported nothing for
exist.xqsuite.hang.threshold.minutes (default 5). The failure names the file
and the tests that were running, the file's remaining tests are failed, its
query is killed and its thread interrupted, and the other files carry on
(the old runner cancelled everything). Whatever the abandoned thread reports
later is ignored.

Giving up on a file is reliable but stopping a spinning query is not: eXist
only stops queries where they check for being killed, and some loops, also
inside the test runner, never do. Such a thread keeps the database from
shutting down, so the engine bounds the shutdown, fails the suite loudly and
skips the suites after it instead of hanging.

The old runner's test for this never made anything hang. The new tests read
the engine's event timeline: files overlap if and only if run in parallel,
the parallelism setting caps them, a hung file is failed while the others
still run, in both modes, and it finishes exactly once. The bounded shutdown
and the settings are tested on their own.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqkMrHZx1uvZFuwvMrc3gj
duncdrum and others added 6 commits October 6, 2026 08:54
The first real suite to use @XQSuite(parallel = true). Its 12 files only
evaluate expressions on numbers: none touches the database or uses a
function with side effects, so they are independent by construction, not
just by chance.

Measured: 40 consecutive runs through the engine gave 72 of 72 passing
every time, and files ran concurrently in every run (up to 10 at once).

Other suites were tried and are not safe to run in parallel because their
files share database state: CoreTests (147 to 150 failures), RangeTests
(63 to 67), LuceneTests (2 to 5 failures and errors), XQuery3Tests (files
drop and recreate the same collection, and 150 of its 157 seconds are one
file, so it could not be faster anyway). DateTests, NumberTests and
UtilTests are also stable but too small to matter.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqkMrHZx1uvZFuwvMrc3gj
org.exist.test (the embedded and web server fixtures, assertion helpers
and the XQuery test runners) lived in exist-core's main tree, so it was
part of the production jar. Move it, and the three XQuery files the
runners load, to src/test; consumers get it from the exist-core test-jar.

With nothing in main using them, hamcrest, junit-jupiter-api, opentest4j
and xmlunit-core become plain test dependencies of exist-core. The 15
modules that used the fixtures without the test-jar now depend on it (the
two JMH modules at compile scope, because their benchmarks are main
code), and exist-xqts has it at runtime for the external XQTS runner.
Three javadoc links from main code to the moved classes become code
references, and the license plugin's paths for DiffMatcher follow it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqkMrHZx1uvZFuwvMrc3gj
Move RUNNERS up, throw IllegalStateException instead of the raw
RuntimeException, and spell out the NaN check that !(value > 0) was
doing: Double.parseDouble("NaN") succeeds, so a NaN threshold had to
stay rejected rather than become a 0ms threshold.

The four assert suppressions are false positives - those tests assert
through assertStatistics, which Codacy's PMD does not recognise.
… tidy static imports

Apply the JUnit5 AssertTrueInstanceofToAssertInstanceOf cleanup to the
affected test sources: rewrite assertTrue(x instanceof Y) to
assertInstanceOf(Y.class, x) and sort the JUnit Jupiter static import
block alphabetically, replacing any collapsed wildcard import with the
explicit set of Assertions methods actually used.

36 test files. exist-core test suite (7379 tests) and the mail/lucene/
restxq modules compile and test clean.
exist-core's test-jar already provides the identical file; with their own
copy these modules logged "Discovered 2 'junit-platform.properties'".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… classpath

exist-core-jmh and exist-indexes-jmh did not compile with -Pperf-tests: the
benchmarks (main code) use ExistEmbeddedServer, which since the test support
code moved into the exist-core test-jar implements the Jupiter extension
callbacks, so javac needs the Jupiter API to resolve its supertypes.
exist-core-jmh declared it with test scope and exist-indexes-jmh not at all.
CI does not build the perf-tests profile, so nothing noticed.

Both modules now declare junit-jupiter-api at compile scope. The dependency
analyzer sees it neither used by the main bytecode nor used by anything but
tests, so it is listed in the ignores for unused declared and for non-test
scoped dependencies, with the reason.

Checked with: mvn compile dependency:analyze-only@analyze -pl exist-core-jmh,exist-indexes-jmh -am -Pperf-tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@duncdrum
duncdrum force-pushed the refactor/exist-core-test-scope branch from 723f422 to cea85e3 Compare October 6, 2026 06:58

@line-o line-o left a comment

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.

Thank you very much!

@line-o
line-o merged commit 198dabc into develop Oct 6, 2026
16 checks passed
@line-o
line-o deleted the refactor/exist-core-test-scope branch October 6, 2026 10:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file java Issues or pull requests that change Java code or are related to the JVM

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[feature] begin junit migration

3 participants