Skip to content

Commit

Permalink
feat: testify test suite support (#58)
Browse files Browse the repository at this point in the history
  • Loading branch information
fredrikaverpil authored Jul 11, 2024
1 parent df4e6b3 commit d723241
Show file tree
Hide file tree
Showing 20 changed files with 878 additions and 39 deletions.
36 changes: 29 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,14 @@ return {

## ⚙️ Configuration

| Argument | Default value | Description |
| ------------------------ | ------------------------------- | ----------------------------------------------------------------------------------------- |
| `go_test_args` | `{ "-v", "-race", "-count=1" }` | Arguments to pass into `go test`. |
| `dap_go_enabled` | `false` | Leverage [leoluz/nvim-dap-go](https://github.com/leoluz/nvim-dap-go) for debugging tests. |
| `dap_go_opts` | `{}` | Options to pass into `require("dap-go").setup()`. |
| `warn_test_name_dupes` | `true` | Warn about duplicate test names within the same Go package. |
| `warn_test_not_executed` | `true` | Warn if test was not executed. |
| Argument | Default value | Description |
| ------------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `go_test_args` | `{ "-v", "-race", "-count=1" }` | Arguments to pass into `go test`. |
| `dap_go_enabled` | `false` | Leverage [leoluz/nvim-dap-go](https://github.com/leoluz/nvim-dap-go) for debugging tests. |
| `dap_go_opts` | `{}` | Options to pass into `require("dap-go").setup()`. |
| `testify_enabled` | `false` | Enable support for [testify](https://github.com/stretchr/testify) suites. See [here](https://github.com/fredrikaverpil/#testify-suites) for more info. |
| `warn_test_name_dupes` | `true` | Warn about duplicate test names within the same Go package. |
| `warn_test_not_executed` | `true` | Warn if test was not executed. |

### Example configuration: custom `go test` arguments

Expand Down Expand Up @@ -354,6 +355,27 @@ and number of tests to run in parallel using the `-p` and `-parallel` flags,
respectively. Execute `go help test`, `go help testflag`, `go help build` for
more information on this.

### Testify suites

> [!WARNING]
> This feature comes with some caveats and nuances, which is why it
> is not enabled by default. I respectfully advise you to only enable this if
> you really need it.
There are some real shenaningans going on behind the scenes to make this work.
First, a lookup of "receiver type-to-suite test function" must be created of all
Go test files in your project. Then, the generated Neotest node tree needs to be
modified by mutating private attributes and merging of nodes to avoid
duplicates. I'm personally a bit afraid of the maintenance burden of this
feature...

> [!NOTE]
> Right now, there is no way to update the lookup other than restarting
> Neotest/Neovim. So in case you are implementing a new suite, please restart to
> see the new suites/tests appear in e.g. the summary window. Also, nested tests
> or table tests are not supported, but can be added at any time. Feel free to
> open a PR!
## 🙏 PRs are welcome

Improvement suggestion PRs to this repo are very much welcome, and I encourage
Expand Down
36 changes: 22 additions & 14 deletions lua/neotest-golang/ast.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

local lib = require("neotest.lib")

local options = require("neotest-golang.options")
local testify = require("neotest-golang.features.testify")

local M = {}

--- Detect test names in Go *._test.go files.
--- @param file_path string
function M.detect_tests(file_path)
local test_function = [[
M.test_function = [[
; query for test function
((function_declaration
name: (identifier) @test.name) (#match? @test.name "^(Test|Example)"))
Expand All @@ -21,13 +21,7 @@ function M.detect_tests(file_path)
@test.definition
]]

local test_method = [[
; query for test method
(method_declaration
name: (field_identifier) @test.name (#match? @test.name "^(Test|Example)")) @test.definition
]]

local table_tests = [[
M.table_tests = [[
;; query for list table tests
(block
(short_var_declaration
Expand Down Expand Up @@ -127,13 +121,27 @@ function M.detect_tests(file_path)
(#eq? @test.key.name @test.key.name1))))))))
]]

local query = test_function .. test_method .. table_tests
--- Detect test names in Go *._test.go files.
--- @param file_path string
function M.detect_tests(file_path)
local opts = { nested_tests = true }
local query = M.test_function .. M.table_tests

if options.get().testify_enabled == true then
-- detect receiver types (as namespaces) and test methods.
query = query
.. testify.query.namespace_query
.. testify.query.test_method_query
end

---@type neotest.Tree
local positions = lib.treesitter.parse_positions(file_path, query, opts)
local tree = lib.treesitter.parse_positions(file_path, query, opts)

if options.get().testify_enabled == true then
tree = testify.tree_modification.modify_neotest_tree(tree)
end

return positions
return tree
end

return M
Empty file.
8 changes: 8 additions & 0 deletions lua/neotest-golang/features/testify/init.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
local M = {}

M.lookup = require("neotest-golang.features.testify.lookup")
M.query = require("neotest-golang.features.testify.query")
M.tree_modification =
require("neotest-golang.features.testify.tree_modification")

return M
117 changes: 117 additions & 0 deletions lua/neotest-golang/features/testify/lookup.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
--- Lookup table for renaming Neotest namespaces (receiver type to testify suite function).

local find = require("neotest-golang.find")
local query = require("neotest-golang.features.testify.query")

local M = {}

--- TreeSitter query for identifying testify suites and their components.
--- @type string
M.query = [[
; query for the lookup between receiver and test suite.
; package main // @package
(package_clause
(package_identifier) @package)
; func TestSuite(t *testing.T) { // @test_function
; suite.Run(t, new(testSuitestruct)) // @suite_lib, @run_method, @suite_receiver
; }
(function_declaration
name: (identifier) @test_function (#match? @test_function "^Test")
body: (block
(expression_statement
(call_expression
function: (selector_expression
operand: (identifier) @suite_lib (#eq? @suite_lib "suite")
field: (field_identifier) @run_method (#eq? @run_method "Run"))
arguments: (argument_list
(identifier)
(call_expression
arguments: (argument_list
(type_identifier) @suite_struct)))))))
; func TestSuite(t *testing.T) { // @test_function
; s := &testSuiteStruct{} // @suite_struct
; suite.Run(t, s) // @suite_lib, @run_method
; }
(function_declaration
name: (identifier) @test_function (#match? @test_function "^Test")
parameters: (parameter_list
(parameter_declaration
name: (identifier)
type: (pointer_type
(qualified_type
package: (package_identifier)
name: (type_identifier)))))
body: (block
(short_var_declaration
left: (expression_list
(identifier))
right: (expression_list
(unary_expression
operand: (composite_literal
type: (type_identifier) @suite_struct
body: (literal_value)))))
(expression_statement
(call_expression
function: (selector_expression
operand: (identifier) @suite_lib (#eq? @suite_lib "suite")
field: (field_identifier) @run_method (#eq? @run_method "Run"))
arguments: (argument_list
(identifier)
(identifier))))))
]]

--- The lookup table.
--- @type table<string, table>
local lookup_table = {}

--- Get the current lookup table, generating it if empty.
--- @return table<string, table> The lookup table containing testify suite information
function M.get()
if vim.tbl_isempty(lookup_table) then
lookup_table = M.generate()
end
return lookup_table
end

--- Generate the lookup table for testify suites.
--- @return table<string, table> The generated lookup table
function M.generate()
local cwd = vim.fn.getcwd()
local filepaths = find.go_test_filepaths(cwd)
local lookup = {}
-- local global_suites = {}

-- First pass: collect all data for the lookup table.
for _, filepath in ipairs(filepaths) do
local matches = query.run_query_on_file(filepath, M.query)

local package_name = matches.package
and matches.package[1]
and matches.package[1].text
or "unknown"

lookup[filepath] = {
package = package_name,
replacements = {},
}

for i, struct in ipairs(matches.suite_struct or {}) do
local func = matches.test_function[i]
if func then
lookup[filepath].replacements[struct.text] = func.text
end
end
end

return lookup
end

--- Clear the lookup table.
function M.clear()
lookup_table = {}
end

return M
98 changes: 98 additions & 0 deletions lua/neotest-golang/features/testify/query.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
--- Helper functions around running Treesitter queries.

local parsers = require("nvim-treesitter.parsers")

local M = {}

M.namespace_query = [[
; query for detecting receiver type and treat as Neotest namespace.
; func (suite *testSuite) TestSomething() { // @namespace.name
; // test code
; }
(method_declaration
receiver: (parameter_list
(parameter_declaration
; name: (identifier)
type: (pointer_type
(type_identifier) @namespace.name )))) @namespace.definition
name: (field_identifier) @test_function (#match? @test_function "^(Test|Example)")
]]

M.test_method_query = [[
; query for test method
(method_declaration
name: (field_identifier) @test.name (#match? @test.name "^(Test|Example)")) @test.definition
]]

--- Run a TreeSitter query on a file and return the matches.
--- @param filepath string The path to the file to query
--- @param query_string string The TreeSitter query string
--- @return table<string, table> A table of matches, where each key is a capture name and the value is a table of nodes
function M.run_query_on_file(filepath, query_string)
local bufnr = vim.api.nvim_create_buf(false, true)
local content = vim.fn.readfile(filepath)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, content)

vim.api.nvim_set_option_value("filetype", "go", { buf = bufnr })

if not parsers.has_parser("go") then
error("Go parser is not available. Please ensure it's installed.")
end

local parser = parsers.get_parser(bufnr, "go")
local tree = parser:parse()[1]
local root = tree:root()

---@type vim.treesitter.Query
local query = vim.treesitter.query.parse("go", query_string)

local matches = {}

for pattern, match, metadata in
query:iter_matches(root, bufnr, 0, -1, { all = true })
do
for id, nodes in pairs(match) do
local name = query.captures[id]
for _, node in ipairs(nodes) do
M.add_match(matches, name, node, bufnr, metadata[id])
end
end
end

vim.api.nvim_buf_delete(bufnr, { force = true })

return matches
end

--- Add a match to the matches table
--- @param matches table<string, table> The table of matches to add to
--- @param name string The name of the capture
--- @param node TSNode The TreeSitter node
--- @param bufnr integer The buffer number
--- @param metadata? table Optional metadata for the node
function M.add_match(matches, name, node, bufnr, metadata)
if not matches[name] then
matches[name] = {}
end
table.insert(matches[name], {
name = name,
node = node,
text = M.get_node_text(node, bufnr, { metadata = metadata }),
})
end

--- Get the text of a TreeSitter node.
--- @param node TSNode The TreeSitter node
--- @param bufnr integer|string The buffer number or content
--- @param opts? table Optional parameters (e.g., metadata for a specific capture)
--- @return string The text of the node
function M.get_node_text(node, bufnr, opts)
local text = vim.treesitter.get_node_text(node, bufnr, opts) -- NOTE: uses vim.treesitter
if type(text) == "table" then
return table.concat(text, "\n")
end
return text
end

return M
Loading

0 comments on commit d723241

Please sign in to comment.