Skip to content

Commit 1a82cd4

Browse files
committed
feat: tests can opt-in to retries
Problem: Tests have no way to retry without manually managing `before_each` and `after_each` setup/teardown. This is painful. And busted does a good job of guarding its internals and makes it impossible to extend it: AFAICT, consumers have no way to access the current "test context", or the current list of `before_each`/`after_each` hooks. Solution: Introduce `env.set_retries()`. On failure the test is retried up to `n` more times, rerunning `after_each`/`before_each` around each attempt. `busted.safe()` gains an optional `deferred` collector so only the final attempt's failure/error/pending events are published: output handlers and the exit code never see non-final attempts. ```lua it('...', function() set_retries(2) -- If the test fails, it will be retried up to 2 times. end) ``` Testing: luarocks --local remove busted --force && luarocks --local make && ~/.luarocks/bin/busted --pattern=core Note: Deferral shifts event timing slightly: a failing test's failure/error event is now published after its `finally` runs, not before. Relative event order is unchanged; in-tree handlers can't observe the difference.
1 parent 4ba489c commit 1a82cd4

6 files changed

Lines changed: 85 additions & 6 deletions

File tree

.luacheckrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ globals = {
1010
"async",
1111
"done",
1212
"busted",
13+
"set_retries",
1314
--"ngx.IS_CLI",
1415
}
1516

busted/core.lua

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,9 @@ return function()
170170
end
171171
end
172172

173-
function busted.safe(descriptor, run, element)
173+
-- `deferred`: optional list collecting the failure/error/pending event of a failed `run` as
174+
-- publish arguments, instead of publishing it. For `set_retries`.
175+
function busted.safe(descriptor, run, element, deferred)
174176
busted.context.push(element)
175177
local trace, message
176178
local status = 'success'
@@ -198,7 +200,12 @@ return function()
198200
-- a test failure, but rather an error outside the test, much like a
199201
-- failure in a support function (i.e. before_each/after_each or
200202
-- setup/teardown).
201-
busted.publish({ status, element.descriptor }, element, busted.context.parent(element), message, trace)
203+
local parent = busted.context.parent(element)
204+
if deferred then
205+
deferred[#deferred + 1] = { { status, element.descriptor }, element, parent, message, trace }
206+
else
207+
busted.publish({ status, element.descriptor }, element, parent, message, trace)
208+
end
202209
end
203210
ret[1] = busted.status(status)
204211

busted/init.lua

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ local function init(busted)
2020
local it = function(element)
2121
local parent = busted.context.parent(element)
2222
local finally
23+
local attempt = 1
24+
local max_attempts = 1
2325

2426
if not block.lazySetup(parent) then
2527
-- skip test if any setup failed
@@ -31,16 +33,42 @@ local function init(busted)
3133
block.rejectAll(element)
3234
element.env.finally = function(fn) finally = fn end
3335
element.env.pending = busted.pending
36+
element.env.set_retries = function(n) max_attempts = n + 1 end
3437

3538
local pass, ancestor = block.execAll('before_each', parent, true)
3639

3740
if pass then
3841
local status = busted.status('success')
3942
if busted.safe_publish('test', { 'test', 'start' }, element, parent) then
40-
status:update(busted.safe('it', element.run, element))
41-
if finally then
42-
block.reject('pending', element)
43-
status:update(busted.safe('finally', finally, element))
43+
local deferred = {}
44+
while attempt <= max_attempts do
45+
if attempt > 1 then
46+
-- Retrying: finish the failed attempt, then redo setup.
47+
finally = nil
48+
block.dexecAll('after_each', ancestor, true)
49+
pass, ancestor = block.execAll('before_each', parent, true)
50+
if not pass then
51+
break
52+
end
53+
end
54+
55+
deferred = {}
56+
status = busted.status('success')
57+
status:update(busted.safe('it', element.run, element, deferred))
58+
if finally then
59+
block.reject('pending', element)
60+
status:update(busted.safe('finally', finally, element, deferred))
61+
end
62+
63+
if not (status:failure() or status:error()) then
64+
break
65+
end
66+
attempt = attempt + 1
67+
end
68+
69+
-- Publish only the final attempt's events (see `deferred` in `busted.safe`).
70+
for _, event in ipairs(deferred) do
71+
busted.publish(event[1], event[2], event[3], event[4], event[5])
4472
end
4573
else
4674
status = busted.status('error')

spec/cl_retries.lua

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- Fixture for cl_spec.lua
2+
3+
describe('Tests retries', function()
4+
local attempts = 0
5+
6+
it('succeeds once retried', function()
7+
set_retries(2)
8+
attempts = attempts + 1
9+
assert.is_equal(3, attempts)
10+
end)
11+
12+
it('reports one error when retries are exhausted', function()
13+
set_retries(2)
14+
error('never succeeds')
15+
end)
16+
end)

spec/cl_spec.lua

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,13 @@ describe('Tests the busted command-line options', function()
181181
assert.is_equal(3, errcnt)
182182
end)
183183

184+
it('tests running with retries', function()
185+
local success, errcnt, out = executeBusted('--pattern=cl_retries.lua$')
186+
assert.is_false(success)
187+
assert.is_equal(1, errcnt)
188+
assert.is_truthy(out:match('1 success'))
189+
end)
190+
184191
it('tests running with -l specified', function()
185192
local _, _, result = executeBusted('-l --pattern=cl_list.lua$')
186193
local expected = 'spec/cl_list.lua:4: Tests list test 1\n' ..

spec/core_spec.lua

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,26 @@ assert(type(mock) == 'table')
1313
assert(type(match) == 'table')
1414
assert(type(assert) == 'table')
1515

16+
describe('Test retries', function()
17+
local before_count, after_count = 0, 0
18+
19+
before_each(function()
20+
before_count = before_count + 1
21+
end)
22+
23+
after_each(function()
24+
after_count = after_count + 1
25+
end)
26+
27+
it('reruns the each-hooks around every attempt', function()
28+
set_retries(2) -- 3 total attempts
29+
assert.is_equal(before_count - 1, after_count)
30+
if before_count < 3 then
31+
assert.is_true(false, ('failing attempt %d'):format(before_count))
32+
end
33+
end)
34+
end)
35+
1636
describe('Before each', function()
1737
local test_val = false
1838

0 commit comments

Comments
 (0)