leoluz / nvim-dap-go

An extension for nvim-dap providing configurations for launching go debugger (delve) and debugging individual tests
MIT License
486 stars 78 forks source link

Is it possible to debug_test a specific test table? #75

Open brunobmello25 opened 7 months ago

brunobmello25 commented 7 months ago

Say I have this test structure:

func TestSomeFunction(t *testing.T) {
    tests := []struct {
        name string
    }{
        {
            name: "first test",
        },
        {
            name: "second test",
        },
        {
            name: "third test",
        },
        {
            name: "fourth test",
        },
    }

    for _, test := range tests {
        t.Run(test.name, func(t *testing.T) {
            // Some test logic here
        })
    }
}

When I run debug test it will run all four tests in this table. Is it possible to make require('dap-go').debug_test() debug only a specific test in this array of tests?

brunobmello25 commented 7 months ago

to add to this, I believe that it would be hard to implement since there are a lot of variances you can have for a test table setup, but maybe we could provide an input suggestion for the test name?

for example, when we run debug_test() with the cursor inside a string, it could prompt us to choose if we want to just run the test function (current behavior) or the test with the name that is inside that string. Would that be possible?

leoluz commented 7 months ago

You are right that implementing this logic wouldn't be trivial. There is no pattern to follow as test tables in go is not really standardized by the language. As a workaround, what I do is adding a skip attribute in every test case and call test.SkipNow() if it is set to true. Not ideal but gets the job done.

brunobmello25 commented 7 months ago

is it viable to implement passing test names to debug_test? maybe with that we could open the possibility to run a test with a specific name inside a function, and the user figures out how to pass this name to the function

wilriker commented 7 months ago

I managed to do it the following way: I added a new configuration:

{
    type = "go",
    name = "Debug test case",
    request = "launch",
    mode = "test",
    program = "./${relativeFileDirname}",
    args = function()
        return { "-test.run", require("extensions.gotest").tbl_testcase_name() }
    end,
}

This will run just the test that's name is determined by the function called in args. For the sake of completeness here is how I implemented it (it relies on the fact that all of my table tests have a name as a string as the first case-property:

