From ca74c4816bf7809712865e803686f6a90e887773 Mon Sep 17 00:00:00 2001 From: Patrick Stevens <3138005+Smaug123@users.noreply.github.com> Date: Sun, 6 Jul 2025 22:30:02 +0100 Subject: [PATCH] Add syntax for exceptions (#19) --- README.md | 7 +++ WoofWare.Expect.Test/TestExceptionThrowing.fs | 14 +++++ .../WoofWare.Expect.Test.fsproj | 1 + WoofWare.Expect/Builder.fs | 58 ++++++++++++++++++- WoofWare.Expect/Domain.fs | 26 ++++++--- WoofWare.Expect/SnapshotUpdate.fs | 3 +- WoofWare.Expect/SurfaceBaseline.txt | 2 + WoofWare.Expect/version.json | 4 +- 8 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 WoofWare.Expect.Test/TestExceptionThrowing.fs diff --git a/README.md b/README.md index 1f78c7f..5b891fc 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,13 @@ let ``This test fails: plain text comparison of ToString`` () = snapshot " 123 " return 123 } + +[] +let ``With return! and snapshotThrows, you can see exceptions too`` () = + expect { + snapshotThrows @"System.Exception: oh no" + return! (fun () -> failwith "oh no") + } ``` You can adjust the formatting: diff --git a/WoofWare.Expect.Test/TestExceptionThrowing.fs b/WoofWare.Expect.Test/TestExceptionThrowing.fs new file mode 100644 index 0000000..cbc962e --- /dev/null +++ b/WoofWare.Expect.Test/TestExceptionThrowing.fs @@ -0,0 +1,14 @@ +namespace WoofWare.Expect.Test + +open NUnit.Framework +open WoofWare.Expect + +[] +module TestExceptionThrowing = + + [] + let ``Can throw an exception`` () = + expect { + snapshotThrows @"System.Exception: oh no" + return! (fun () -> failwith "oh no") + } diff --git a/WoofWare.Expect.Test/WoofWare.Expect.Test.fsproj b/WoofWare.Expect.Test/WoofWare.Expect.Test.fsproj index 49d4155..07aa168 100644 --- a/WoofWare.Expect.Test/WoofWare.Expect.Test.fsproj +++ b/WoofWare.Expect.Test/WoofWare.Expect.Test.fsproj @@ -13,6 +13,7 @@ + diff --git a/WoofWare.Expect/Builder.fs b/WoofWare.Expect/Builder.fs index 875019c..399ec34 100644 --- a/WoofWare.Expect/Builder.fs +++ b/WoofWare.Expect/Builder.fs @@ -142,6 +142,51 @@ type ExpectBuilder (mode : Mode) = Actual = None } + /// + /// Expresses that the given expression throws during evaluation. + /// + /// + /// + /// expect { + /// snapshotThrows @"System.Exception: oh no" + /// return! (fun () -> failwith "oh no") + /// } + /// + /// + [] + member _.SnapshotThrows<'a> + ( + state : ExpectState<'a>, + snapshot : string, + [] ?memberName : string, + [] ?callerLine : int, + [] ?filePath : string + ) + : ExpectState<'a> + = + match state.Snapshot with + | Some _ -> failwith "snapshot can only be specified once" + | None -> + + let memberName = defaultArg memberName "" + let filePath = defaultArg filePath "" + let lineNumber = defaultArg callerLine -1 + + let callerInfo = + { + MemberName = memberName + FilePath = filePath + LineNumber = lineNumber + } + + { + Formatter = None + JsonSerialiserOptions = state.JsonSerialiserOptions + JsonDocOptions = state.JsonDocOptions + Snapshot = Some (SnapshotValue.ThrowsException snapshot, callerInfo) + Actual = None + } + /// /// Express that the return value of this builder should be formatted using this function, before /// comparing to the snapshot. @@ -156,7 +201,7 @@ type ExpectBuilder (mode : Mode) = | Some _ -> failwith "Please don't supply withFormat more than once" | None -> { state with - Formatter = Some formatter + Formatter = Some (fun f -> f () |> formatter) } /// @@ -224,6 +269,17 @@ type ExpectBuilder (mode : Mode) = /// Expresses the "actual value" component of the assertion "expected snapshot = actual value". member _.Return (value : 'T) : ExpectState<'T> = + { + Snapshot = None + Formatter = None + JsonDocOptions = None + JsonSerialiserOptions = None + Actual = Some (fun () -> value) + } + + /// Expresses the "actual value" component of the assertion "expected snapshot = actual value", but delayed behind + /// a function (by contrast with `Return`). + member _.ReturnFrom (value : unit -> 'T) : ExpectState<'T> = { Snapshot = None Formatter = None diff --git a/WoofWare.Expect/Domain.fs b/WoofWare.Expect/Domain.fs index 2b492cb..fcba3d2 100644 --- a/WoofWare.Expect/Domain.fs +++ b/WoofWare.Expect/Domain.fs @@ -17,20 +17,21 @@ type CallerInfo = type private SnapshotValue = | Json of expected : string | Formatted of expected : string + | ThrowsException of expected : string type private CompletedSnapshotValue<'T> = | Json of expected : string * JsonSerializerOptions * JsonDocumentOptions - | Formatted of expected : string * format : ('T -> string) + | Formatted of expected : string * format : ((unit -> 'T) -> string) /// The state accumulated by the `expect` builder. You should never find yourself interacting with this type. type ExpectState<'T> = private { - Formatter : ('T -> string) option + Formatter : ((unit -> 'T) -> string) option JsonSerialiserOptions : JsonSerializerOptions option JsonDocOptions : JsonDocumentOptions option Snapshot : (SnapshotValue * CallerInfo) option - Actual : 'T option + Actual : (unit -> 'T) option } /// The state accumulated by the `expect` builder. You should never find yourself interacting with this type. @@ -39,7 +40,7 @@ type internal CompletedSnapshotGeneric<'T> = { SnapshotValue : CompletedSnapshotValue<'T> Caller : CallerInfo - Actual : 'T + Actual : unit -> 'T } [] @@ -68,11 +69,22 @@ module internal CompletedSnapshotGeneric = | SnapshotValue.Formatted expected -> let formatter = match state.Formatter with - | None -> fun x -> x.ToString () + | None -> fun x -> x().ToString () | Some f -> f CompletedSnapshotValue.Formatted (expected, formatter) + | SnapshotValue.ThrowsException expected -> + CompletedSnapshotValue.Formatted ( + expected, + fun x -> + try + x () |> ignore + "" + with e -> + e.GetType().FullName + ": " + e.Message + ) + { SnapshotValue = snapshot Caller = source @@ -84,7 +96,7 @@ module internal CompletedSnapshotGeneric = let internal replacement (s : CompletedSnapshotGeneric<'T>) = match s.SnapshotValue with | CompletedSnapshotValue.Json (_existing, options, _) -> - JsonSerializer.Serialize (s.Actual, options) + JsonSerializer.Serialize (s.Actual (), options) |> JsonDocument.Parse |> _.RootElement |> _.ToString() @@ -104,7 +116,7 @@ module internal CompletedSnapshotGeneric = None let canonicalActual = - JsonSerializer.Serialize (state.Actual, jsonSerOptions) |> JsonDocument.Parse + JsonSerializer.Serialize (state.Actual (), jsonSerOptions) |> JsonDocument.Parse match canonicalSnapshot with | None -> Some ("[JSON failed to parse:] " + snapshot, canonicalActual.RootElement.ToString ()) diff --git a/WoofWare.Expect/SnapshotUpdate.fs b/WoofWare.Expect/SnapshotUpdate.fs index 62c912b..acc8706 100644 --- a/WoofWare.Expect/SnapshotUpdate.fs +++ b/WoofWare.Expect/SnapshotUpdate.fs @@ -147,7 +147,8 @@ module internal SnapshotUpdate = let searchText = String.concat "\n" relevantLines // Find snapshot keyword - let snapshotMatch = Regex.Match (searchText, @"\b(snapshot|snapshotJson)\b") + let snapshotMatch = + Regex.Match (searchText, @"\b(snapshot|snapshotJson|snapshotThrows)\b") if not snapshotMatch.Success then None diff --git a/WoofWare.Expect/SurfaceBaseline.txt b/WoofWare.Expect/SurfaceBaseline.txt index 6f3e05c..9b9142b 100644 --- a/WoofWare.Expect/SurfaceBaseline.txt +++ b/WoofWare.Expect/SurfaceBaseline.txt @@ -16,9 +16,11 @@ WoofWare.Expect.ExpectBuilder.Bind [method]: ('U WoofWare.Expect.ExpectState, un WoofWare.Expect.ExpectBuilder.Delay [method]: (unit -> 'T WoofWare.Expect.ExpectState) -> (unit -> 'T WoofWare.Expect.ExpectState) WoofWare.Expect.ExpectBuilder.Return [method]: 'T -> 'T WoofWare.Expect.ExpectState WoofWare.Expect.ExpectBuilder.Return [method]: unit -> 'T WoofWare.Expect.ExpectState +WoofWare.Expect.ExpectBuilder.ReturnFrom [method]: (unit -> 'T) -> 'T WoofWare.Expect.ExpectState WoofWare.Expect.ExpectBuilder.Run [method]: (unit -> 'T WoofWare.Expect.ExpectState) -> unit WoofWare.Expect.ExpectBuilder.Snapshot [method]: ('a WoofWare.Expect.ExpectState, string, string option, int option, string option) -> 'a WoofWare.Expect.ExpectState WoofWare.Expect.ExpectBuilder.SnapshotJson [method]: (unit WoofWare.Expect.ExpectState, string, string option, int option, string option) -> 'a WoofWare.Expect.ExpectState +WoofWare.Expect.ExpectBuilder.SnapshotThrows [method]: ('a WoofWare.Expect.ExpectState, string, string option, int option, string option) -> 'a WoofWare.Expect.ExpectState WoofWare.Expect.ExpectBuilder.WithFormat [method]: ('T WoofWare.Expect.ExpectState, 'T -> string) -> 'T WoofWare.Expect.ExpectState WoofWare.Expect.ExpectBuilder.WithJsonDocOptions [method]: ('T WoofWare.Expect.ExpectState, System.Text.Json.JsonDocumentOptions) -> 'T WoofWare.Expect.ExpectState WoofWare.Expect.ExpectBuilder.WithJsonSerializerOptions [method]: ('T WoofWare.Expect.ExpectState, System.Text.Json.JsonSerializerOptions) -> 'T WoofWare.Expect.ExpectState diff --git a/WoofWare.Expect/version.json b/WoofWare.Expect/version.json index 6006221..f68ef4f 100644 --- a/WoofWare.Expect/version.json +++ b/WoofWare.Expect/version.json @@ -1,5 +1,5 @@ { - "version": "0.4", + "version": "0.5", "publicReleaseRefSpec": [ "^refs/heads/main$" ], @@ -10,4 +10,4 @@ ":/Directory.Build.props", ":/LICENSE" ] -} +} \ No newline at end of file