Allow overriding snapshot formatting (#9)

This commit is contained in:
Patrick Stevens
2025-06-16 19:35:12 +01:00
committed by GitHub
parent 9c1960722a
commit c02acabb8c
6 changed files with 348 additions and 46 deletions

View File

@@ -46,6 +46,56 @@ let ``This test fails: plain text comparison of ToString`` () =
}
```
You can adjust the formatting:
```fsharp
[<Test>]
let ``Overriding the formatting`` () =
expect {
// doesn't matter which order these two lines are in
withFormat (fun x -> x.GetType().Name)
snapshot @"Int32"
return 123
}
```
You can override the JSON serialisation if you find the snapshot format displeasing:
```fsharp
[<Test>]
let ``Override JSON serialisation`` () =
expect {
snapshotJson "<excerpted>"
withJsonSerializerOptions (
let options = JsonFSharpOptions.ThothLike().ToJsonSerializerOptions ()
options.WriteIndented <- true
options
)
return myComplexAlgebraicDataType
}
```
You can adjust the JSON snapshot parsing if you like, e.g. if you want to add comments to your snapshot text:
```fsharp
[<Test>]
let ``Overriding JSON parse`` () =
expect {
// Without a custom JsonDocumentOptions, WoofWare.Expect would fail to parse this as JSON
// and would unconditionally declare that the snapshot did not match:
snapshotJson @"{
// a key here
""a"":3
}"
// But you can override the JsonDocumentOptions to state that comments are fine:
withJsonDocOptions (JsonDocumentOptions (CommentHandling = JsonCommentHandling.Skip))
return Map.ofList [ "a", 3 ]
}
```
## Updating an individual snapshot
If a snapshot is failing, add a `'` to the `expect` builder and rerun.

View File

@@ -1,5 +1,8 @@
namespace WoofWare.Expect.Test
open System.Collections.Generic
open System.Text.Json
open System.Text.Json.Serialization
open WoofWare.Expect
open NUnit.Framework
@@ -41,3 +44,133 @@ actual was:
snapshot @"123"
return 123
}
[<Test>]
let ``Formatting example`` () =
expect {
withFormat (fun x -> x.GetType().Name)
snapshot @"Int32"
return 123
}
expect {
snapshot @"Int32"
withFormat (fun x -> x.GetType().Name)
return 123
}
[<Test>]
let ``Custom JSON output`` () =
// Out of the box, comments in snapshots cause the JSON parser to throw, so the snapshot fails to match...
expect {
snapshot
@"snapshot mismatch! snapshot at file.fs:99 (Custom JSON output) was:
- [JSON failed to parse:] {
- // a key here
- ""a"":3
- }
actual was:
+ {
+ ""a"": 3
+ }"
return
Assert.Throws<ExpectException> (fun () ->
expectWithMockedFilePath ("file.fs", 99) {
snapshotJson
@"{
// a key here
""a"":3
}"
return Map.ofList [ "a", 3 ]
}
)
|> _.Message
}
// but it can be made to like them!
expect {
snapshotJson
@"{
// a key here
""a"":3
}"
withJsonDocOptions (JsonDocumentOptions (CommentHandling = JsonCommentHandling.Skip))
return Map.ofList [ "a", 3 ]
}
type SomeDu =
| Something of IReadOnlyDictionary<string, string>
| SomethingElse of string
type MoreComplexType =
{
Thing : int
SomeDu : SomeDu option
}
[<Test>]
let ``JSON snapshot of complex ADT`` () =
expect {
snapshotJson
@"{
""SomeDu"": {
""Case"": ""Something"",
""Fields"": [
{
""hi"": ""bye""
}
]
},
""Thing"": 3,
}"
return
{
Thing = 3
SomeDu = Some (SomeDu.Something (Map.ofList [ "hi", "bye" ]))
}
}
[<Test>]
let ``Overriding JSON format, from docstring`` () =
expect {
snapshotJson @"{""a"":3}"
withJsonSerializerOptions (JsonSerializerOptions (WriteIndented = false))
return Map.ofList [ "a", 3 ]
}
[<Test>]
let ``Overriding the JSON format`` () =
expect {
snapshotJson
@"{
""Thing"": 3,
""SomeDu"": [
""Some"",
[
""Something"",
{
""hi"": ""bye""
}
]
]
}"
withJsonSerializerOptions (
let options = JsonFSharpOptions.ThothLike().ToJsonSerializerOptions ()
options.WriteIndented <- true
options
)
return
{
Thing = 3
SomeDu = Some (SomeDu.Something (Map.ofList [ "hi", "bye" ]))
}
}

