Skip to content

Commit d00f9d0

Browse files
Test: @test_throws add 3-arg form for testing exception type and message (#59117)
1 parent 81352c1 commit d00f9d0

File tree

3 files changed

+167
-36
lines changed

3 files changed

+167
-36
lines changed

NEWS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ Standard library changes
8181

8282
* Test failures when using the `@test` macro now show evaluated arguments for all function calls ([#57825], [#57839]).
8383
* Transparent test sets (`@testset let`) now show context when tests error ([#58727]).
84+
* `@test_throws` now supports a three-argument form `@test_throws ExceptionType pattern expr` to test both exception type and message pattern in one call ([#59117]).
8485

8586
#### InteractiveUtils
8687

stdlib/Test/src/Test.jl

Lines changed: 106 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -799,17 +799,29 @@ end
799799

800800
"""
801801
@test_throws exception expr
802+
@test_throws extype pattern expr
802803
803804
Tests that the expression `expr` throws `exception`.
804805
The exception may specify either a type,
805806
a string, regular expression, or list of strings occurring in the displayed error message,
806807
a matching function,
807808
or a value (which will be tested for equality by comparing fields).
809+
810+
In the two-argument form, `@test_throws exception expr`, the `exception` can be a type or a pattern.
811+
812+
In the three-argument form, `@test_throws extype pattern expr`, both the exception type and
813+
a message pattern are tested. The `extype` must be a type, and `pattern` may be
814+
a string, regular expression, or list of strings occurring in the displayed error message,
815+
a matching function, or a value.
816+
808817
Note that `@test_throws` does not support a trailing keyword form.
809818
810819
!!! compat "Julia 1.8"
811820
The ability to specify anything other than a type or a value as `exception` requires Julia v1.8 or later.
812821
822+
!!! compat "Julia 1.13"
823+
The three-argument form `@test_throws extype pattern expr` requires Julia v1.12 or later.
824+
813825
# Examples
814826
```jldoctest
815827
julia> @test_throws BoundsError [1, 2, 3][4]
@@ -823,13 +835,19 @@ Test Passed
823835
julia> @test_throws "Try sqrt(Complex" sqrt(-1)
824836
Test Passed
825837
Message: "DomainError with -1.0:\\nsqrt was called with a negative real argument but will only return a complex result if called with a complex argument. Try sqrt(Complex(x))."
838+
839+
julia> @test_throws ErrorException "error foo" error("error foo 1")
840+
Test Passed
841+
Thrown: ErrorException
826842
```
827843
828-
In the final example, instead of matching a single string it could alternatively have been performed with:
844+
In the third example, instead of matching a single string it could alternatively have been performed with:
829845
830846
- `["Try", "Complex"]` (a list of strings)
831847
- `r"Try sqrt\\([Cc]omplex"` (a regular expression)
832848
- `str -> occursin("complex", str)` (a matching function)
849+
850+
In the final example, both the exception type (`ErrorException`) and message pattern (`"error foo"`) are tested.
833851
"""
834852
macro test_throws(extype, ex)
835853
orig_ex = Expr(:inert, ex)
@@ -847,6 +865,22 @@ macro test_throws(extype, ex)
847865
return :(do_test_throws($result, $orig_ex, $(esc(extype))))
848866
end
849867

868+
macro test_throws(extype, pattern, ex)
869+
orig_ex = Expr(:inert, ex)
870+
ex = Expr(:block, __source__, esc(ex))
871+
result = quote
872+
try
873+
Returned($ex, nothing, $(QuoteNode(__source__)))
874+
catch _e
875+
if $(esc(extype)) != InterruptException && _e isa InterruptException
876+
rethrow()
877+
end
878+
Threw(_e, Base.current_exceptions(), $(QuoteNode(__source__)))
879+
end
880+
end
881+
return :(do_test_throws($result, $orig_ex, $(esc(extype)), $(esc(pattern))))
882+
end
883+
850884
const MACROEXPAND_LIKE = Symbol.(("@macroexpand", "@macroexpand1", "macroexpand"))
851885

852886
function isequalexception(@nospecialize(a), @nospecialize(b))
@@ -864,49 +898,78 @@ end
864898

865899
# An internal function, called by the code generated by @test_throws
866900
# to evaluate and catch the thrown exception - if it exists
867-
function do_test_throws(result::ExecutionResult, @nospecialize(orig_expr), extype)
901+
function do_test_throws(result::ExecutionResult, @nospecialize(orig_expr), extype, pattern=nothing)
868902
if isa(result, Threw)
869903
# Check that the right type of exception was thrown
870904
success = false
871905
message_only = false
872906
exc = result.exception
873-
# NB: Throwing LoadError from macroexpands is deprecated, but in order to limit
874-
# the breakage in package tests we add extra logic here.
875-
from_macroexpand =
876-
orig_expr isa Expr &&
877-
orig_expr.head in (:call, :macrocall) &&
878-
orig_expr.args[1] in MACROEXPAND_LIKE
879-
if isa(extype, Type)
880-
success =
881-
if from_macroexpand && extype == LoadError && exc isa Exception
882-
Base.depwarn("macroexpand no longer throws a LoadError so `@test_throws LoadError ...` is deprecated and passed without checking the error type!", :do_test_throws)
883-
true
884-
elseif extype == ErrorException && isa(exc, FieldError)
885-
Base.depwarn(lazy"Using ErrorException to test field access is deprecated; use FieldError instead.", :do_test_throws)
886-
true
887-
else
888-
isa(exc, extype)
889-
end
890-
elseif isa(extype, Exception) || !isa(exc, Exception)
891-
if extype isa LoadError && !(exc isa LoadError) && typeof(extype.error) == typeof(exc)
892-
extype = extype.error # deprecated
907+
908+
# Handle three-argument form (type + pattern)
909+
if pattern !== nothing
910+
# In 3-arg form, first argument must be a type
911+
if !isa(extype, Type)
912+
testres = Fail(:test_throws_wrong, orig_expr, extype, exc, nothing, result.source, false, "First argument must be an exception type in three-argument form")
913+
record(get_testset(), testres)
914+
return
893915
end
894-
# Support `UndefVarError(:x)` meaning `UndefVarError(:x, scope)` for any `scope`.
895-
# Retains the behaviour from pre-v1.11 when `UndefVarError` didn't have `scope`.
896-
if isa(extype, UndefVarError) && !isdefined(extype, :scope)
897-
success = exc isa UndefVarError && exc.var == extype.var
898-
else isa(exc, typeof(extype))
899-
success = isequalexception(exc, extype)
916+
917+
# Format combined expected value for display
918+
pattern_str = isa(pattern, AbstractString) ? repr(pattern) :
919+
isa(pattern, Function) ? "< match function >" :
920+
string(pattern)
921+
combined_expected = string(extype) * " with pattern " * pattern_str
922+
923+
# Check both type and pattern
924+
type_success = isa(exc, extype)
925+
if type_success
926+
exc_msg = sprint(showerror, exc)
927+
pattern_success = contains_warn(exc_msg, pattern)
928+
success = pattern_success
929+
else
930+
success = false
900931
end
932+
extype = combined_expected # Use combined format for all results
901933
else
902-
message_only = true
903-
exc = sprint(showerror, exc)
904-
success = contains_warn(exc, extype)
905-
exc = repr(exc)
906-
if isa(extype, AbstractString)
907-
extype = repr(extype)
908-
elseif isa(extype, Function)
909-
extype = "< match function >"
934+
# Original two-argument form logic
935+
# NB: Throwing LoadError from macroexpands is deprecated, but in order to limit
936+
# the breakage in package tests we add extra logic here.
937+
from_macroexpand =
938+
orig_expr isa Expr &&
939+
orig_expr.head in (:call, :macrocall) &&
940+
orig_expr.args[1] in MACROEXPAND_LIKE
941+
if isa(extype, Type)
942+
success =
943+
if from_macroexpand && extype == LoadError && exc isa Exception
944+
Base.depwarn("macroexpand no longer throws a LoadError so `@test_throws LoadError ...` is deprecated and passed without checking the error type!", :do_test_throws)
945+
true
946+
elseif extype == ErrorException && isa(exc, FieldError)
947+
Base.depwarn(lazy"Using ErrorException to test field access is deprecated; use FieldError instead.", :do_test_throws)
948+
true
949+
else
950+
isa(exc, extype)
951+
end
952+
elseif isa(extype, Exception) || !isa(exc, Exception)
953+
if extype isa LoadError && !(exc isa LoadError) && typeof(extype.error) == typeof(exc)
954+
extype = extype.error # deprecated
955+
end
956+
# Support `UndefVarError(:x)` meaning `UndefVarError(:x, scope)` for any `scope`.
957+
# Retains the behaviour from pre-v1.11 when `UndefVarError` didn't have `scope`.
958+
if isa(extype, UndefVarError) && !isdefined(extype, :scope)
959+
success = exc isa UndefVarError && exc.var == extype.var
960+
else isa(exc, typeof(extype))
961+
success = isequalexception(exc, extype)
962+
end
963+
else
964+
message_only = true
965+
exc = sprint(showerror, exc)
966+
success = contains_warn(exc, extype)
967+
exc = repr(exc)
968+
if isa(extype, AbstractString)
969+
extype = repr(extype)
970+
elseif isa(extype, Function)
971+
extype = "< match function >"
972+
end
910973
end
911974
end
912975
if success
@@ -927,6 +990,13 @@ function do_test_throws(result::ExecutionResult, @nospecialize(orig_expr), extyp
927990
testres = Fail(:test_throws_wrong, orig_expr, extype, exc, nothing, result.source, message_only, bt_str)
928991
end
929992
else
993+
# Handle no exception case - need to format extype properly for 3-arg form
994+
if pattern !== nothing
995+
pattern_str = isa(pattern, AbstractString) ? repr(pattern) :
996+
isa(pattern, Function) ? "< match function >" :
997+
string(pattern)
998+
extype = string(extype) * " with pattern " * pattern_str
999+
end
9301000
testres = Fail(:test_throws_nothing, orig_expr, extype, nothing, nothing, result.source, false)
9311001
end
9321002
record(get_testset(), testres)

stdlib/Test/test/runtests.jl

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,25 @@ end
107107
@test_throws "\"" throw("\"")
108108
@test_throws Returns(false) throw(Returns(false))
109109
end
110+
111+
@testset "Pass - exception with pattern (3-arg form)" begin
112+
# Test 3-argument form: @test_throws ExceptionType pattern expr
113+
@test_throws ErrorException "error foo" error("error foo 1")
114+
@test_throws DomainError r"sqrt.*negative" sqrt(-1)
115+
@test_throws BoundsError "at index [2]" [1][2]
116+
@test_throws ErrorException ["error", "foo"] error("error foo bar")
117+
118+
# Test with function pattern
119+
@test_throws ErrorException (s -> occursin("foo", s)) error("error foo bar")
120+
121+
# Test output format
122+
let result = @test_throws ErrorException "error foo" error("error foo 1")
123+
output = sprint(show, result)
124+
@test occursin("Test Passed", output)
125+
@test occursin("Thrown: ErrorException", output)
126+
end
127+
end
128+
110129
# Test printing of Fail results
111130
include("nothrow_testset.jl")
112131

@@ -402,6 +421,47 @@ let retval_tests = @testset NoThrowTestSet begin
402421
end
403422
end
404423

424+
@testset "Fail - exception with pattern (3-arg form)" begin
425+
# Test type mismatch
426+
let fails = @testset NoThrowTestSet begin
427+
@test_throws ArgumentError "error foo" error("error foo 1") # Wrong type
428+
end
429+
@test length(fails) == 1
430+
@test fails[1] isa Test.Fail
431+
@test fails[1].test_type === :test_throws_wrong
432+
@test occursin("ArgumentError with pattern \"error foo\"", fails[1].data)
433+
end
434+
435+
# Test pattern mismatch
436+
let fails = @testset NoThrowTestSet begin
437+
@test_throws ErrorException "wrong pattern" error("error foo 1") # Wrong pattern
438+
end
439+
@test length(fails) == 1
440+
@test fails[1] isa Test.Fail
441+
@test fails[1].test_type === :test_throws_wrong
442+
@test occursin("ErrorException with pattern \"wrong pattern\"", fails[1].data)
443+
end
444+
445+
# Test no exception thrown
446+
let fails = @testset NoThrowTestSet begin
447+
@test_throws ErrorException "error foo" 1 + 1 # No exception
448+
end
449+
@test length(fails) == 1
450+
@test fails[1] isa Test.Fail
451+
@test fails[1].test_type === :test_throws_nothing
452+
@test occursin("ErrorException with pattern \"error foo\"", fails[1].data)
453+
end
454+
455+
# Test first argument must be a type
456+
let fails = @testset NoThrowTestSet begin
457+
@test_throws "not a type" "error foo" error("error foo 1") # First arg not a type
458+
end
459+
@test length(fails) == 1
460+
@test fails[1] isa Test.Fail
461+
@test fails[1].test_type === :test_throws_wrong
462+
end
463+
end
464+
405465
@testset "printing of a TestSetException" begin
406466
tse_str = sprint(show, Test.TestSetException(1, 2, 3, 4, Vector{Union{Test.Error, Test.Fail}}()))
407467
@test occursin("1 passed", tse_str)

0 commit comments

Comments
 (0)