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")