View File

@@ -2,14 +2,11 @@
open System.IO
open System.Runtime.CompilerServices
open System.Text.Json
/// An exception indicating that a value failed to match its snapshot.
exception ExpectException of Message : string
/// A dummy type which is here to provide better error messages when you supply
/// the `snapshot` keyword multiple times.
type YouHaveSuppliedMultipleSnapshots = private | NonConstructible
/// <summary>Specify how the Expect computation expression treats failures.</summary>
/// <remarks>You probably don't want to use this directly; use the computation expression definitions
/// like <c>expect</c> in the <c>Builder</c> module instead.</remarks>
@@ -38,10 +35,7 @@ type ExpectBuilder (mode : Mode) =
ExpectBuilder Mode.Assert
/// Combine two `ExpectState`s. The first one is the "expected" snapshot; the second is the "actual".
member _.Bind
(state : ExpectState<YouHaveSuppliedMultipleSnapshots>, f : unit -> ExpectState<'U>)
: ExpectState<'U>
=
member _.Bind<'U> (state : ExpectState<'U>, f : unit -> ExpectState<'U>) : ExpectState<'U> =
let actual = f ()
match state.Actual with
@@ -52,23 +46,44 @@ type ExpectBuilder (mode : Mode) =
| Some _ -> failwith "somehow Actual came through with a Snapshot"
| None ->
let formatter =
match state.Formatter, actual.Formatter with
| None, f -> f
| Some f, None -> Some f
| Some _, Some _ -> failwith "multiple formatters supplied for a single expect!"
let jsonSerOptions =
match state.JsonSerialiserOptions, actual.JsonSerialiserOptions with
| None, f -> f
| Some f, None -> Some f
| Some _, Some _ -> failwith "multiple JSON serialiser options supplied for a single expect!"
let jsonDocOptions =
match state.JsonDocOptions, actual.JsonDocOptions with
| None, f -> f
| Some f, None -> Some f
| Some _, Some _ -> failwith "multiple JSON document options supplied for a single expect!"
// Pass through the state structure when there's no actual value
{
Formatter = formatter
Snapshot = state.Snapshot
Actual = actual.Actual
JsonSerialiserOptions = jsonSerOptions
JsonDocOptions = jsonDocOptions
}
/// <summary>Express that the actual value's <c>ToString</c> should identically equal this string.</summary>
[<CustomOperation("snapshot", MaintainsVariableSpaceUsingBind = true)>]
member _.Snapshot
member _.Snapshot<'a>
(
state : ExpectState<unit>,
state : ExpectState<'a>,
snapshot : string,
[<CallerMemberName>] ?memberName : string,
[<CallerLineNumber>] ?callerLine : int,
[<CallerFilePath>] ?filePath : string
)
: ExpectState<YouHaveSuppliedMultipleSnapshots>
: ExpectState<'a>
=
match state.Snapshot with
| Some _ -> failwith "snapshot can only be specified once"
@@ -84,8 +99,8 @@ type ExpectBuilder (mode : Mode) =
LineNumber = lineNumber
}
{
Snapshot = Some (SnapshotValue.BareString snapshot, callerInfo)
{ state with
Snapshot = Some (SnapshotValue.Formatted snapshot, callerInfo)
Actual = None
}
@@ -94,10 +109,10 @@ type ExpectBuilder (mode : Mode) =
/// which matches the JSON document that is this string.
/// </summary>
/// <remarks>
/// For example, <c>snapshot "123"</c> indicates the JSON integer 123.
/// For example, <c>snapshotJson "123"</c> indicates the JSON integer 123.
/// </remarks>
[<CustomOperation("snapshotJson", MaintainsVariableSpaceUsingBind = true)>]
member _.SnapshotJson
member _.SnapshotJson<'a>
(
state : ExpectState<unit>,
snapshot : string,
@@ -105,7 +120,7 @@ type ExpectBuilder (mode : Mode) =
[<CallerLineNumber>] ?callerLine : int,
[<CallerFilePath>] ?filePath : string
)
: ExpectState<YouHaveSuppliedMultipleSnapshots>
: ExpectState<'a>
=
match state.Snapshot with
| Some _ -> failwith "snapshot can only be specified once"
@@ -122,13 +137,89 @@ type ExpectBuilder (mode : Mode) =
}
{
Formatter = None
JsonSerialiserOptions = state.JsonSerialiserOptions
JsonDocOptions = state.JsonDocOptions
Snapshot = Some (SnapshotValue.Json snapshot, callerInfo)
Actual = None
}
/// <summary>
/// Express that the <c>return</c> value of this builder should be formatted using this function, before
/// comparing to the snapshot.
/// this value.
/// </summary>
/// <remarks>
/// For example, <c>withFormat (fun x -> x.ToString ()) "123"</c> is equivalent to <c>snapshot "123"</c>.
/// </remarks>
[<CustomOperation("withFormat", MaintainsVariableSpaceUsingBind = true)>]
member _.WithFormat<'T> (state : ExpectState<'T>, formatter : 'T -> string) =
match state.Formatter with
| Some _ -> failwith "Please don't supply withFormat more than once"
| None ->
{ state with
Formatter = Some formatter
}
/// <summary>
/// Express that these JsonSerializerOptions should be used to construct the JSON object to which the snapshot
/// is to be compared (or, in write-out-the-snapshot mode, to construct the JSON object to be written out).
/// </summary>
/// <example>
/// If you want your snapshots to be written out compactly, rather than the default indenting:
/// <code>
/// expect {
/// snapshotJson @"{""a"":3}"
/// withJsonSerializerOptions (JsonSerializerOptions (WriteIndented = false))
/// return Map.ofList ["a", 3]
/// }
/// </code>
/// </example>
[<CustomOperation("withJsonSerializerOptions", MaintainsVariableSpaceUsingBind = true)>]
member _.WithJsonSerializerOptions<'T> (state : ExpectState<'T>, jsonOptions : JsonSerializerOptions) =
match state.JsonSerialiserOptions with
| Some _ -> failwith "Please don't supply withJsonSerializerOptions more than once"
| None ->
{ state with
JsonSerialiserOptions = Some jsonOptions
}
/// <summary>
/// Express that these JsonDocumentOptions should be used when parsing the snapshot string into a JSON object.
/// </summary>
/// <remarks>
/// For example, you might use this if you want your snapshot to contain comments;
/// the default JSON document parser will instead throw on comments, causing the snapshot instantly to fail to match.
/// </remarks>
/// <example>
/// <code>
/// expect {
/// snapshotJson
/// @"{
/// // a key here
/// ""a"":3
/// }"
///
/// withJsonDocOptions (JsonDocumentOptions (CommentHandling = JsonCommentHandling.Skip))
/// return Map.ofList [ "a", 3 ]
/// }
/// </code>
/// </example>
[<CustomOperation("withJsonDocOptions", MaintainsVariableSpaceUsingBind = true)>]
member _.WithJsonDocOptions<'T> (state : ExpectState<'T>, jsonOptions : JsonDocumentOptions) =
match state.JsonDocOptions with
| Some _ -> failwith "Please don't supply withJsonDocOptions more than once"
| None ->
{ state with
JsonDocOptions = Some jsonOptions
}
/// MaintainsVariableSpaceUsingBind causes this to be used; it's a dummy representing "no snapshot and no assertion".
member _.Return (() : unit) : ExpectState<'T> =
{
Formatter = None
JsonSerialiserOptions = None
JsonDocOptions = None
Snapshot = None
Actual = None
}
@@ -137,6 +228,9 @@ type ExpectBuilder (mode : Mode) =
member _.Return (value : 'T) : ExpectState<'T> =
{
Snapshot = None
Formatter = None
JsonDocOptions = None
JsonSerialiserOptions = None
Actual = Some value
}

View File

@@ -15,13 +15,20 @@ type CallerInfo =
}
type private SnapshotValue =
| BareString of string
| Json of string
| Json of expected : string
| Formatted of expected : string
type private CompletedSnapshotValue<'T> =
| Json of expected : string * JsonSerializerOptions * JsonDocumentOptions
| Formatted of expected : string * format : ('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
JsonSerialiserOptions : JsonSerializerOptions option
JsonDocOptions : JsonDocumentOptions option
Snapshot : (SnapshotValue * CallerInfo) option
Actual : 'T option
}
@@ -30,16 +37,42 @@ type ExpectState<'T> =
type internal CompletedSnapshotGeneric<'T> =
private
{
SnapshotValue : SnapshotValue
SnapshotValue : CompletedSnapshotValue<'T>
Caller : CallerInfo
Actual : 'T
}
[<RequireQualifiedAccess>]
module internal CompletedSnapshotGeneric =
let private defaultJsonSerialiserOptions : JsonSerializerOptions =
let options = JsonFSharpOptions.Default().ToJsonSerializerOptions ()
options.AllowTrailingCommas <- true
options.WriteIndented <- true
options
let private defaultJsonDocOptions : JsonDocumentOptions =
let options = JsonDocumentOptions (AllowTrailingCommas = true)
options
let make (state : ExpectState<'T>) : CompletedSnapshotGeneric<'T> =
match state.Snapshot, state.Actual with
| Some (snapshot, source), Some actual ->
let snapshot =
match snapshot with
| SnapshotValue.Json expected ->
let serOpts =
state.JsonSerialiserOptions |> Option.defaultValue defaultJsonSerialiserOptions
let docOpts = state.JsonDocOptions |> Option.defaultValue defaultJsonDocOptions
CompletedSnapshotValue.Json (expected, serOpts, docOpts)
| SnapshotValue.Formatted expected ->
let formatter =
match state.Formatter with
| None -> fun x -> x.ToString ()
| Some f -> f
CompletedSnapshotValue.Formatted (expected, formatter)
{
SnapshotValue = snapshot
Caller = source
@@ -48,47 +81,39 @@ module internal CompletedSnapshotGeneric =
| None, _ -> failwith "Must specify snapshot"
| _, None -> failwith "Must specify actual value with 'return'"
let private jsonOptions =
let options = JsonFSharpOptions.Default().ToJsonSerializerOptions ()
options.AllowTrailingCommas <- true
options.WriteIndented <- true
options
let internal replacement (s : CompletedSnapshotGeneric<'T>) =
match s.SnapshotValue with
| SnapshotValue.BareString _existing -> s.Actual.ToString ()
| SnapshotValue.Json _existing ->
JsonSerializer.Serialize (s.Actual, jsonOptions)
| CompletedSnapshotValue.Json (_existing, options, _) ->
JsonSerializer.Serialize (s.Actual, options)
|> JsonDocument.Parse
|> _.RootElement
|> _.ToString()
| CompletedSnapshotValue.Formatted (_existing, f) -> f s.Actual
/// Returns None if the assertion passes, or Some (expected, actual) if the assertion fails.
let internal passesAssertion (state : CompletedSnapshotGeneric<'T>) : (string * string) option =
match state.SnapshotValue with
| SnapshotValue.Json snapshot ->
| CompletedSnapshotValue.Formatted (snapshot, f) ->
let actual = f state.Actual
if actual = snapshot then None else Some (snapshot, actual)
| CompletedSnapshotValue.Json (snapshot, jsonSerOptions, jsonDocOptions) ->
let canonicalSnapshot =
try
JsonDocument.Parse snapshot |> Some
JsonDocument.Parse (snapshot, jsonDocOptions) |> Some
with _ ->
None
let canonicalActual =
JsonSerializer.Serialize (state.Actual, jsonOptions) |> JsonDocument.Parse
JsonSerializer.Serialize (state.Actual, jsonSerOptions) |> JsonDocument.Parse
match canonicalSnapshot with
| None -> Some (snapshot, canonicalActual.RootElement.ToString ())
| None -> Some ("[JSON failed to parse:] " + snapshot, canonicalActual.RootElement.ToString ())
| Some canonicalSnapshot ->
if not (JsonElement.DeepEquals (canonicalActual.RootElement, canonicalSnapshot.RootElement)) then
Some (canonicalSnapshot.RootElement.ToString (), canonicalActual.RootElement.ToString ())
else
None
| SnapshotValue.BareString snapshot ->
let actual = state.Actual.ToString ()
if actual = snapshot then None else Some (snapshot, actual)
/// Represents a snapshot test that has failed and is awaiting update or report to the user.
type CompletedSnapshot =
internal

View File

@@ -12,26 +12,26 @@ WoofWare.Expect.ExpectBuilder inherit obj
WoofWare.Expect.ExpectBuilder..ctor [constructor]: (string * int)
WoofWare.Expect.ExpectBuilder..ctor [constructor]: bool
WoofWare.Expect.ExpectBuilder..ctor [constructor]: WoofWare.Expect.Mode
WoofWare.Expect.ExpectBuilder.Bind [method]: (WoofWare.Expect.YouHaveSuppliedMultipleSnapshots WoofWare.Expect.ExpectState, unit -> 'U WoofWare.Expect.ExpectState) -> 'U WoofWare.Expect.ExpectState
WoofWare.Expect.ExpectBuilder.Bind [method]: ('U WoofWare.Expect.ExpectState, unit -> 'U WoofWare.Expect.ExpectState) -> 'U WoofWare.Expect.ExpectState
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.Run [method]: (unit -> 'T WoofWare.Expect.ExpectState) -> unit
WoofWare.Expect.ExpectBuilder.Snapshot [method]: (unit WoofWare.Expect.ExpectState, string, string option, int option, string option) -> WoofWare.Expect.YouHaveSuppliedMultipleSnapshots WoofWare.Expect.ExpectState
WoofWare.Expect.ExpectBuilder.SnapshotJson [method]: (unit WoofWare.Expect.ExpectState, string, string option, int option, string option) -> WoofWare.Expect.YouHaveSuppliedMultipleSnapshots WoofWare.Expect.ExpectState
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.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
WoofWare.Expect.ExpectException inherit System.Exception, implements System.Collections.IStructuralEquatable
WoofWare.Expect.ExpectException..ctor [constructor]: string
WoofWare.Expect.ExpectException..ctor [constructor]: unit
WoofWare.Expect.ExpectException.Equals [method]: (System.Exception, System.Collections.IEqualityComparer) -> bool
WoofWare.Expect.ExpectException.Equals [method]: System.Exception -> bool
WoofWare.Expect.ExpectException.Message [property]: [read-only] string
WoofWare.Expect.ExpectState`1 inherit obj, implements 'T WoofWare.Expect.ExpectState System.IEquatable, System.Collections.IStructuralEquatable, 'T WoofWare.Expect.ExpectState System.IComparable, System.IComparable, System.Collections.IStructuralComparable
WoofWare.Expect.ExpectState`1.Equals [method]: ('T WoofWare.Expect.ExpectState, System.Collections.IEqualityComparer) -> bool
WoofWare.Expect.ExpectState`1 inherit obj
WoofWare.Expect.GlobalBuilderConfig inherit obj
WoofWare.Expect.GlobalBuilderConfig.clearTests [static method]: unit -> unit
WoofWare.Expect.GlobalBuilderConfig.enterBulkUpdateMode [static method]: unit -> unit
WoofWare.Expect.GlobalBuilderConfig.updateAllSnapshots [static method]: unit -> unit
WoofWare.Expect.Mode inherit obj, implements WoofWare.Expect.Mode System.IEquatable, System.Collections.IStructuralEquatable, WoofWare.Expect.Mode System.IComparable, System.IComparable, System.Collections.IStructuralComparable
WoofWare.Expect.Mode.Equals [method]: (WoofWare.Expect.Mode, System.Collections.IEqualityComparer) -> bool
WoofWare.Expect.YouHaveSuppliedMultipleSnapshots inherit obj, implements WoofWare.Expect.YouHaveSuppliedMultipleSnapshots System.IEquatable, System.Collections.IStructuralEquatable, WoofWare.Expect.YouHaveSuppliedMultipleSnapshots System.IComparable, System.IComparable, System.Collections.IStructuralComparable
WoofWare.Expect.YouHaveSuppliedMultipleSnapshots.Equals [method]: (WoofWare.Expect.YouHaveSuppliedMultipleSnapshots, System.Collections.IEqualityComparer) -> bool

View File

@@ -1,5 +1,5 @@
{
"version": "0.3",
"version": "0.4",
"publicReleaseRefSpec": [
"^refs/heads/main$"
],