Skip to content

Commit c449b77

Browse files
Initial support for running test failures first via failures_first=true (#212)
* Hacky prototype for running test failures first * Also sort unseen before passes * Use id => unit8 mapping * Update option name, default it to true * Add tests * Defailt to `false` * Another test * Add to README * Bump version * Support nworkers>0
1 parent 534810b commit c449b77

File tree

8 files changed

+149
-15
lines changed

8 files changed

+149
-15
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.33.2"
3+
version = "1.34.0"
44

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

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ You can set `runtests` to stop on the first test-item failure by passing `failfa
130130
131131
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`.
132132

133+
#### Running previous failures first
134+
135+
You can set `runtests` to run first any test-items that failed the last time they were run by passing `failures_first=true`.
136+
When `failures_first=true` is set, test-items are order so that previously failing test-items run first, followed by previously unseen test-items, followed by previously passing test-items.
137+
138+
This option can be combined with `failfast=true` to efficiently find the next failing test-item during development.
133139

134140
## Writing tests
135141

src/ReTestItems.jl

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,20 @@ else
2323
const errmon = identity
2424
end
2525

26+
# Used by failures_first to sort failures before unseen before passes.
27+
@enum _TEST_STATUS::UInt8 begin
28+
_FAILED = 0
29+
_UNSEEN = 1
30+
_PASSED = 2
31+
end
32+
const GLOBAL_TEST_STATUS = Dict{String,_TEST_STATUS}()
33+
reset_test_status!() = (empty!(GLOBAL_TEST_STATUS); nothing)
34+
_status_when_last_seen(ti) = get(GLOBAL_TEST_STATUS, ti.id, _UNSEEN)
35+
function _cache_status!(ti)
36+
status = ti.is_non_pass[] ? _FAILED : _PASSED
37+
GLOBAL_TEST_STATUS[ti.id] = status
38+
end
39+
2640
# We use the Test.jl stdlib `failfast` mechanism to implement `testitem_failfast`, but that
2741
# feature was only added in Julia v1.9, so we define these shims so our code can be
2842
# compatible with earlier Julia versions, with `testitem_failfast` just having no effect.
@@ -245,6 +259,9 @@ will be run.
245259
Defaults to the value passed to the `failfast` keyword.
246260
If a `@testitem` sets its own `failfast` keyword, then that takes precedence.
247261
Note that the `testitem_failfast` keyword only takes effect in Julia v1.9+ and is ignored in earlier Julia versions.
262+
- `failures_first::Bool=false`: if `true`, first runs test items that failed the last time
263+
they ran, followed by new test items, followed by test items that passed the last time they ran.
264+
Can also be set using the `RETESTITEMS_FAILURES_FIRST` environment variable.
248265
"""
249266
function runtests end
250267

@@ -274,6 +291,7 @@ end
274291
timeout_profile_wait::Int
275292
memory_threshold::Float64
276293
gc_between_testitems::Bool
294+
failures_first::Bool
277295
end
278296

279297

@@ -298,6 +316,7 @@ function runtests(
298316
gc_between_testitems::Bool=parse(Bool, get(ENV, "RETESTITEMS_GC_BETWEEN_TESTITEMS", string(nworkers > 1))),
299317
failfast::Bool=parse(Bool, get(ENV, "RETESTITEMS_FAILFAST", "false")),
300318
testitem_failfast::Bool=parse(Bool, get(ENV, "RETESTITEMS_TESTITEM_FAILFAST", string(failfast))),
319+
failures_first::Bool=parse(Bool, get(ENV, "RETESTITEMS_FAILURES_FIRST", "false")),
301320
)
302321
nworker_threads = _validated_nworker_threads(nworker_threads)
303322
paths′ = _validated_paths(paths, validate_paths)
@@ -318,7 +337,7 @@ function runtests(
318337
(timeout_profile_wait > 0 && Sys.iswindows()) && @warn "CPU profiles on timeout is not supported on Windows, ignoring `timeout_profile_wait`"
319338
mkpath(RETESTITEMS_TEMP_FOLDER[]) # ensure our folder wasn't removed
320339
save_current_stdio()
321-
cfg = _Config(; nworkers, nworker_threads, worker_init_expr, test_end_expr, testitem_timeout, testitem_failfast, failfast, retries, logs, report, verbose_results, timeout_profile_wait, memory_threshold, gc_between_testitems)
340+
cfg = _Config(; nworkers, nworker_threads, worker_init_expr, test_end_expr, testitem_timeout, testitem_failfast, failfast, retries, logs, report, verbose_results, timeout_profile_wait, memory_threshold, gc_between_testitems, failures_first)
322341
debuglvl = Int(debug)
323342
if debuglvl > 0
324343
withdebug(debuglvl) do
@@ -400,6 +419,15 @@ function _runtests_in_current_env(
400419
@info "Scheduling $ntestitems tests on pid $(Libc.getpid())" *
401420
(nworkers == 0 ? "" : " with $nworkers worker processes and $nworker_threads threads per worker.")
402421
try
422+
if cfg.failures_first && !isempty(GLOBAL_TEST_STATUS)
423+
sort!(testitems.testitems; by=_status_when_last_seen)
424+
foreach(enumerate(testitems.testitems)) do (i, ti)
425+
ti.number[] = i # reset number to match new order
426+
end
427+
is_sorted_queue = true
428+
else
429+
is_sorted_queue = false
430+
end
403431
if nworkers == 0
404432
length(cfg.worker_init_expr.args) > 0 && error("worker_init_expr is set, but will not run because number of workers is 0.")
405433
# This is where we disable printing for the serial executor case.
@@ -423,7 +451,7 @@ function _runtests_in_current_env(
423451
@debugv 2 "Running GC"
424452
GC.gc(true)
425453
end
426-
is_non_pass = any_non_pass(ts)
454+
testitem.is_non_pass[] = is_non_pass = any_non_pass(ts)
427455
if is_non_pass && run_number != max_runs
428456
run_number += 1
429457
@info "Retrying $(repr(testitem.name)). Run=$run_number."
@@ -458,7 +486,7 @@ function _runtests_in_current_env(
458486
end
459487
# Now all workers are started, we can begin processing test items.
460488
@info "Starting running test items"
461-
starting = get_starting_testitems(testitems, nworkers)
489+
starting = get_starting_testitems(testitems, nworkers; is_sorted=is_sorted_queue)
462490
@sync for (i, w) in enumerate(workers)
463491
ti = starting[i]
464492
@spawn begin
@@ -651,7 +679,7 @@ function manage_worker(
651679
@debugv 2 "Running GC on $worker"
652680
remote_fetch(worker, :(GC.gc(true)))
653681
end
654-
is_non_pass = any_non_pass(ts)
682+
testitem.is_non_pass[] = is_non_pass = any_non_pass(ts)
655683
if is_non_pass && run_number != max_runs
656684
run_number += 1
657685
@info "Retrying $(repr(testitem.name)) on $worker. Run=$run_number."

src/macros.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ struct TestItem
129129
testsetups::Vector{TestSetup} # populated by runtests coordinator
130130
workerid::Base.RefValue{Int} # populated when the test item is scheduled
131131
testsets::Vector{DefaultTestSet} # populated when the test item is finished running
132+
is_non_pass::Base.RefValue{Bool} # populated when the test item is finished running
132133
eval_number::Base.RefValue{Int} # to keep track of how many items have been run so far
133134
stats::Vector{PerfStats} # populated when the test item is finished running
134135
scheduled_for_evaluation::ScheduledForEvaluation # to keep track of whether the test item has been scheduled for evaluation
@@ -140,6 +141,7 @@ function TestItem(number, name, id, tags, default_imports, setups, retries, time
140141
TestSetup[],
141142
Ref{Int}(0),
142143
DefaultTestSet[],
144+
Ref{Bool}(),
143145
Ref{Int}(0),
144146
PerfStats[],
145147
ScheduledForEvaluation(),

src/testcontext.jl

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ function record_results!(file::FileNode, ti::TestItem)
141141
# Always record last try as the final status, so a pass-on-retry is a pass.
142142
Test.record(file.testset, last(ti.testsets))
143143
junit_record!(file.junit, ti)
144+
_cache_status!(ti)
144145
end
145146
end
146147

@@ -151,11 +152,16 @@ junit_record!(_, ::Nothing) = nothing
151152

152153
Test.finish(ti::TestItems) = Test.finish(ti.graph.testset)
153154

154-
function get_starting_testitems(ti::TestItems, n)
155-
# we want to select n evenly spaced test items from ti.testitems
155+
function get_starting_testitems(ti::TestItems, n::Int; is_sorted::Bool=false)
156156
len = length(ti.testitems)
157-
step = max(1, len / n)
158-
testitems = [ti.testitems[round(Int, i)] for i in 1:step:len]
157+
if is_sorted
158+
# select the first n test items
159+
testitems = ti.testitems[1:min(n, len)]
160+
else
161+
# select n evenly spaced test items starting with the first one
162+
step = max(1, len / n)
163+
testitems = [ti.testitems[round(Int, i)] for i in 1:step:len]
164+
end
159165
@debugv 2 "get_starting_testitems len=$len n=$n allunique=$(allunique(testitems))"
160166
@assert length(testitems) == min(n, len) && allunique(testitems)
161167
for (i, t) in enumerate(testitems)

test/integrationtests.jl

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1514,4 +1514,76 @@ end
15141514
@test contains(c.output, "3/3 test items were run.")
15151515
end
15161516

1517+
@testset "failures_first" verbose=true begin
1518+
using IOCapture
1519+
# we use logs to tell us the order in which tests were run.
1520+
function testitems_runorder(logstr::String)
1521+
re = r"START \((?<num>\d)/\d\) test item \"(?<name>.*)\""
1522+
names = [String(m[:name]) for m in eachmatch(re, logstr)]
1523+
order = [parse(Int, m[:num]) for m in eachmatch(re, logstr)]
1524+
return names[order]
1525+
end
1526+
file = joinpath(TEST_FILES_DIR, "_failures_first_tests.jl")
1527+
@testset for nworkers in (0, 1)
1528+
ReTestItems.reset_test_status!()
1529+
for run in (1, 2)
1530+
c = IOCapture.capture() do
1531+
encased_testset(()->runtests(file; failures_first=true, nworkers))
1532+
end
1533+
results = c.value
1534+
@test n_tests(results) == 4
1535+
@test n_passed(results) == 2
1536+
tis = testitems_runorder(c.output)
1537+
if run == 1
1538+
@test tis == ["a. pass", "b. fail", "c. pass", "d. fail"]
1539+
else
1540+
@test tis == ["b. fail", "d. fail", "a. pass", "c. pass"]
1541+
end
1542+
end
1543+
# run a subset of tests
1544+
name = r"^a|^d"
1545+
c = IOCapture.capture() do
1546+
encased_testset(()->runtests(file; failures_first=true, nworkers, name))
1547+
end
1548+
results = c.value
1549+
@test n_tests(results) == 2
1550+
@test n_passed(results) == 1
1551+
tis = testitems_runorder(c.output)
1552+
@test tis == ["d. fail", "a. pass"]
1553+
# run including new tests
1554+
file2 = joinpath(TEST_FILES_DIR, "_happy_tests.jl")
1555+
c = IOCapture.capture() do
1556+
encased_testset(()->runtests(file, file2; failures_first=true, nworkers))
1557+
end
1558+
results = c.value
1559+
@test n_tests(results) == 4 + 3
1560+
@test n_passed(results) == 2 + 3
1561+
tis = testitems_runorder(c.output)
1562+
new_tests = ["happy 1", "happy 2", "happy 3"]
1563+
@test tis == ["b. fail", "d. fail", new_tests..., "a. pass", "c. pass"]
1564+
end
1565+
@testset "nworkers=2" begin
1566+
nworkers = 2
1567+
ReTestItems.reset_test_status!()
1568+
for run in (1, 2)
1569+
c = IOCapture.capture() do
1570+
encased_testset(()->runtests(file; failures_first=true, nworkers))
1571+
end
1572+
results = c.value
1573+
@test n_tests(results) == 4
1574+
tis = testitems_runorder(c.output)
1575+
if run == 1
1576+
# The 2 workers grab evenly spaced out testitems, starting with the first
1577+
# one, hence a. and c.
1578+
@test Set(tis[1:2]) == Set(["a. pass", "c. pass"])
1579+
@test Set(tis[3:4]) == Set(["b. fail", "d. fail"])
1580+
else
1581+
# The 2 workers should get the failures first, hence b. and d.
1582+
@test Set(tis[1:2]) == Set(["b. fail", "d. fail"])
1583+
@test Set(tis[3:4]) == Set(["a. pass", "c. pass"])
1584+
end
1585+
end
1586+
end
1587+
end
1588+
15171589
end # integrationtests.jl testset

test/internals.jl

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,21 @@ using ReTestItems
1111
# let's test this exhaustively for 1-10 testitems across 1-10 workers.
1212
for nworkers in 1:10
1313
for nitems in 1:10
14-
testitems = [@testitem("ti-$i", _run=false, begin end) for i in 1:nitems]
15-
starts = get_starting_testitems(TestItems(graph, testitems), nworkers)
16-
startitems = [x for x in starts if !isnothing(x)]
17-
@test length(starts) == nworkers
18-
@test length(startitems) == min(nworkers, nitems)
19-
@test allunique(ti.name for ti in startitems)
14+
for is_sorted in (true, false)
15+
testitems = [@testitem("ti-$i", _run=false, begin end) for i in 1:nitems]
16+
starts = get_starting_testitems(TestItems(graph, testitems), nworkers; is_sorted)
17+
startitems = [x for x in starts if !isnothing(x)]
18+
@test length(starts) == nworkers
19+
@test length(startitems) == min(nworkers, nitems)
20+
@test allunique(ti.name for ti in startitems)
21+
end
2022
end
2123
end
24+
# the `is_sorted` case just returns the first `n` items
25+
n = 3
26+
testitems = [@testitem("ti-$i", _run=false, begin end) for i in 1:(2n)]
27+
starts = get_starting_testitems(TestItems(graph, testitems), n; is_sorted=true)
28+
@test starts == testitems[1:n]
2229
end
2330

2431
@testset "is_test_file" begin
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Used to test the order in which tests are run
2+
@testitem "a. pass" begin
3+
@test 1 == 1
4+
end
5+
@testitem "b. fail" begin
6+
@test 1 == 3
7+
end
8+
@testitem "c. pass" begin
9+
@test 2 == 2
10+
end
11+
@testitem "d. fail" begin
12+
@test 2 == 4
13+
end

0 commit comments

Comments
 (0)