# TestRunner.jl A Julia package for selective test execution using pattern matching. TestRunner allows you to run specific tests from a test file based on testset names, test expressions, or line numbers, while ensuring all necessary dependencies are executed. ## Features - **Pattern-based test selection**: Run only the tests that match your specified patterns - **Multiple pattern types**: String matching, regex patterns, expression, and line number patterns. - **Fast execution**: Test code is interpreted only at top-level; function calls within tests are compiled normally while avoiding execution of unrelated test code - **JSON output**: Machine-readable test results with diagnostics for integration with editors and CI systems ## Requirements - Julia 1.12 or higher ## Installation ### As a CLI application (recommended) Install from the `release` branch, which has vendored dependencies to avoid conflicts with your project's packages: ```julia-repl pkg> app add https://github.com/aviatesk/TestRunner.jl#release ``` This installs the `testrunner` executable. Make sure `~/.julia/bin` is in your `PATH`. See for details. ### As a package For programmatic usage within Julia: ```julia-repl pkg> add https://github.com/aviatesk/TestRunner.jl#release ``` ## Quick Start ```bash $ testrunner demo.jl "basic tests" # Run tests matching a specific testset name $ testrunner demo.jl "basic tests" "struct tests" # Run multiple testsets $ testrunner demo.jl '(:(@test startswith(inner_func2(), "inner")))' # Run standalone test case ``` Or equivalently via Julia REPL: ```julia-repl julia> using TestRunner julia> runtest("demo.jl", ["basic tests"]) julia> runtest("demo.jl", ["basic tests", "struct tests"]) julia> runtest("demo.jl", [:(@test startswith(inner_func2(), "inner"))]) ``` ## Programmatic Usage ### `runtest` ```julia runtest(filename::AbstractString, patterns; filter_lines=nothing, topmodule::Module=Main, source=nothing) ``` Run tests from a file that match the given patterns and/or are on the specified lines. **Arguments:** - `filename::AbstractString`: Path to the test file - `patterns`: Patterns to match. Can be strings, regexes, expressions, integers (line numbers), or ranges (line ranges) - `filter_lines=nothing`: Optional line numbers to filter pattern matches. When provided, only pattern matches that overlap with these lines will be executed. This is particularly useful for IDE integration where clicking on a specific test should run only that test, even when multiple tests may match the same pattern - `topmodule::Module=Main`: Module context for execution (default: `Main`) - `source::Union{Nothing,AbstractString}=nothing`: When provided, use this source text for the entry file instead of reading it from disk. `filename` is still used for `@__FILE__`, error messages, and resolving paths of `include`d files. Useful for editor integrations that want to run tests against an unsaved buffer **Returns:** - Test results from the selectively executed tests ### `runtests` ```julia runtests(entryfilename::AbstractString, patterns; filter_lines=nothing, topmodule::Module=Main, source=nothing) ``` The package also provides a `runtests` function for advanced use cases like selectively running package test cases from `test/runtests.jl`, where you need to specify different patterns for different files in a test suite that includes multiple files via `include` statements. See its docstring for detailed usage. ### `TestRunnerTestSet` Since `runtest` and `runtests` execute Test.jl code using JuliaInterpreter, they cannot directly utilize the test failure handling provided by Test.jl. Therefore, this package implements a custom `@testset` type called `TestRunnerTestSet`. When `runtest[s]` is executed within `@testset TestRunnerTestSet`, you get equivalent test failure handling to Test.jl. For programmatic usage, it can be used specifically in the following way: ```julia using Test, TestRunner @testset TestRunnerTestSet runtest("mytest.jl", ["my tests"]) ``` **It is recommended to always use `runtest[s]` together with `@testset TestRunnerTestSet` as shown above.** `TestRunnerTestSet` is automatically used in the [`testrunner` app](#app-usage). See the [Test Failure/Error Handling](#test-failureerror-handling) section for detailed comparisons showing how different types of failures are reported. ## Pattern Types ### String Patterns Match testsets by exact name: ```julia # Match a testset by name runtest("demo.jl", ["struct tests"]) # matches any testset whose name is "struct tests" ``` ### Regex Patterns Match testsets using regular expressions: ```julia runtest("demo.jl", [r"foo"]) # matches any testset containing "foo" ``` ### Expression Patterns Match arbitrary Julia expressions using MacroTools patterns: ```julia runtest("demo.jl", [:(@test startswith(s_, prefix_))]) # matches e.g. `@test startswith(s, "Julia")` runtest("demo.jl", [:(@test a_ > b_)]) # matches e.g. `@test x > 0` ``` ### Line Number Patterns Directly specify line numbers or ranges to execute: ```julia # Run code on specific lines runtest("demo.jl", [10, 20, 30]) # Run code in a line range runtest("demo.jl", [10:15]) # Combine with other patterns runtest("demo.jl", ["basic tests", 42, 50:55]) ``` ## App Usage TestRunner can be installed as a CLI executable (see the [installation](#installation) section): ```bash # Run specific testsets by name testrunner mypkg/runtests.jl.jl "basic tests" "advanced tests" # Run tests on specific lines testrunner mypkg/runtests.jl.jl L10 testrunner mypkg/runtests.jl.jl L10:20 # Run tests matching expression patterns testrunner mypkg/runtests.jl.jl ':(@test foo(x_) == y_)' # Run tests matching regex patterns testrunner mypkg/runtests.jl.jl r"^test.*basic" # Run all tests in a file testrunner mypkg/runtests.jl.jl # Combine patterns with filter lines testrunner mypkg/runtests.jl.jl "my tests" --filter-lines=10,15,20:25 # Use verbose output testrunner -v mypkg/runtests.jl.jl L55:57 # Use a specific project environment testrunner --project=/path/to/project mypkg/runtests.jl.jl "my tests" # Show help testrunner --help # Output results in JSON format testrunner --json mypkg/runtests.jl "my tests" # Read source from stdin (e.g. an unsaved editor buffer); the file path is # still needed for `@__FILE__` and to resolve any `include`d files cat my-edits.jl | testrunner --read-stdin --json mypkg/runtests.jl "my tests" ``` Pattern formats: - `L10` - Run tests on line 10 - `L10:20` - Run tests on lines 10-20 - `:(expr)` - Match expression pattern - `r"^test.*"` - Match testset names with regex - `"my tests"` - Match testset by exact name (default) Options: - `--project[=]` - Set project/environment (same format and meaning as Julia's `--project` flag) - `--filter-lines=1,5,10:20` or `-f=1,5,10:20` - Filter to specific lines - `--verbose` or `-v` - Show verbose output - `--json` - Output results in JSON format for machine-readable test results - `--read-stdin` - Read source for the given file path from stdin instead of disk. The path is still used for `@__FILE__`, error messages, and resolving `include`d files ## Examples Given this [demo.jl](./demo.jl) file: > demo.jl ```julia using Test struct MyStruct value::Int end function process(s::MyStruct) return s.value * 2 end @testset "basic tests" begin @test 1 + 1 == 2 @test 2 * 2 == 4 @test_broken 1 + 1 == 3 # This is expected to fail end @testset "struct tests" begin s = MyStruct(5) @test process(s) == 10 @test s.value == 5 end # Standalone test @test process(MyStruct(3)) == 6 @testset "nested tests" begin outer_func() = "outer" @test outer_func() == "outer" @testset "inner tests 1" begin inner_func1() = "inner1" @test inner_func1() == "inner1" @test length(inner_func1()) == 6 end @testset "inner tests 2" begin inner_func2() = "inner2" @test inner_func2() == "inner2" @test startswith(inner_func2(), "inner") end end @testset "calculator tests" begin add(a, b) = a + b mul(a, b) = a * b @test add(2, 3) == 5 # line 55 @test mul(3, 4) == 12 # line 56 @test add(10, 20) == 30 # line 57 end # More standalone tests @test 100 - 50 == 50 # line 61 @test sqrt(16) == 4 # line 62 @testset "Test failure" begin @test sin(0) == π end @testset "Exception inside of `@test`" begin @test sin(Inf) == π @test sin(0) == 0 @test cos(Inf) == π end @testset "Exception outside of `@test`" begin v = sin(Inf) @test v == π @test @isdefined v # not executed end ``` ```julia-repl julia> using TestRunner ``` Run `@testset "basic tests"`: ```julia-repl julia> @testset "Basic tests runner" verbose=true runtest("demo.jl", ["basic tests"]); Test Summary: | Pass Broken Total Time Basic tests runner | 2 1 3 0.0s basic tests | 2 1 3 0.0s ``` `@testset` can be selected with regex: ```julia-repl julia> @testset "Regex runner" verbose=true runtest("demo.jl", [r".*tests"]); Test Summary: | Pass Broken Total Time Regex runner | 12 1 13 0.0s basic tests | 2 1 3 0.0s struct tests | 2 2 0.0s nested tests | 5 5 0.0s calculator tests | 3 3 0.0s ``` Run individual `@test` cases that use the `process` function: ```julia-repl julia> @testset "Standalone runner" verbose=true runtest("demo.jl", [:(@test process(s_) == n_)]); Test Summary: | Pass Total Time Standalone runner | 2 2 0.0s ``` Nested `@testset` can be selected: ```julia-repl julia> @testset "Nested runner" verbose=true runtest("demo.jl", ["inner tests 1"]); Test Summary: | Pass Total Time Nested runner | 2 2 0.0s inner tests 1 | 2 2 0.0s ``` Individual `@test` cases can be selectively matched using pattern expressions: ```julia-repl julia> @testset "Pattern in nested" verbose=true runtest("demo.jl", [:(@test startswith(s_, prefix_))]); Test Summary: | Pass Total Time Pattern in nested | 1 1 0.0s ``` We can run tests by directly specifying line numbers: ```julia-repl julia> @testset "Single line" verbose=true runtest("demo.jl", [56]); # Run only the test on line 56 Test Summary: | Pass Total Time Single line | 1 1 0.0s julia> @testset "Line range" verbose=true runtest("demo.jl", [55:57]); # Run tests in lines 55-57 Test Summary: | Pass Total Time Line range | 3 3 0.0s julia> @testset "Mixed patterns" verbose=true runtest("demo.jl", ["calculator tests", 61]); # Combine named testsets with line numbers Test Summary: | Pass Total Time Mixed patterns | 4 4 0.0s calculator tests | 3 3 0.0s ``` > [!note] > Note that the `@testset "xxx runner" verbose=true` part is used only to show > the test results in an organized way and is not required for TestRunner > functionality itself. ### Test Failure/Error Handling When tests fail or encounter errors, TestRunner provides enhanced debugging capabilities through its custom `TestRunnerTestSet` type. This section explains how different types of test failures are handled and reported. Let's compare how different types of test failures are reported with and without `TestRunnerTestSet`: #### 1. Test Failure For simple assertion failures, there's no difference between the two approaches. Both provide the full interpreter stacktrace. **With `Test.DefaultTestSet`:** ```julia-repl julia> @testset verbose=true runtest("demo.jl", ["Test failure"]); Test failure: Test Failed at demo.jl:68 Expression: sin(0) == π Evaluated: 0.0 == π Stacktrace: [1] evaluate_call!(interp::TestRunner.TRInterpreter, frame::JuliaInterpreter.Frame, fargs::Vector{Any}, ::Bool) @ TestRunner ~/julia/packages/TestRunner/src/TestRunner.jl:515 ... ``` **With `TestRunnerTestSet`:** ```julia-repl julia> @testset TestRunnerTestSet verbose=true runtest("demo.jl", ["Test failure"]); Test failure: Test Failed at demo.jl:68 Expression: sin(0) == π Evaluated: 0.0 == π Stacktrace: [1] evaluate_call!(interp::TestRunner.TRInterpreter, frame::JuliaInterpreter.Frame, fargs::Vector{Any}, ::Bool) @ TestRunner ~/julia/packages/TestRunner/src/TestRunner.jl:515 ... ``` #### 2. Exception Inside `@test` When an exception occurs within a `@test` expression, full exception information is only available with `TestRunnerTestSet`. **With `Test.DefaultTestSet`:** ```julia-repl julia> @testset verbose=true runtest("demo.jl", ["Exception inside of `@test`"]); Exception inside of `@test`: Error During Test at demo.jl:72 Test threw exception Expression: sin(Inf) == π Exception inside of `@test`: Error During Test at demo.jl:74 Test threw exception Expression: cos(Inf) == π ``` **With `TestRunnerTestSet`:** ```julia-repl julia> @testset TestRunnerTestSet verbose=true runtest("demo.jl", ["Exception inside of `@test`"]); Exception inside of `@test`: Error During Test at demo.jl:72 Test threw exception Expression: sin(Inf) == π DomainError with Inf: sin(x) is only defined for finite x. Stacktrace: [1] sin_domain_error(x::Float64) @ Base.Math ./special/trig.jl:28 [2] sin(x::Float64) @ Base.Math ./special/trig.jl:39 ... Exception inside of `@test`: Error During Test at demo.jl:74 Test threw exception Expression: cos(Inf) == π DomainError with Inf: cos(x) is only defined for finite x. Stacktrace: [1] cos_domain_error(x::Float64) @ Base.Math ./special/trig.jl:97 [2] cos(x::Float64) @ Base.Math ./special/trig.jl:108 ... ``` #### 3. Exception Outside `@test` When an exception occurs outside of a `@test` macro (preventing subsequent tests from running), full exception information is available only with `TestRunnerTestSet`: **With `Test.DefaultTestSet`:** ```julia-repl julia> @testset verbose=true runtest("demo.jl", ["Exception outside of `@test`"]); Exception outside of `@test`: Error During Test at demo.jl:77 Got exception outside of a @test ``` **With `TestRunnerTestSet`:** ```julia-repl julia> @testset TestRunnerTestSet verbose=true runtest("demo.jl", ["Exception outside of `@test`"]); Exception outside of `@test`: Error During Test at demo.jl:77 Got exception outside of a @test DomainError with Inf: sin(x) is only defined for finite x. Stacktrace: [1] sin_domain_error(x::Float64) @ Base.Math ./special/trig.jl:28 [2] sin(x::Float64) @ Base.Math ./special/trig.jl:39 ... ``` ## How It Works TestRunner leverages JuliaInterpreter and LoweredCodeUtils to selectively execute test code: 1. Pattern Matching on AST: Uses JuliaSyntax to parse code and MacroTools to match patterns against the syntax tree 2. Line-based Selection: Maps matched AST nodes to source line numbers, which serve as the bridge to lowered code 3. Selective Interpretation: Only top-level code is interpreted; function calls within tests are compiled and run at normal speed 4. Conservative Dependency Execution: Executes _all_ top-level code except `@test` and `@testset` expressions to ensure tests don't fail due to missing dependencies The key insight is that in reasonably-organized test code, the conservative dependency execution would only run the function and type definitions necessary for tests, without actual test code executed. Since test execution bottlenecks are often in the test cases themselves (not in defining functions), TestRunner allows efficient execution of test cases in interest by skipping execution of unrelated tests while still ensuring all code dependencies are available. ## Limitations 1. **Source Provenance**: Pattern matching occurs at the surface AST level and results are converted to line numbers. However, lowered code representation lacks proper source provenance (especially for macro expansions), causing surface-level pattern match information to be incorrectly mapped to lowered code. Example (from [limitation1.jl](./limitations/limitation1.jl)): ```julia @testset "limitation1" begin limitation1() = nothing @test isnothing(limitation1()) #=want to run only this=#; @test isnothing(identity(limitation1())) #=but this runs too=# end ``` When trying to run only the first test: ```julia-repl julia> @testset "Limitation1 runner" verbose=true runtest("limitations/limitation1.jl", [:(@test isnothing(limitation1()))]); Test Summary: | Pass Total Time Limitation1 runner | 2 2 0.0s ``` Both tests are executed (2 tests pass) because they are on the same line. The line-based selection mechanism cannot distinguish between multiple expressions on the same line. **Workaround** (see [workaround1.jl](./limitations/workaround1.jl)): Place each `@test` on a separate line: ```julia @testset "workaround1" begin workaround1() = nothing @test isnothing(workaround1()) # want to run only this @test isnothing(identity(workaround1())) # now this test is skipped end ``` With this workaround: ```julia-repl julia> @testset "Workaround1 runner" verbose=true runtest("limitations/workaround1.jl", [:(@test isnothing(workaround1()))]); Test Summary: | Pass Total Time Workaround1 runner | 1 1 0.0s ``` Now only the matched test is executed (1 test passes). This limitation will be resolved with JuliaLowering.jl integration, which will eliminate the need for crude line number conversion from surface AST pattern matches. 2. **Conservative Dependency Execution**: Due to the conservative approach, ALL top-level code except `@test` and `@testset` expressions is executed. This means individual `@test` cases within function calls cannot be selectively executed. Example (from [limitation2.jl](./limitations/limitation2.jl)): ```julia using Test function limitation2() @test String(nameof(Test)) == "Test" end limitation2() # This executes during dependency execution @testset "selected test" limitation2() # This also executes when selected ``` When trying to match the `@testset` pattern, both the direct function call and the testset run: ```julia-repl julia> @testset "limitation2 demo" verbose=true runtest("limitations/limitation2.jl", ["selected test"]); Test Summary: | Pass Total Time limitation2 demo | 2 2 0.3s selected test | 1 1 0.0s ``` The test inside `limitation2()` executes twice: once from the top-level `limitation2()` call (which executes as part of conservative dependency execution) and once from the matched `@testset "selected test"`. **Workaround** (see [workaround2.jl](./limitations/workaround2.jl)): Wrap test execution code in `@testset` blocks instead of functions, or avoid top-level function calls that contain tests. 3. **Tests Within Function Bodies**: Individual `@test` cases within function bodies cannot be selectively executed using expression patterns. Example (from [limitation3.jl](./limitations/limitation3.jl)): ```julia function test_arithmetic() @test 1 + 1 == 2 # line 10: Can't match this with :(@test 1 + 1 == 2) @test 2 * 2 == 4 # line 11: Can't match this with :(@test 2 * 2 == 4) end ``` When trying to match a specific `@test` pattern: ```julia-repl julia> @testset "limitation3 demo" verbose=true runtest("limitations/limitation3.jl", [:(@test 1 + 1 == 2)]); Test Summary: | Total Time limitation3 demo | 0 0.0s ``` No tests execute because the pattern matching doesn't look inside function bodies. This happens because: - Pattern matching occurs at the AST level where the `@test` is inside a function definition - Function bodies are not executed during pattern matching - Only top-level `@test` expressions or those within `@testset` blocks can be matched **Workarounds** (see [workaround3.jl](./limitations/workaround3.jl)): 1. Move tests into `@testset` blocks: ```julia @testset "arithmetic tests" begin @test 1 + 1 == 2 # This CAN be matched with :(@test 1 + 1 == 2) @test 2 * 2 == 4 end ``` 2. Call test functions inside `@testset` blocks to make them selectable: ```julia @testset "function calls" begin test_arithmetic() # Now these tests execute when this testset is selected end ``` ## Development TestRunner is built on top of: - [JuliaInterpreter.jl](https://github.com/JuliaDebug/JuliaInterpreter.jl) and [LoweredCodeUtils.jl](https://github.com/JuliaDebug/LoweredCodeUtils.jl) for selective code execution - [JuliaSyntax.jl](https://github.com/JuliaLang/JuliaSyntax.jl) for parsing - [MacroTools.jl](https://github.com/FluxML/MacroTools.jl) for pattern matching ### Release Process TestRunner avoids dependency conflicts by rewriting the UUIDs of its dependencies and vendoring them. This allows the `testrunner` CLI to work reliably in any user environment. **Branch strategy:** - `master`: Development branch where regular development happens - `release`: Distribution branch for users with vendored dependencies **Updating the release:** ```bash ./scripts/prepare-release.sh ``` This script: 1. Creates a `release-update` branch from `release` and merges `master` 2. Vendors dependencies with rewritten UUIDs 3. Creates a pull request to `release` After CI passes, merge the PR using "Create a merge commit". For local testing of vendored environment: ```bash julia --startup-file=no --project=. scripts/vendor-deps.jl --source-branch=master --local ```