Skip to content

Commit 796903a

Browse files
committed
Add test discovery documentation for test addons
1 parent 69ae1d2 commit 796903a

File tree

1 file changed

+222
-0
lines changed

1 file changed

+222
-0
lines changed

jekyll/test_framework_addons.markdown

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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

Comments
 (0)