Nested async testsets

Documenting this mostly for myself, but may be useful for others as well.


Julia currently does not handle nested asynchronous test sets correctly.

julia> @testset verbose=true "foo" begin
           @sync @async @testset "bar" begin
               @test true
           end
       end;
Test Summary: | Pass  Total  Time
bar           |    1      1  0.0s
Test Summary: |Time
foo           | None  0.0s

The following macro fixes this.

"""
   @async_testset args...

Like `@async @testset args...`, but makes sure that the given test set is
executed within the scope of the enclosing test set (if any).
"""
macro async_testset(args...)
    testset_stack = gensym("testset_stack")
    esc(quote
        let $testset_stack = Base.task_local_storage(:__BASETESTNEXT__)
            @async begin
                Base.task_local_storage(
                    :__BASETESTNEXT__,
                    $testset_stack
                )
                @testset($(args...))
            end
        end
    end)
end

Example:

julia> @testset verbose=true "foo" begin
           @sync @async_testset "bar" begin
               @test true
           end
       end;
Test Summary: | Pass  Total  Time
foo           |    1      1  0.0s
  bar         |    1      1  0.0s
3 Likes

Looks good. Why not open a PR for the Test module?

Actually, it’s broken.

julia> @testset verbose=true "parent" begin
           @sync begin
               @async_testset "child 1" begin
                   sleep(1.0)
               end
               @async_testset "child 2" begin
               end
           end
       end;
Test Summary: |Time
parent        | None  1.0s
  child 1     | None  1.0s

I believe the problem is that the testing library gets confused if pushes and pops don’t align. The fact that the authors of Test explicitly used task-local storage should have been a hint, really…

Yes, I think some form of synchronization between tasks is needed in order to ensure that sub-testsets are recorded sequentially in their parent.

Something like this should mostly work (although the output of concurrent tasks gets mangled):

using Test


struct SilentTestSet <: Test.AbstractTestSet
    ts
    SilentTestSet(args...; kwargs...) = new(Test.DefaultTestSet(args...; kwargs...))
end

Test.record(ts::SilentTestSet, arg) = Test.record(ts.ts, arg)
Test.finish(ts::SilentTestSet) = ts.ts


function async_testsets(body)
    channel = Channel()
    task = let parent = Test.get_testset()
        @async for ts in channel
            Test.record(parent, ts)
        end
    end

    function run_testset(body, name)
        try
            ts = @testset SilentTestSet "$name" begin
                body()
            end
            push!(channel, ts)
        catch e
            e isa Test.TestSetException || rethrow()
        end
    end
    body(run_testset)

    close(channel)
    wait(task)
end
julia> @testset "Root" begin
           async_testsets() do run_testset
               @sync begin
                   @async run_testset("Child 1") do
                       @test 1==1
                       @test 1==2
                   end
                   @async run_testset("Child 2") do
                       @test 1==1
                       @test 3==4
                   end
               end
           end
       end
Child 1: Test Failed at REPL[6]:6
  Expression: 1 == 2
   Evaluated: 1 == 2
Stacktrace:
 [1] Child 2macro expansion: 
Test Failed   @  at ~/.asdf/installs/julia/1.8.5/share/julia/stdlib/v1.8/Test/src/REPL[6]:10
Test.jl:464  Expression:  [inlined]3 == 4

   Evaluated: 3 == 4
Stacktrace:
 [1]  macro expansion[2]
    @ (::var"#8#13")~/.asdf/installs/julia/1.8.5/share/julia/stdlib/v1.8/Test/src/(Test.jl:464) [inlined]

   @  Main[2]  ./(::var"#10#15")REPL[6]:6()

   @ Main ./REPL[6]:10
Test Summary: | Pass  Fail  Total  Time
Root          |    2     2      4  0.4s
  Child 1     |    1     1      2      
  Child 2     |    1     1      2      
ERROR: Some tests did not pass: 2 passed, 2 failed, 0 errored, 0 broken.

I think you can fix the output mangling by recording test results within @async contexts and then playing them back serially after the @async sections completed. But for my part, I came to the conclusion that for my purposes, a simple @assert works just as well as an @test, and doing so resolves all concurrency issues without straying too far from standard Julia.