Skip to content

Commit dc61eec

Browse files
vchuravymaleadtchristiangnrd
authored
Add WorkerTestSet to avoid loss of context due to TestSetException (#55)
Co-authored-by: Tim Besard <tim.besard@gmail.com> Co-authored-by: Christian Guinard <28689358+christiangnrd@users.noreply.github.com>
1 parent 681ae1a commit dc61eec

File tree

2 files changed

+91
-33
lines changed

2 files changed

+91
-33
lines changed

src/ParallelTestRunner.jl

Lines changed: 61 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ using Serialization
1212
import Test
1313
import Random
1414
import IOCapture
15+
import Test: DefaultTestSet
16+
17+
function anynonpass(ts::Test.AbstractTestSet)
18+
@static if VERSION >= v"1.13.0-DEV.1037"
19+
return Test.anynonpass(ts)
20+
else
21+
Test.get_test_counts(ts)
22+
return ts.anynonpass
23+
end
24+
end
1525

1626
#Always set the max rss so that if tests add large global variables (which they do) we don't make the GC's life too hard
1727
if Sys.WORD_SIZE == 64
@@ -68,7 +78,7 @@ end
6878
abstract type AbstractTestRecord end
6979

7080
struct TestRecord <: AbstractTestRecord
71-
value::Any # AbstractTestSet or TestSetException
81+
value::DefaultTestSet
7282
output::String # captured stdout/stderr
7383

7484
# stats
@@ -82,6 +92,10 @@ function memory_usage(rec::TestRecord)
8292
return rec.rss
8393
end
8494

95+
function Base.getindex(rec::TestRecord)
96+
return rec.value
97+
end
98+
8599

86100
#
87101
# overridable I/O context for pretty-printing
@@ -213,13 +227,46 @@ end
213227
#
214228
# entry point
215229
#
230+
"""
231+
WorkerTestSet
232+
233+
A test set wrapper used internally by worker processes.
234+
`Base.DefaultTestSet` detects when it is the top-most and throws
235+
a `TestSetException` containing very little information. By inserting this
236+
wrapper as the top-most test set, we can capture the full results.
237+
"""
238+
mutable struct WorkerTestSet <: Test.AbstractTestSet
239+
const name::String
240+
wrapped_ts::Test.DefaultTestSet
241+
function WorkerTestSet(name::AbstractString)
242+
new(name)
243+
end
244+
end
245+
246+
function Test.record(ts::WorkerTestSet, res)
247+
@assert res isa Test.DefaultTestSet
248+
@assert !isdefined(ts, :wrapped_ts)
249+
ts.wrapped_ts = res
250+
return nothing
251+
end
252+
253+
function Test.finish(ts::WorkerTestSet)
254+
# This testset is just a placeholder so it must be the top-most
255+
@assert Test.get_testset_depth() == 0
256+
@assert isdefined(ts, :wrapped_ts)
257+
# Return the wrapped_ts so that we don't need to handle WorkerTestSet anywhere else
258+
return ts.wrapped_ts
259+
end
216260

217261
function runtest(::Type{TestRecord}, f, name, init_code, color)
218262
function inner()
219263
# generate a temporary module to execute the tests in
220264
mod = @eval(Main, module $(gensym(name)) end)
221265
@eval(mod, import ParallelTestRunner: Test, Random)
222266
@eval(mod, using .Test, .Random)
267+
# Both bindings must be imported since `@testset` can't handle fully-qualified names when VERSION < v"1.11.0-DEV.1518".
268+
@eval(mod, import ParallelTestRunner: WorkerTestSet)
269+
@eval(mod, import Test: DefaultTestSet)
223270

224271
Core.eval(mod, init_code)
225272

@@ -229,15 +276,13 @@ function runtest(::Type{TestRecord}, f, name, init_code, color)
229276

230277
mktemp() do path, io
231278
stats = redirect_stdio(stdout=io, stderr=io) do
232-
@timed try
233-
@testset $name begin
279+
# @testset CustomTestRecord switches the all lower-level testset to our custom testset,
280+
# so we need to have two layers here such that the user-defined testsets are using `DefaultTestSet`.
281+
# This also guarantees our invariant about `WorkerTestSet` containing a single `DefaultTestSet`.
282+
@timed @testset WorkerTestSet "placeholder" begin
283+
@testset DefaultTestSet $name begin
234284
$f
235285
end
236-
catch err
237-
isa(err, Test.TestSetException) || rethrow()
238-
239-
# return the error to package it into a TestRecord
240-
err
241286
end
242287
end
243288
close(io)
@@ -719,7 +764,7 @@ function runtests(mod::Module, ARGS; test_filter = Returns(true), RecordType = T
719764
test_name, wrkr, record = msg[2], msg[3], msg[4]
720765

721766
clear_status()
722-
if record.value isa Exception
767+
if anynonpass(record[])
723768
print_test_failed(record, wrkr, test_name, io_ctx)
724769
else
725770
print_test_finished(record, wrkr, test_name, io_ctx)
@@ -814,6 +859,7 @@ function runtests(mod::Module, ARGS; test_filter = Returns(true), RecordType = T
814859
Malt.stop(wrkr)
815860
end
816861
else
862+
# One of Malt.TerminatedWorkerException, Malt.RemoteException, or ErrorException
817863
@assert result isa Exception
818864
put!(printer_channel, (:crashed, test, worker_id(wrkr)))
819865
if do_quickfail
@@ -932,31 +978,14 @@ function runtests(mod::Module, ARGS; test_filter = Returns(true), RecordType = T
932978
for (testname, result, start, stop) in results
933979
push!(completed_tests, testname)
934980

935-
# decode or fake a testset
936981
if result isa AbstractTestRecord
937-
if result.value isa Test.AbstractTestSet
938-
testset = result.value
939-
historical_durations[testname] = stop - start
940-
else
941-
# TODO: improve the Test stdlib to keep track of the exact failure
942-
# instead of flattening into an exception without provenance
943-
@assert result.value isa Test.TestSetException
944-
testset = create_testset(testname; start, stop)
945-
for i in 1:result.value.pass
946-
Test.record(testset, Test.Pass(:test, nothing, nothing, nothing, LineNumberNode(@__LINE__, @__FILE__)))
947-
end
948-
for i in 1:result.value.broken
949-
Test.record(testset, Test.Broken(:test, nothing))
950-
end
951-
for t in result.value.errors_and_fails
952-
Test.record(testset, t)
953-
end
954-
end
982+
testset = result[]::DefaultTestSet
983+
historical_durations[testname] = stop - start
955984
else
956-
# If this test raised an exception that is not a remote testset
957-
# exception, that means the test runner itself had some problem, so we
958-
# may have hit a segfault, deserialization errors or something similar.
985+
# If this test raised an exception that means the test runner itself had some problem,
986+
# so we may have hit a segfault, deserialization errors or something similar.
959987
# Record this testset as Errored.
988+
# One of Malt.TerminatedWorkerException, Malt.RemoteException, or ErrorException
960989
@assert result isa Exception
961990
testset = create_testset(testname; start, stop)
962991
Test.record(testset, Test.Error(:nontest_error, testname, nothing, Base.ExceptionStack(NamedTuple[(;exception = result, backtrace = [])]), LineNumberNode(1)))
@@ -1003,8 +1032,7 @@ function runtests(mod::Module, ARGS; test_filter = Returns(true), RecordType = T
10031032
end
10041033
print(io_ctx.stdout, c.output)
10051034
end
1006-
if (VERSION >= v"1.13.0-DEV.1037" && !Test.anynonpass(o_ts)) ||
1007-
(VERSION < v"1.13.0-DEV.1037" && !o_ts.anynonpass)
1035+
if !anynonpass(o_ts)
10081036
println(io_ctx.stdout, " \033[32;1mSUCCESS\033[0m")
10091037
else
10101038
println(io_ctx.stderr, " \033[31;1mFAILURE\033[0m\n")

test/runtests.jl

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,36 @@ end
9797
@test contains(str, "1 == 2")
9898
end
9999

100+
@testset "nested failure" begin
101+
custom_tests = Dict(
102+
"nested" => quote
103+
@test true
104+
@testset "foo" begin
105+
@test true
106+
@testset "bar" begin
107+
@test false
108+
end
109+
end
110+
end
111+
)
112+
error_line = @__LINE__() - 5
113+
114+
io = IOBuffer()
115+
@test_throws Test.FallbackTestSetException("Test run finished with errors") begin
116+
runtests(ParallelTestRunner, ["--verbose"]; custom_tests, stdout=io, stderr=io)
117+
end
118+
119+
str = String(take!(io))
120+
@test contains(str, r"nested .+ started at")
121+
@test contains(str, r"nested .+ failed at")
122+
@test contains(str, r"nested .+ \| .+ 2 .+ 1 .+ 3")
123+
@test contains(str, r"foo .+ \| .+ 1 .+ 1 .+ 2")
124+
@test contains(str, r"bar .+ \| .+ 1 .+ 1")
125+
@test contains(str, "FAILURE")
126+
@test contains(str, "Error in testset bar")
127+
@test contains(str, "$(basename(@__FILE__)):$error_line")
128+
end
129+
100130
@testset "throwing test" begin
101131
custom_tests = Dict(
102132
"throwing test" => quote

0 commit comments

Comments
 (0)