Skip to content

Commit fc7845b

Browse files
Add failfast option (#133)
* Add failfast * support ENV var * Improve logging for failfast mode * Add tests * Handle `@testset failfast=true` * Bump test timeout to avoid unexpected failures * Add testitem failfast * Switch to separate `testitem_failfast` keyword * testitem failfast printing * Fallback to testset timing if tests errored * Add testitem failfast tests * Make testset name more distinctive * More docs * Setting `failfast` sets `testitem_failfast` by default * Remove racy cancellation check and switch to an atomic * Document current limitation of `failfast` with multiple workers To make clear this may change in a non-breaking release * Bump version * fixup! Document current limitation of `failfast` with multiple workers
1 parent 039b01e commit fc7845b

19 files changed

+534
-38
lines changed

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name = "ReTestItems"
22
uuid = "817f1d60-ba6b-4fd5-9520-3cf149f6a823"
3-
version = "1.27.0"
3+
version = "1.28.0"
44

55
[deps]
66
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"

README.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,6 @@ Filtering by `name` and `tags` can be combined to run only test-items that match
9595
julia> runtests("test/Database/"; tags=:regression, name=r"^issue")
9696
```
9797

98-
9998
#### Running tests in parallel
10099

101100
You can run tests in parallel on multiple worker processes using the `nworkers` keyword.
@@ -117,6 +116,21 @@ threadpools](https://docs.julialang.org/en/v1/manual/multi-threading/#man-thread
117116

118117
Note ReTestItems.jl uses distributed parallelism, not multi-threading, to run test-items in parallel.
119118

119+
#### Stopping tests early
120+
121+
You can set `runtests` to stop on the first test-item failure by passing `failfast=true`.
122+
123+
124+
> [!NOTE]
125+
> Note `failfast` prevents any new test-items starting to run after the first test-item failure, but
126+
> test-items that were already running on another worker in parallel with the failing test will complete and appear in the test report.
127+
> Tests that were not run will not appear in the test report.
128+
>
129+
> This may be improved in a future version of ReTestItems.jl, so that all test-items running in parallel are stopped on when one test-item fails
130+
131+
If you want individual test-items to stop on their first test failure, but not stop the whole `runtests` early, you can instead pass just `testitem_failfast=true` to `runtests`.
132+
133+
120134
## Writing tests
121135

122136
Tests must be wrapped in a [`@testitem`](https://docs.juliahub.com/General/ReTestItems/stable/autodocs/#ReTestItems.@testitem-Tuple{Any,%20Vararg{Any}}).
@@ -194,6 +208,19 @@ The `skip` keyword allows you to define the condition under which a test needs t
194208
for example if it can only be run on a certain platform.
195209
See [filtering tests](#filtering-tests) for controlling which tests run in a particular `runtests` call.
196210

211+
#### Failing early
212+
213+
Each test-item can control whether or not it stops on the first failure using the `failfast` keyword.
214+
215+
```julia
216+
@testitem "stops on first failure" failfast=true begin
217+
@test 1 + 1 == 3
218+
@test 2 * 2 == 4
219+
end
220+
```
221+
222+
If a test-items set the `failfast` then that value takes precedence over the `testitem_failfast` keyword passed to `runtests`.
223+
197224
#### Post-testitem hook
198225

199226
If there is something that should be checked after every single `@testitem`, then it's possible to pass an expression to `runtests` using the `test_end_expr` keyword.
@@ -255,7 +282,7 @@ runtests("frobble_tests.jl"; nworkers, worker_init_expr)
255282
using ReTestItems, MyPackage
256283
runtests(MyPackage)
257284
```
258-
- Pass to `runtests` any configuration you want your tests to run with, such as `retries`, `testitem_timeout`, `nworkers`, `nworker_threads`, `worker_init_expr`, `test_end_expr`.
285+
- Pass to `runtests` any configuration you want your tests to run with, such as `retries`, `failfast`, `testitem_failfast`, `testitem_timeout`, `nworkers`, `nworker_threads`, `worker_init_expr`, `test_end_expr`.
259286
See the [`runtests`](https://docs.juliahub.com/General/ReTestItems/stable/autodocs/#ReTestItems.runtests) docstring for details.
260287

261288
---

src/ReTestItems.jl

Lines changed: 102 additions & 20 deletions
Large diffs are not rendered by default.

src/log_capture.jl

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ function save_current_stdio()
1212
DEFAULT_LOGGER[] = Base.CoreLogging._global_logstate.logger
1313
end
1414

15-
# A lock that helps to stagger prints to DEFAULT_STDOUT, used in `print_errors_and_captured_logs`
16-
# which is called by multiple tasks on the coordinator
15+
# A lock that helps to stagger prints to DEFAULT_STDOUT, used in functions that print and
16+
# are called by multiple tasks on the coordinator, e.g. `print_errors_and_captured_logs`
1717
const LogCaptureLock = ReentrantLock()
1818
macro loglock(expr)
1919
return :(@lock LogCaptureLock $(esc(expr)))
@@ -279,15 +279,44 @@ function log_testitem_start(ti::TestItem, ntestitems=0)
279279
write(DEFAULT_STDOUT[], take!(io.io))
280280
end
281281

282-
function log_testitem_done(ti::TestItem, ntestitems=0)
282+
function log_testitem_done(ti::TestItem, ntestitems=0; failedfast::Bool=false)
283283
io = IOContext(IOBuffer(), :color => get(DEFAULT_STDOUT[], :color, false)::Bool)
284284
print_state(io, "DONE", ti, ntestitems)
285285
x = last(ti.stats) # always print stats for most recent run
286-
print_time(io; x.elapsedtime, x.bytes, x.gctime, x.allocs, x.compile_time, x.recompile_time)
286+
if x == PerfStats() # no `@timed` stats if tests threw, fallback on testset timing
287+
ts = last(ti.testsets)
288+
if !isnothing(ts.time_end)
289+
secs_taken = ts.time_end - ts.time_start
290+
_print_scaled_one_dec(io, secs_taken, 1, " secs")
291+
end
292+
else
293+
print_time(io; x.elapsedtime, x.bytes, x.gctime, x.allocs, x.compile_time, x.recompile_time)
294+
end
295+
failedfast && print(io, " (Failed Fast)")
287296
println(io)
288297
write(DEFAULT_STDOUT[], take!(io.io))
289298
end
290299

300+
# So that the user is warned that we're cancelling the rest of the run.
301+
# Use loglock here as this is called when multiple tasks are running tests and printing output.
302+
function print_failfast_cancellation(ti::TestItem)
303+
io = IOContext(IOBuffer(), :color => get(DEFAULT_STDOUT[], :color, false)::Bool)
304+
printstyled(io, "[ Fail Fast: "; bold=true, color=Base.warn_color())
305+
println(io, "Test item $(repr(ti.name)) at $(_file_info(ti)) failed. Cancelling tests.")
306+
@loglock write(DEFAULT_STDOUT[], take!(io.io))
307+
return nothing
308+
end
309+
310+
# So that the user is warned that not all tests were run.
311+
# We don't use loglock here, because this is only called once on the coordinator after all
312+
# tasks running tests have stopped and we're printing the final test report.
313+
function print_failfast_summary(t::TestItems)
314+
io = DEFAULT_STDOUT[]
315+
printstyled(io, "[ Fail Fast: "; bold=true, color=Base.warn_color())
316+
println(io, "$(t.count)/$(length(t.testitems)) test items were run.")
317+
return nothing
318+
end
319+
291320
function report_empty_testsets(ti::TestItem, ts::DefaultTestSet)
292321
empty_testsets = String[]
293322
_find_empty_testsets!(empty_testsets, ts)

src/macros.jl

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ struct TestItem
121121
retries::Int
122122
timeout::Union{Int,Nothing} # in seconds
123123
skip::Union{Bool,Expr}
124+
failfast::Union{Bool,Nothing}
124125
file::String
125126
line::Int
126127
project_root::String
@@ -132,10 +133,10 @@ struct TestItem
132133
stats::Vector{PerfStats} # populated when the test item is finished running
133134
scheduled_for_evaluation::ScheduledForEvaluation # to keep track of whether the test item has been scheduled for evaluation
134135
end
135-
function TestItem(number, name, id, tags, default_imports, setups, retries, timeout, skip, file, line, project_root, code)
136+
function TestItem(number, name, id, tags, default_imports, setups, retries, timeout, skip, failfast, file, line, project_root, code)
136137
_id = @something(id, repr(hash(name, hash(relpath(file, project_root)))))
137138
return TestItem(
138-
number, name, _id, tags, default_imports, setups, retries, timeout, skip, file, line, project_root, code,
139+
number, name, _id, tags, default_imports, setups, retries, timeout, skip, failfast, file, line, project_root, code,
139140
TestSetup[],
140141
Ref{Int}(0),
141142
DefaultTestSet[],
@@ -241,6 +242,14 @@ expression that returns a `Bool` to determine if the testitem should be skipped.
241242
242243
The `skip` expression is run in its own module, just like a test-item.
243244
No code inside a `@testitem` is run when a test-item is skipped.
245+
246+
If a `@testitem` should stop running on the first test failure, then you can set the `failfast` keyword.
247+
248+
@testitem "stop early" failfast=true begin
249+
@test false
250+
@test true
251+
@test error("oops")
252+
end
244253
"""
245254
macro testitem(nm, exs...)
246255
default_imports = true
@@ -249,6 +258,7 @@ macro testitem(nm, exs...)
249258
tags = Symbol[]
250259
setup = Any[]
251260
skip = false
261+
failfast = nothing
252262
_id = nothing
253263
_run = true # useful for testing `@testitem` itself
254264
_source = QuoteNode(__source__)
@@ -284,6 +294,9 @@ macro testitem(nm, exs...)
284294
skip = ex.args[2]
285295
# If the `Expr` doesn't evaluate to a Bool, throws at runtime.
286296
@assert skip isa Union{Bool,Expr} "`skip` keyword must be passed a `Bool`"
297+
elseif kw == :failfast
298+
failfast = ex.args[2]
299+
@assert failfast isa Bool "`failfast` keyword must be passed a `Bool`. Got `failfast=$failfast`"
287300
elseif kw == :_id
288301
_id = ex.args[2]
289302
# This will always be written to the JUnit XML as a String, require the user
@@ -309,7 +322,7 @@ macro testitem(nm, exs...)
309322
ti = gensym(:ti)
310323
esc(quote
311324
let $ti = $TestItem(
312-
$Ref(0), $nm, $_id, $tags, $default_imports, $setup, $retries, $timeout, $skip,
325+
$Ref(0), $nm, $_id, $tags, $default_imports, $setup, $retries, $timeout, $skip, $failfast,
313326
$String($_source.file), $_source.line,
314327
$gettls(:__RE_TEST_PROJECT__, "."),
315328
$q,

src/testcontext.jl

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,19 @@ mutable struct TestItems
8989
# they are populated once the full graph is done by doing
9090
# a depth-first traversal of the graph
9191
testitems::Vector{TestItem} # frozen once flatten_testitems! is called
92+
@atomic cancelled::Bool # if true, no more testitems should run
9293
@atomic count::Int # number of testitems that have been taken for eval so far
9394
end
9495

95-
TestItems(graph) = TestItems(graph, TestItem[], 0)
96+
TestItems(graph) = TestItems(graph, TestItem[])
97+
TestItems(graph, testitems) = TestItems(graph, testitems, false, 0)
98+
# Prevent any new testitems from being scheduled, and return a `Bool` indicating whether or
99+
# not the testitems were already cancelled.
100+
cancel!(t::TestItems) = @atomicswap t.cancelled = true
101+
# Check whether the testitems have been cancelled.
102+
# Should _not_ be used to decide whether or not to cancel testitems, instead just call
103+
# `cancel` and check the return value to know if they had already been cancelled.
104+
is_cancelled(t::TestItems) = @atomic t.cancelled
96105

97106
###
98107
### record results
@@ -124,9 +133,12 @@ end
124133

125134
function record_results!(file::FileNode, ti::TestItem)
126135
@debugv 1 "Recording TestItem $(repr(ti.name)) to file $(repr(file.path))"
127-
# Always record last try as the final status, so a pass-on-retry is a pass.
128-
Test.record(file.testset, last(ti.testsets))
129-
junit_record!(file.junit, ti)
136+
# If `failfast`, this testitem might never have been run, so nothing to record.
137+
if ti.eval_number[] != 0
138+
# Always record last try as the final status, so a pass-on-retry is a pass.
139+
Test.record(file.testset, last(ti.testsets))
140+
junit_record!(file.junit, ti)
141+
end
130142
end
131143

132144
# DirNode and FileNode have `junit=nothing` when no report is needed.
@@ -160,6 +172,7 @@ end
160172

161173
# i is the index of the last test item that was run
162174
function next_testitem(ti::TestItems, i::Int)
175+
is_cancelled(ti) && return nothing
163176
len = length(ti.testitems)
164177
n = len
165178
while n > 0

0 commit comments

Comments
 (0)