local M = {}
local func_query_parsed = nil
local func_query = function()
    if func_query_parsed == nil then
        func_query_parsed = vim.treesitter.query.parse(
            "go",
            [[
(function_declaration
    name: (identifier) @test.name
    parameters: (parameter_list
        . (parameter_declaration
            type: (pointer_type) @type) .)
    (#match? @type "*testing.(T|M)")
    (#match? @test.name "^Test.+$")) @test.function
]]
        )
    end
    return func_query_parsed
end

local tbl_case_query_parsed = nil
local tbl_case_query = function()
    if tbl_case_query_parsed == nil then
        tbl_case_query_parsed = vim.treesitter.query.parse(
            "go",
            [[
(literal_value (
    literal_element (
        literal_value .(
            keyed_element
            (literal_element (identifier))
            (literal_element (interpreted_string_literal) @testcase.name)
        )
        ) @testcase.struct
))
]]
        )
    end
    return tbl_case_query_parsed
end

local name_from_query = function(bufnr, query, capture_name_node, capture_block_node)
    local bufn = bufnr or vim.api.nvim_get_current_buf()
    local ok, parser = pcall(vim.treesitter.get_parser, bufn)
    if not ok or not parser then
        return
    end
    local tree = parser:parse()
    tree = tree[1]

    local row, col = unpack(vim.api.nvim_win_get_cursor(0))
    row = row - 1
    for _, match, _ in query:iter_matches(tree:root(), bufn, 0, row, { all = true }) do
        local name = nil
        local cursor_in_result = false
        for id, nodes in pairs(match) do
            local capture = query.captures[id]
            for _, node in ipairs(nodes) do
                if capture == capture_name_node then
                    name = vim.treesitter.get_node_text(node, bufn)
                end

                if capture == capture_block_node then
                    cursor_in_result = vim.treesitter.is_in_node_range(node, row, col)
                end
            end
        end
        if cursor_in_result then
            return name
        end
    end
    return nil
end

local format_test_name = function(name, opts)
    opts = opts or {}
    name = name:gsub('"', "")
    -- NOTE: \Q...\E encloses literal text in go's regex engine (https://github.com/google/re2/wiki/Syntax)
    local esacped_name = string.format([[^\Q%s\E$]], name)
    if opts.quote then
        return "'" .. esacped_name .. "'"
    end
    return esacped_name
end

M.test_func_name = function(bufnr, opts)
    local test_name = name_from_query(bufnr, func_query(), "test.name", "test.function")
    if test_name == "" or test_name == nil then
        return nil
    end
    return format_test_name(test_name, opts)
end

M.tbl_testcase_name = function(bufnr, opts)
    local etn = M.test_func_name(bufnr, opts)
    if etn == "" or etn == nil then
        return
    end
    local testcase_name = name_from_query(bufnr, tbl_case_query(), "testcase.name", "testcase.struct")
    if testcase_name == "" or testcase_name == nil then
        return nil
    end
    return etn .. "/" .. format_test_name(testcase_name, opts)
end

return M

Acknowledgements: all of that code is cobbled together from various plugins determining test- and test-case names (partially from this plugin, partially from ray-x/go.nvim, partially from crispgm/nvim-go).

gustavooferreira commented 7 months ago

For some more inspiration the neotest-go adapter for the neotest plugin also managed to implement, very successfully, running individual test in a table driven test, both for slices and maps of tests, see this issue for an example: https://github.com/nvim-neotest/neotest-go/issues/43.

I've been using neotest with the golang adapter for quite some time, and it's been working flawlessly for me.

brunobmello25 commented 7 months ago

I managed to get it working for normal test running, but this fails when trying to debug a test, because it seems that neotest-go doesn't support nvim-dap

gustavooferreira commented 7 months ago

My comment above was just to show that another plugin was able to isolate the subtests inside a table test, so in theory, we should be able to use the same treesitter query to do the same with nvim-dap-go.

As I'm not sure when if ever this will be implemented in nvim-dap-go, I'd like to document here the 2 methods I use to debug individual subtests inside a table driven test setup.

(PS: Leoluz's workaround works, but it's too many code changes, especially if you have lots of test cases)

Let's take Bruno's example code and add a little bit to it:

func SomeFunction(a, b int) int {
    return a + b
}

func TestSomeFunction(t *testing.T) {
    tests := []struct {
        name           string
        a              int
        b              int
        expectedResult int
    }{
        {
            name:           "first test",
            a:              0,
            b:              0,
            expectedResult: 0,
        },
        {
            name:           "second test",
            a:              10,
            b:              20,
            expectedResult: 30,
        },
        {
            name:           "third test",
            a:              1,
            b:              5,
            expectedResult: 6,
        },
        {
            name:           "fourth test",
            a:              100,
            b:              10,
            expectedResult: 110,
        },
    }

    for _, test := range tests {
        t.Run(test.name, func(t *testing.T) {
            result := SomeFunction(test.a, test.b)
            if result != test.expectedResult {
                t.Errorf("expected %d, got %d", test.expectedResult, result)
            }
        })
    }
}

With neotest I'm able to run individual tests inside a table (regardless of whether it's a slice or a map containing the tests).

When it comes to debugging the tests individually, I too, have a "workaround". In particular, I use 2 methods.

The first method is to copy the test name, and set a conditional breakpoint when the test name matches the string literal. So, if I want to specifically debug the third test, I'll place my conditional breakpoint on line result := SomeFunction(test.a, test.b) and the condition will look like test.name == "third test". The debugger will run and stop on that line exactly when the name of the test matches what I want.

This isn't much work, since I just move to the line with the test name do a vi" to select the test name inside quotes, then "+y to copy it to the clipboard, then move to the line I want to place my conditional breakpoint in, hit a keymap that will show a popup where I can just type the condition above and paste the name of the test. It's very fast.

NOTE: DO NOT set the conditional breakpoint on the line with the t.Run() method call. Delve seems to get confused and tries to evaluate the variable test inside the code in the standard library for t.Run which obviously doesn't exist (I'm not talking about the closure here, I'm talking about the actual code that is part of t.Run. But I'm not entirely sure what's the problem there, I just know there is defo a problem!).

This works like a charm, however, there is a second method I occasionally use, which is to literally put a temporary if statement in my code and then set a normal breakpoint inside of it. I don't tend to use this to select a specific test name, but rather when I have more complex conditional logic. This is only temporary code that I make sure to delete once I'm done with the debugging session.

With the code above, that would look like:

(...)

    for _, test := range tests {
        t.Run(test.name, func(t *testing.T) {
            if test.a == test.b && test.a > 50 {
                t.Log("Set breakpoint on this line!")
            }

            result := SomeFunction(test.a, test.b)
            if result != test.expectedResult {
                t.Errorf("expected %d, got %d", test.expectedResult, result)
            }
        })
    }

(...)

As I said, this second method is particularly powerful when you have complex conditional logic that may even require you calling other methods in your code. This is nice because delve has limitations on what you can use as an expression. However, as powerful as this can be, it's not really necessary when all you want is to select a specific subtest in a table test. In that case, just use method 1.

Hope that helps 🙌

PS: Leo thanks a bunch for creating this plugin and maintaining it. I rely on it on a daily basis!! 🦸

leoluz commented 7 months ago

@gustavooferreira tks a lot for your feedback. Now I am curious to see how neotest achieved this. :)

stale[bot] commented 1 month ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.