Skip to content

Commit 1252cb3

Browse files
authored
feat: make tests run in coroutine to support asynchronous testing (#426)
1 parent 69d0a92 commit 1252cb3

File tree

3 files changed

+119
-24
lines changed

3 files changed

+119
-24
lines changed

TESTS_README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,19 @@ To test this in your `~/.config/nvim` configuration, try the suggested file stru
125125
lua/example/module.lua
126126
lua/spec/example/module_spec.lua
127127
```
128+
129+
# Asynchronous testing
130+
131+
Tests run in a coroutine, which can be yielded and resumed. This can be used to
132+
test code that uses asynchronous Neovim functionalities. For example, this can
133+
be done inside a test:
134+
135+
```lua
136+
local co = coroutine.running()
137+
vim.defer_fn(function()
138+
coroutine.resume(co)
139+
end, 1000)
140+
--The test will reach here immediately.
141+
coroutine.yield()
142+
--The test will only reach here after one second, when the deferred function runs.
143+
```

lua/plenary/busted.lua

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,9 @@ mod.run = function(file)
218218
print("\n" .. HEADER)
219219
print("Testing: ", file)
220220

221-
local ok, msg = pcall(dofile, file)
221+
local loaded, msg = loadfile(file)
222222

223-
if not ok then
223+
if not loaded then
224224
print(HEADER)
225225
print "FAILED TO LOAD FILE"
226226
print(color_string("red", msg))
@@ -232,33 +232,37 @@ mod.run = function(file)
232232
end
233233
end
234234

235-
-- If nothing runs (empty file without top level describe)
236-
if not results.pass then
237-
if is_headless then
238-
return vim.cmd "0cq"
239-
else
240-
return
235+
coroutine.wrap(function()
236+
loaded()
237+
238+
-- If nothing runs (empty file without top level describe)
239+
if not results.pass then
240+
if is_headless then
241+
return vim.cmd "0cq"
242+
else
243+
return
244+
end
241245
end
242-
end
243246

244-
mod.format_results(results)
247+
mod.format_results(results)
245248

246-
if #results.errs ~= 0 then
247-
print("We had an unexpected error: ", vim.inspect(results.errs), vim.inspect(results))
248-
if is_headless then
249-
return vim.cmd "2cq"
250-
end
251-
elseif #results.fail > 0 then
252-
print "Tests Failed. Exit: 1"
249+
if #results.errs ~= 0 then
250+
print("We had an unexpected error: ", vim.inspect(results.errs), vim.inspect(results))
251+
if is_headless then
252+
return vim.cmd "2cq"
253+
end
254+
elseif #results.fail > 0 then
255+
print "Tests Failed. Exit: 1"
253256

254-
if is_headless then
255-
return vim.cmd "1cq"
256-
end
257-
else
258-
if is_headless then
259-
return vim.cmd "0cq"
257+
if is_headless then
258+
return vim.cmd "1cq"
259+
end
260+
else
261+
if is_headless then
262+
return vim.cmd "0cq"
263+
end
260264
end
261-
end
265+
end)()
262266
end
263267

264268
return mod

tests/plenary/async_testing_spec.lua

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
local Job = require "plenary.job"
2+
3+
local Timing = {}
4+
5+
function Timing:log(name)
6+
self[name] = vim.loop.uptime()
7+
end
8+
9+
function Timing:check(from, to, min_elapsed)
10+
assert(self[from], "did not log " .. from)
11+
assert(self[to], "did not log " .. to)
12+
local elapsed = self[to] - self[from]
13+
assert(
14+
min_elapsed <= elapsed,
15+
string.format("only took %s to get from %s to %s - expected at least %s", elapsed, from, to, min_elapsed)
16+
)
17+
end
18+
19+
describe("Async test", function()
20+
it("can resume testing with vim.defer_fn", function()
21+
local co = coroutine.running()
22+
assert(co, "not running inside a coroutine")
23+
24+
local timing = setmetatable({}, { __index = Timing })
25+
26+
vim.defer_fn(function()
27+
coroutine.resume(co)
28+
end, 200)
29+
timing:log "before"
30+
coroutine.yield()
31+
timing:log "after"
32+
timing:check("before", "after", 0.1)
33+
end)
34+
35+
it("can resume testing from job callback", function()
36+
local co = coroutine.running()
37+
assert(co, "not running inside a coroutine")
38+
39+
local timing = setmetatable({}, { __index = Timing })
40+
41+
Job:new({
42+
command = "bash",
43+
args = {
44+
"-ce",
45+
[[
46+
sleep 0.2
47+
echo hello
48+
sleep 0.2
49+
echo world
50+
sleep 0.2
51+
exit 42
52+
]],
53+
},
54+
on_stdout = function(_, data)
55+
timing:log(data)
56+
end,
57+
on_exit = function(_, exit_status)
58+
timing:log "exit"
59+
--This is required so that the rest of the test will run in a proper context
60+
vim.schedule(function()
61+
coroutine.resume(co, exit_status)
62+
end)
63+
end,
64+
}):start()
65+
timing:log "job started"
66+
local exit_status = coroutine.yield()
67+
timing:log "job finished"
68+
assert.are.equal(exit_status, 42)
69+
70+
timing:check("job started", "job finished", 0.3)
71+
timing:check("job started", "hello", 0.1)
72+
timing:check("hello", "world", 0.1)
73+
timing:check("world", "job finished", 0.1)
74+
end)
75+
end)

0 commit comments

Comments
 (0)