|
| 1 | +--- |
| 2 | +layout: default |
| 3 | +title: Test framework add-ons |
| 4 | +nav_order: 10 |
| 5 | +parent: Add-ons |
| 6 | +--- |
| 7 | + |
| 8 | +# Test framework add-ons |
| 9 | + |
| 10 | +{: .note } |
| 11 | +Before diving into building test framework add-ons, read about the [test explorer documentation](test_explorer) first. |
| 12 | + |
| 13 | +The Ruby LSP's test explorer includes built-in support for Minitest and Test Unit. Add-ons can add support for other |
| 14 | +test frameworks, like [Active Support test case](rails-add-on) and [RSpec](https://github.com/st0012/ruby-lsp-rspec). |
| 15 | + |
| 16 | +There are 3 main parts for contributing support for a new framework: |
| 17 | + |
| 18 | +- [Test discovery](#test-discovery): identifying tests within the codebase and their structure |
| 19 | +- Command resolution: determining how to execute a specific test or group of tests |
| 20 | +- Custom reporting: displaying test execution results in the test explorer |
| 21 | + |
| 22 | +## Test discovery |
| 23 | + |
| 24 | +Test discovery is the process of populating the explorer view with the tests that exist in the codebase. The Ruby LSP |
| 25 | +extension is responsible for discovering all test files. The convention to be considered a test file is that it must |
| 26 | +match this glob pattern: `**/{test,spec,features}/**/{*_test.rb,test_*.rb,*_spec.rb,*.feature}`. It is possible to |
| 27 | +configure test frameworks to use different naming patterns, but this convention is established to guarantee that we |
| 28 | +can discover all test files with adequate performance and without requiring configuration from users. |
| 29 | + |
| 30 | +The part that add-ons are responsible for is discovering which tests exist **inside** of those files, which requires |
| 31 | +static analysis and rules that are framework dependent. Like most other add-on contribution points, test discovery |
| 32 | +can be enhanced by attaching a new listener to the process of discovering tests. |
| 33 | + |
| 34 | +```ruby |
| 35 | +module RubyLsp |
| 36 | + module MyTestFrameworkGem |
| 37 | + class Addon < ::RubyLsp::Addon |
| 38 | + #: (GlobalState, Thread::Queue) -> void |
| 39 | + def activate(global_state, message_queue) |
| 40 | + @global_state = global_state |
| 41 | + end |
| 42 | + |
| 43 | + # Declare the factory method that will hook a new listener into the test discovery process |
| 44 | + # @override |
| 45 | + #: (ResponseBuilders::TestCollection, Prism::Dispatcher, URI::Generic) -> void |
| 46 | + def create_discover_tests_listener(response_builder, dispatcher, uri) |
| 47 | + # Because the Ruby LSP runs requests concurrently, there are no guarantees that we'll be done executing |
| 48 | + # activate when a request for test discovery comes in. If this happens, skip until the global state is ready |
| 49 | + return unless @global_state |
| 50 | + |
| 51 | + # Create our new test discovery listener, which will hook into the dispatcher |
| 52 | + TestDiscoveryListener.new(response_builder, @global_state, dispatcher, uri) |
| 53 | + end |
| 54 | + end |
| 55 | + end |
| 56 | +end |
| 57 | +``` |
| 58 | + |
| 59 | +Next, the listener itself needs to be implemented. If the test framework being handled uses classes to define test |
| 60 | +groups, like Minitest and Test Unit, the Ruby LSP provides a parent class to make some aspects of the implementation |
| 61 | +easier and more standardized. Let's take a look at this case first and then see how frameworks that don't use classes |
| 62 | +can be handled (such as RSpec). |
| 63 | + |
| 64 | +In this example, test groups are defined with classes that inherit from `MyTestFramework::Test` and test examples are |
| 65 | +defined by creating methods prefixed with `test_`. |
| 66 | + |
| 67 | +```ruby |
| 68 | +module RubyLsp |
| 69 | + module MyTestFrameworkGem |
| 70 | + class TestDiscoveryListener < Listeners::TestDiscovery |
| 71 | + #: (ResponseBuilders::TestCollection, GlobalState, Prism::Dispatcher, URI::Generic) -> void |
| 72 | + def initialize(response_builder, global_state, dispatcher, uri) |
| 73 | + super(response_builder, global_state, dispatcher, uri) |
| 74 | + |
| 75 | + # Register on the dispatcher for the node events we are interested in |
| 76 | + dispatcher.register(self, :on_class_node_enter, :on_def_node_enter) |
| 77 | + end |
| 78 | + |
| 79 | + #: (Prism::ClassNode node) -> void |
| 80 | + def on_class_node_enter(node) |
| 81 | + # Here we use the `with_test_ancestor_tracking` so that we can check if the class we just found inherits |
| 82 | + # from our framework's parent test class. This check is important because users can define any classes or |
| 83 | + # modules inside a test file and not all of them are runnable tests |
| 84 | + with_test_ancestor_tracking(node) do |name, ancestors| |
| 85 | + if ancestors.include?("MyTestFrameworkGem::Test") |
| 86 | + # If the test class indeed inherits from our framework, then we can create a new test item representing |
| 87 | + # this test in the explorer. The expected arguments are: |
| 88 | + # |
| 89 | + # - id: a unique ID for this test item. Must match the same IDs reported during test execution |
| 90 | + # (explained in the next section) |
| 91 | + # - label: the label that will appear in the explorer |
| 92 | + # - uri: the URI where this test can be found (e.g.: file:///Users/me/src/my_project/test/my_test.rb). |
| 93 | + # has to be a URI::Generic object |
| 94 | + # - range: a RubyLsp::Interface::Range object describing the range inside of `uri` where we can find the |
| 95 | + # test definition |
| 96 | + # - framework: a framework ID that will be used for resolving test commands. Each add-on should only |
| 97 | + # resolve the items marked as their framework |
| 98 | + test_item = Requests::Support::TestItem.new( |
| 99 | + name, |
| 100 | + name, |
| 101 | + @uri, |
| 102 | + range_from_node(node), |
| 103 | + framework: :my_framework |
| 104 | + ) |
| 105 | + |
| 106 | + # Push the test item as an explorer entry |
| 107 | + @response_builder.add(test_item) |
| 108 | + |
| 109 | + # Push the test item for code lenses. This allows users to run tests by clicking the `Run`, |
| 110 | + # `Run in terminal` and `Debug` buttons directly on top of tests |
| 111 | + @response_builder.add_code_lens(test_item) |
| 112 | + end |
| 113 | + end |
| 114 | + end |
| 115 | + |
| 116 | + #: (Prism::DefNode) -> void |
| 117 | + def on_def_node_enter(node) |
| 118 | + # If the method is not public, then it cannot be considered an example. The visibility stack is tracked |
| 119 | + # automatically by the `RubyLsp::Listeners::TestDiscovery` parent class |
| 120 | + return if @visibility_stack.last != :public |
| 121 | + |
| 122 | + # If the method name doesn't begin with `test_`, then it's not a test example |
| 123 | + name = node.name.to_s |
| 124 | + return unless name.start_with?("test_") |
| 125 | + |
| 126 | + # The current group of a test example depends on which exact namespace nesting it is defined in. We can use |
| 127 | + # the Ruby LSP's index to get the fully qualified name of the current namespace using the `@nesting` variable |
| 128 | + # provided by the TestDiscovery parent class |
| 129 | + current_group_name = RubyIndexer::Index.actual_nesting(@nesting, nil).join("::") |
| 130 | + |
| 131 | + # The test explorer is populated with a hierarchy of items. Groups have children, which can include other |
| 132 | + # groups and examples. Listeners should always add newly discovered children to the parent item where they |
| 133 | + # are discovered. For example: |
| 134 | + # |
| 135 | + # class MyTest < MyFrameworkGem::Test |
| 136 | + # |
| 137 | + # # this NestedTest is a child of MyTest |
| 138 | + # class NestedTest < MyFrameworkGem::Test |
| 139 | + # |
| 140 | + # # this example is a child of NestedTest |
| 141 | + # def test_something; end |
| 142 | + # end |
| 143 | + # |
| 144 | + # # This example is a child of MyTest |
| 145 | + # def test_something_else; end |
| 146 | + # end |
| 147 | + # |
| 148 | + # Get the current test item from the response builder using the ID. In this case, the immediate group |
| 149 | + # enclosing will be based on the nesting |
| 150 | + test_item = @response_builder[current_group_name] |
| 151 | + return unless test_item |
| 152 | + |
| 153 | + # Create the test item for the example. To make IDs unique, always include the group names as part of the ID |
| 154 | + # since users can define the same exact example name in multiple different groups |
| 155 | + example_item = Requests::Support::TestItem.new( |
| 156 | + "#{current_group_name}##{name}", |
| 157 | + name, |
| 158 | + @uri, |
| 159 | + range_from_node(node), |
| 160 | + framework: :my_framework, |
| 161 | + ) |
| 162 | + |
| 163 | + # Add the example item to both as an explorer entry and code lens |
| 164 | + test_item.add(example_item) |
| 165 | + @response_builder.add_code_lens(example_item) |
| 166 | + end |
| 167 | + end |
| 168 | + end |
| 169 | +end |
| 170 | +``` |
| 171 | + |
| 172 | +{: .important } |
| 173 | +Test item IDs have an implicit formatting requirement: groups must be separated by `::` and examples must be separated |
| 174 | +by `#`. This is required even for frameworks that do not use classes and methods to define groups and examples. |
| 175 | +Including spaces in group or example IDs is allowed. |
| 176 | + |
| 177 | +For example, if we have the following test: |
| 178 | + |
| 179 | +```ruby |
| 180 | +class MyTest < MyFrameworkGem::Test |
| 181 | + class NestedTest < MyFrameworkGem::Test |
| 182 | + def test_something; end |
| 183 | + end |
| 184 | +end |
| 185 | +``` |
| 186 | + |
| 187 | +the expected ID for the item representing `test_something` should be `MyTest::NestedTest#test_something`. |
| 188 | + |
| 189 | +For frameworks that do not define test groups using classes, such as RSpec, the listener should not inherit from |
| 190 | +`RubyLsp::Listeners::TestDiscovery`. Instead, the logic can be implemented directly, based on the framework's specific |
| 191 | +rules. |
| 192 | + |
| 193 | +```ruby |
| 194 | +module RubyLsp |
| 195 | + module MyTestFrameworkGem |
| 196 | + class MySpecListener |
| 197 | + #: (ResponseBuilders::TestCollection, GlobalState, Prism::Dispatcher, URI::Generic) -> void |
| 198 | + def initialize(response_builder, global_state, dispatcher, uri) |
| 199 | + # Register on the dispatcher for the node events we are interested in |
| 200 | + dispatcher.register(self, :on_call_node_enter) |
| 201 | + |
| 202 | + @spec_name_stack = [] |
| 203 | + end |
| 204 | + |
| 205 | + #: (Prism::CallNode) -> void |
| 206 | + def on_call_node_enter(node) |
| 207 | + method_name = node.message |
| 208 | + |
| 209 | + case method_name |
| 210 | + when "describe", "context" |
| 211 | + # Extract the name of this group from the call node's arguments |
| 212 | + # Create a test item and push it as entries and code lenses |
| 213 | + # Push the name of this group into the stack, so that we can find which group is current later |
| 214 | + when "it" |
| 215 | + # Extract the name of this example from the call node's arguments |
| 216 | + # Create a test item and push it as entries and code lenses |
| 217 | + end |
| 218 | + end |
| 219 | + end |
| 220 | + end |
| 221 | +end |
| 222 | +``` |
0 commit comments