Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,16 +130,20 @@ 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<br/>iterations back into one result
rb-->>neotest: results, shown in the UI
```

**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.

Expand Down
34 changes: 24 additions & 10 deletions lua/neotest-java/core/result_builder.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 <string, neotest-java.JunitResult[]>
local function group_by_method_base(testcases)
local groups = {}
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions lua/neotest-java/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions tests/unit/test_result_builder_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -157,6 +158,125 @@ 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("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")
Expand Down
Loading