From 023aadd36e9436a4459c0404f282fe0e1d48edc6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ricardo=20Cas=C3=ADa?=
<31012661+rcasia@users.noreply.github.com>
Date: Wed, 30 Sep 2026 13:20:10 +0200
Subject: [PATCH 1/2] test(result_builder): reproduce single-test ID mismatch
---
tests/unit/test_result_builder_spec.lua | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/tests/unit/test_result_builder_spec.lua b/tests/unit/test_result_builder_spec.lua
index 7e24b549..06a94d7e 100644
--- a/tests/unit/test_result_builder_spec.lua
+++ b/tests/unit/test_result_builder_spec.lua
@@ -157,6 +157,30 @@ describe("ResultBuilder", function()
)
end)
+ it("matches a single report testcase without parentheses to the discovered method ID", function()
+ -- given — JUnit Vintage reports a plain method as "firstTestMethod",
+ -- while discovery identifies the node as "firstTestMethod()".
+ local file_path = Path("MyTest.java")
+ local tree = TREES.TWO_TESTS_IN_FILE(file_path)
+ local jrs = { jr(passing("firstTestMethod", "com.example.ExampleTest")) }
+
+ -- then
+ eq(
+ {
+ ["com.example.ExampleTest#firstTestMethod()"] = {
+ status = "passed",
+ output = TEMPNAME,
+ },
+ },
+ ResultBuilder({
+ scan_dir = scan_dir_returning(file_path),
+ junit_result_reader = reader_returning(jrs),
+ remove_file = remove_file,
+ tempname_fn = fake_tempname,
+ }).build_results(DEFAULT_SPEC, SUCCESSFUL_RESULT, tree)
+ )
+ end)
+
it("builds failed results when assertion message contains a greater-than character", function()
-- given
local file_path = Path("MyTest.java")
From 50f3502eaacd82ff895c9bc3e2998397dd640f9e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ricardo=20Cas=C3=ADa?=
<31012661+rcasia@users.noreply.github.com>
Date: Wed, 30 Sep 2026 14:06:21 +0200
Subject: [PATCH 2/2] fix(result_builder): match single-test results to
discovered IDs
---
ARCHITECTURE.md | 10 ++-
lua/neotest-java/core/result_builder.lua | 34 ++++++---
lua/neotest-java/types.lua | 2 +
tests/unit/test_result_builder_spec.lua | 96 ++++++++++++++++++++++++
4 files changed, 129 insertions(+), 13 deletions(-)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3329078a..764b247b 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -130,6 +130,7 @@ sequenceDiagram
rb->>rb: find TEST-*.xml report files
rb->>jr: parse each report into JunitResult objects
jr-->>rb: pass/fail/error + output per test
+ rb->>rb: match report IDs to discovered test nodes
rb->>rb: merge parameterized-test
iterations back into one result
rb-->>neotest: results, shown in the UI
```
@@ -137,9 +138,12 @@ sequenceDiagram
**In plain terms:** `core/result_builder.lua` looks for the
`TEST-*.xml` files JUnit wrote to the reports directory, and
`core/junit_result_reader.lua` + `model/junit_result.lua` parse them into
-pass/fail/error status with output and stack traces. One extra step: a
-single `@ParameterizedTest`/`@TestFactory` method produces *multiple*
-JUnit testcases (one per invocation) — `result_builder.lua` groups those
+pass/fail/error status with output and stack traces. The result builder
+matches report IDs to discovered test nodes, preferring an exact match
+and otherwise matching the class and method name without the parameter
+signature. This also handles single JUnit Vintage testcases whose names
+omit `()`. A single `@ParameterizedTest`/`@TestFactory` method produces
+*multiple* JUnit testcases (one per invocation) — `result_builder.lua` groups those
back together and merges them into a single result for the one tree node
neotest knows about, then deletes the temporary report files.
diff --git a/lua/neotest-java/core/result_builder.lua b/lua/neotest-java/core/result_builder.lua
index 29cf132f..e7123040 100644
--- a/lua/neotest-java/core/result_builder.lua
+++ b/lua/neotest-java/core/result_builder.lua
@@ -7,6 +7,28 @@ local clean_id = function(str)
return str:gsub("%(.*", "")
end
+--- @param tree neotest.Tree
+--- @param id string
+--- @return string | nil
+local function matching_test_id(tree, id)
+ if tree:get_key(id) then
+ return id
+ end
+
+ local method_base = clean_id(id)
+ return vim
+ .iter(tree:iter())
+ --- @param pos neotest.Position
+ :map(function(_, pos)
+ if pos.type == "test" then
+ return pos.id
+ end
+ end)
+ :find(function(pos_id)
+ return clean_id(pos_id) == method_base
+ end)
+end
+
--- @return table
local function group_by_method_base(testcases)
local groups = {}
@@ -70,17 +92,9 @@ local ResultBuilder = function(deps)
--- @type neotest-java.JunitResult
local jres = items[1]
- results[id] = jres:result()
+ results[matching_test_id(tree, id) or id] = jres:result()
else
- local _id = vim
- .iter(tree:iter())
- --- @param pos neotest.Position
- :map(function(_, pos)
- return pos.id
- end)
- :find(function(pos_id)
- return clean_id(pos_id) == clean_id(items[1]:id())
- end)
+ local _id = matching_test_id(tree, id)
if _id then
results[_id] = JunitResult.merge_results(items, deps.tempname_fn)
diff --git a/lua/neotest-java/types.lua b/lua/neotest-java/types.lua
index 597b26bc..305fa688 100644
--- a/lua/neotest-java/types.lua
+++ b/lua/neotest-java/types.lua
@@ -11,9 +11,11 @@
---@class neotest.Tree
---@field data fun(): neotest.Position
+---@field get_key fun(self: neotest.Tree, key: string): neotest.Tree?
---@field iter fun(): fun():integer[], neotest.Position
---@field to_list fun(): neotest.Position[]
---@field children fun(): neotest.Tree[]
+---@field parent fun(self: neotest.Tree): neotest.Tree?
---@field root fun(): neotest.Tree
---@class neotest.Error
diff --git a/tests/unit/test_result_builder_spec.lua b/tests/unit/test_result_builder_spec.lua
index 06a94d7e..a4aa485f 100644
--- a/tests/unit/test_result_builder_spec.lua
+++ b/tests/unit/test_result_builder_spec.lua
@@ -4,6 +4,7 @@ local JunitResult = require("neotest-java.model.junit_result")
local Path = require("neotest-java.model.path")
local eq = require("tests.assertions").eq
local TREES = require("tests.trees")
+local Tree = require("neotest.types").Tree
local current_dir = vim.fn.fnamemodify(vim.fn.expand("%:p:h"), ":p")
@@ -181,6 +182,101 @@ describe("ResultBuilder", function()
)
end)
+ it("preserves failure details when matching a single Vintage testcase to the selected method", function()
+ local id = "com.example.ExampleTest#firstTestMethod()"
+ local tree = Tree.from_list({
+ id = id,
+ name = "firstTestMethod",
+ path = tostring(Path("MyTest.java")),
+ range = { 2, 2, 5, 3 },
+ type = "test",
+ }, function(pos)
+ return pos.id
+ end)
+ local jrs = {
+ jr(
+ failing(
+ "firstTestMethod",
+ "com.example.ExampleTest",
+ "assertion failed",
+ "java.lang.AssertionError",
+ "trace"
+ )
+ ),
+ }
+
+ local result = ResultBuilder({
+ scan_dir = scan_dir_returning(Path("any/TEST-junit-vintage.xml")),
+ junit_result_reader = reader_returning(jrs),
+ remove_file = remove_file,
+ tempname_fn = fake_tempname,
+ }).build_results(DEFAULT_SPEC, { code = 1, output = "output" }, tree)
+
+ eq({
+ [id] = {
+ status = "failed",
+ short = "assertion failed",
+ errors = { { message = "assertion failed" } },
+ output = TEMPNAME,
+ },
+ }, result)
+ end)
+
+ for _, name in ipairs({ "parameterizedMethodShouldFail[0]", "parameterizedMethodShouldFail(Integer, Integer)[1]" }) do
+ it("matches a single parameterized invocation to the selected method: " .. name, function()
+ local tree = TREES.PARAMETERIZED_TEST
+ local jrs = { jr(passing(name)) }
+
+ local result = ResultBuilder({
+ scan_dir = scan_dir_returning(Path("any/TEST-parameterized.xml")),
+ junit_result_reader = reader_returning(jrs),
+ remove_file = remove_file,
+ tempname_fn = fake_tempname,
+ }).build_results(DEFAULT_SPEC, SUCCESSFUL_RESULT, tree)
+
+ eq({
+ [tree:data().id] = {
+ status = "passed",
+ output = TEMPNAME,
+ },
+ }, result)
+ end)
+ end
+
+ it("prefers an exact method ID when another method shares its base name", function()
+ local id = "com.example.ExampleTest#firstTestMethod(java.lang.String)"
+ local filepath = tostring(Path("MyTest.java"))
+ local tree = Tree.from_list({
+ { id = filepath, name = filepath, path = filepath, type = "file", range = { 0, 0, 20, 0 } },
+ {
+ id = "com.example.ExampleTest#firstTestMethod()",
+ name = "firstTestMethod",
+ path = filepath,
+ type = "test",
+ range = { 2, 2, 5, 3 },
+ },
+ {
+ id = id,
+ name = "firstTestMethod",
+ path = filepath,
+ type = "test",
+ range = { 7, 2, 10, 3 },
+ },
+ }, function(pos)
+ return pos.id
+ end)
+ local jrs = { jr(passing("firstTestMethod(java.lang.String)")) }
+
+ local result = ResultBuilder({
+ scan_dir = scan_dir_returning(Path("any/TEST-exact.xml")),
+ junit_result_reader = reader_returning(jrs),
+ remove_file = remove_file,
+ tempname_fn = fake_tempname,
+ }).build_results(DEFAULT_SPEC, SUCCESSFUL_RESULT, tree)
+
+ eq({ [id] = { status = "passed", output = TEMPNAME } }, result)
+ end)
+
it("builds failed results when assertion message contains a greater-than character", function()
-- given
local file_path = Path("MyTest.java")