Skip to content

Commit

Permalink
[Test] Print RNG of a failed testset and add option to set it (#56260)
Browse files Browse the repository at this point in the history
Also, add a keyword option to `@testset` to let users override the seed
used there, to make testsets more replicable.

To give you a taster of what this PR
enables:
```
julia> using Random, Test

julia> @testset begin
           @test rand() == 0
       end;
test set: Test Failed at REPL[2]:2
  Expression: rand() == 0
   Evaluated: 0.559472630416976 == 0

Stacktrace:
 [1] top-level scope
   @ REPL[2]:2
 [2] macro expansion
   @ ~/repo/julia/usr/share/julia/stdlib/v1.12/Test/src/Test.jl:1713 [inlined]
 [3] macro expansion
   @ REPL[2]:2 [inlined]
 [4] macro expansion
   @ ~/repo/julia/usr/share/julia/stdlib/v1.12/Test/src/Test.jl:679 [inlined]
Test Summary: | Fail  Total  Time
test set      |    1      1  0.9s
ERROR: Some tests did not pass: 0 passed, 1 failed, 0 errored, 0 broken.
Random seed for this testset: Xoshiro(0x2e026445595ed28e, 0x07bb81ac4c54926d, 0x83d7d70843e8bad6, 0xdbef927d150af80b, 0xdbf91ddf2534f850)

julia> @testset rng=Xoshiro(0x2e026445595ed28e, 0x07bb81ac4c54926d, 0x83d7d70843e8bad6, 0xdbef927d150af80b, 0xdbf91ddf2534f850) begin
           @test rand() == 0.559472630416976
       end;
Test Summary: | Pass  Total  Time
test set      |    1      1  0.0s
```
This also works with nested testsets, and testsets on for loops:
```
julia> @testset rng=Xoshiro(0xc380f460355639ee, 0xb39bc754b7d63bbf, 0x1551dbcfb5ed5668, 0x71ab5a18fec21a25, 0x649d0c1be1ca5436) "Outer" begin
           @test rand() == 0.0004120194925605336
           @testset rng=Xoshiro(0xee97f5b53f7cdc49, 0x480ac387b0527d3d, 0x614b416502a9e0f5, 0x5250cb36e4a4ceb1, 0xed6615c59e475fa0) "Inner: $(i)" for i in 1:10
               @test rand() == 0.39321938407066637
           end
       end;
Test Summary: | Pass  Total  Time
Outer         |   11     11  0.0s
```

Being able to see what was the seed inside a testset and being able to
set it afterwards should make replicating test failures which only
depend on the state of the RNG much easier to debug.
  • Loading branch information
giordano authored Dec 31, 2024
1 parent d604057 commit 6136893
Show file tree
Hide file tree
Showing 3 changed files with 86 additions and 18 deletions.
9 changes: 9 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,15 @@ Standard library changes

#### Test

* A failing `DefaultTestSet` now prints to screen the random number generator (RNG) of the failed test, to help reproducing a stochastic failure which only depends on the state of the RNG.
It is also possible seed a test set by passing the `rng` keyword argument to `@testset`:
```julia
using Test, Random
@testset rng=Xoshiro(0x2e026445595ed28e, 0x07bb81ac4c54926d, 0x83d7d70843e8bad6, 0xdbef927d150af80b, 0xdbf91ddf2534f850) begin
@test rand() == 0.559472630416976
end
```

#### Dates

#### Statistics
Expand Down
64 changes: 53 additions & 11 deletions stdlib/Test/src/Test.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1071,8 +1071,9 @@ mutable struct DefaultTestSet <: AbstractTestSet
time_end::Union{Float64,Nothing}
failfast::Bool
file::Union{String,Nothing}
rng::Union{Nothing,AbstractRNG}
end
function DefaultTestSet(desc::AbstractString; verbose::Bool = false, showtiming::Bool = true, failfast::Union{Nothing,Bool} = nothing, source = nothing)
function DefaultTestSet(desc::AbstractString; verbose::Bool = false, showtiming::Bool = true, failfast::Union{Nothing,Bool} = nothing, source = nothing, rng = nothing)
if isnothing(failfast)
# pass failfast state into child testsets
parent_ts = get_testset()
Expand All @@ -1082,7 +1083,7 @@ function DefaultTestSet(desc::AbstractString; verbose::Bool = false, showtiming:
failfast = false
end
end
return DefaultTestSet(String(desc)::String, [], 0, false, verbose, showtiming, time(), nothing, failfast, extract_file(source))
return DefaultTestSet(String(desc)::String, [], 0, false, verbose, showtiming, time(), nothing, failfast, extract_file(source), rng)
end
extract_file(source::LineNumberNode) = extract_file(source.file)
extract_file(file::Symbol) = string(file)
Expand Down Expand Up @@ -1219,6 +1220,13 @@ function print_test_results(ts::AbstractTestSet, depth_pad=0)
println()
# Recursively print a summary at every level
print_counts(ts, depth_pad, align, pass_width, fail_width, error_width, broken_width, total_width, duration_width, timing)
# Print the RNG of the outer testset if there are failures
if total != total_pass + total_broken
rng = get_rng(ts)
if !isnothing(rng)
println("RNG of the outermost testset: ", rng)
end
end
end


Expand Down Expand Up @@ -1290,6 +1298,24 @@ function filter_errors(ts::DefaultTestSet)
efs
end

"""
Test.get_rng(ts::AbstractTestSet) -> Union{Nothing,AbstractRNG}
Return the global random number generator (RNG) associated to the input testset `ts`.
If no RNG is associated to it, return `nothing`.
"""
get_rng(::AbstractTestSet) = nothing
get_rng(ts::DefaultTestSet) = ts.rng
"""
Test.set_rng!(ts::AbstractTestSet, rng::AbstractRNG) -> AbstractRNG
Set the global random number generator (RNG) associated to the input testset `ts` to `rng`.
If no RNG is associated to it, do nothing.
In any case, always return the input `rng`.
"""
set_rng!(::AbstractTestSet, rng::AbstractRNG) = rng
set_rng!(ts::DefaultTestSet, rng::AbstractRNG) = ts.rng = rng

"""
TestCounts
Expand Down Expand Up @@ -1494,21 +1520,27 @@ along with a summary of the test results.
Any custom testset type (subtype of `AbstractTestSet`) can be given and it will
also be used for any nested `@testset` invocations. The given options are only
applied to the test set where they are given. The default test set type
accepts three boolean options:
- `verbose`: if `true`, the result summary of the nested testsets is shown even
accepts the following options:
- `verbose::Bool`: if `true`, the result summary of the nested testsets is shown even
when they all pass (the default is `false`).
- `showtiming`: if `true`, the duration of each displayed testset is shown
- `showtiming::Bool`: if `true`, the duration of each displayed testset is shown
(the default is `true`).
- `failfast`: if `true`, any test failure or error will cause the testset and any
- `failfast::Bool`: if `true`, any test failure or error will cause the testset and any
child testsets to return immediately (the default is `false`).
This can also be set globally via the env var `JULIA_TEST_FAILFAST`.
- `rng::Random.AbstractRNG`: use the given random number generator (RNG) as the global one
for the testset. `rng` must be `copy!`-able. This option can be useful to locally
reproduce stochastic test failures which only depend on the state of the global RNG.
!!! compat "Julia 1.8"
`@testset test_func()` requires at least Julia 1.8.
!!! compat "Julia 1.9"
`failfast` requires at least Julia 1.9.
!!! compat "Julia 1.12"
The `rng` option requires at least Julia 1.12.
The description string accepts interpolation from the loop indices.
If no description is provided, one is constructed based on the variables.
If a function call is provided, its name will be used.
Expand All @@ -1521,13 +1553,19 @@ method, which by default will return a list of the testset objects used in
each iteration.
Before the execution of the body of a `@testset`, there is an implicit
call to `Random.seed!(seed)` where `seed` is the current seed of the global RNG.
call to `copy!(Random.default_rng(), rng)` where `rng` is the RNG of the current task, or
the value of the RNG passed via the `rng` option.
Moreover, after the execution of the body, the state of the global RNG is
restored to what it was before the `@testset`. This is meant to ease
reproducibility in case of failure, and to allow seamless
re-arrangements of `@testset`s regardless of their side-effect on the
global RNG state.
!!! note "RNG of nested testsets"
Unless changed with the `rng` option, the same RNG is set at the beginning of all
nested testsets. The RNG printed to screen when a testset has failures is the global RNG of
the outermost testset even if inner testsets have different RNGs manually set by the user.
## Examples
```jldoctest; filter = r"trigonometric identities | 4 4 [0-9\\.]+s"
julia> @testset "trigonometric identities" begin
Expand Down Expand Up @@ -1717,9 +1755,11 @@ function testset_beginend_call(args, tests, source)
# by wrapping the body in a function
local default_rng_orig = copy(default_rng())
local tls_seed_orig = copy(Random.get_tls_seed())
local tls_seed = isnothing(get_rng(ts)) ? set_rng!(ts, tls_seed_orig) : get_rng(ts)
try
# default RNG is reset to its state from last `seed!()` to ease reproduce a failed test
copy!(Random.default_rng(), tls_seed_orig)
copy!(Random.default_rng(), tls_seed)
copy!(Random.get_tls_seed(), Random.default_rng())
let
$(esc(tests))
end
Expand Down Expand Up @@ -1800,10 +1840,10 @@ function testset_forloop(args, testloop, source)
finish_errored = true
push!(arr, finish(ts))
finish_errored = false
copy!(default_rng(), tls_seed_orig)
copy!(default_rng(), tls_seed)
end
ts = if ($testsettype === $DefaultTestSet) && $(isa(source, LineNumberNode))
$(testsettype)($desc; source=$(QuoteNode(source.file)), $options...)
$(testsettype)($desc; source=$(QuoteNode(source.file)), $options..., rng=tls_seed)
else
$(testsettype)($desc; $options...)
end
Expand All @@ -1825,10 +1865,12 @@ function testset_forloop(args, testloop, source)
local arr = Vector{Any}()
local first_iteration = true
local ts
local rng_option = get($(options), :rng, nothing)
local finish_errored = false
local default_rng_orig = copy(default_rng())
local tls_seed_orig = copy(Random.get_tls_seed())
copy!(Random.default_rng(), tls_seed_orig)
local tls_seed = isnothing(rng_option) ? copy(Random.get_tls_seed()) : rng_option
copy!(Random.default_rng(), tls_seed)
try
let
$(Expr(:for, Expr(:block, [esc(v) for v in loopvars]...), blk))
Expand Down
31 changes: 24 additions & 7 deletions stdlib/Test/test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1712,13 +1712,13 @@ end

# this tests both the `TestCounts` parts as well as the fallback `x`s
expected = r"""
Test Summary: | Pass Fail Error Broken Total Time
outer | 3 1 1 1 6 \s*\d*.\ds
a | 1 1 \s*\d*.\ds
custom | 1 1 1 1 4 \s*?s
no-record | x x x x ? \s*?s
b | 1 1 \s*\d*.\ds
ERROR: Some tests did not pass: 3 passed, 1 failed, 1 errored, 1 broken.
Test Summary: \| Pass Fail Error Broken Total Time
outer \| 3 1 1 1 6 \s*\d*.\ds
a \| 1 1 \s*\d*.\ds
custom \| 1 1 1 1 4 \s*\?s
no-record \| x x x x \? \s*\?s
b \| 1 1 \s*\d*.\ds
RNG of the outermost testset: .*
"""

cmd = `$(Base.julia_cmd()) --startup-file=no --color=no $f`
Expand Down Expand Up @@ -1753,3 +1753,20 @@ module M54082 end
@test only(result) isa Test.Fail
end
end

@testset "Set RNG of testset" begin
rng1 = Xoshiro(0x2e026445595ed28e, 0x07bb81ac4c54926d, 0x83d7d70843e8bad6, 0xdbef927d150af80b, 0xdbf91ddf2534f850)
rng2 = Xoshiro(0xc380f460355639ee, 0xb39bc754b7d63bbf, 0x1551dbcfb5ed5668, 0x71ab5a18fec21a25, 0x649d0c1be1ca5436)
rng3 = Xoshiro(0xee97f5b53f7cdc49, 0x480ac387b0527d3d, 0x614b416502a9e0f5, 0x5250cb36e4a4ceb1, 0xed6615c59e475fa0)

@testset rng=rng1 begin
@test rand() == rand(rng1)
end

@testset rng=rng2 "Outer" begin
@test rand() == rand(rng2)
@testset rng=rng3 "Inner: $(i)" for i in 1:10
@test rand() == rand(rng3)
end
end
end

0 comments on commit 6136893

Please sign in to comment.