mirror of
https://github.com/Smaug123/WoofWare.Expect
synced 2025-10-06 13:08:39 +00:00
Compare commits
2 Commits
WoofWare.E
...
WoofWare.E
Author | SHA1 | Date | |
---|---|---|---|
|
c02acabb8c | ||
|
9c1960722a |
97
README.md
97
README.md
@@ -46,6 +46,58 @@ 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.
|
||||
The rerun will throw, but it will update the snapshot; then remove the `'` again to put the test back into "assert snapshot" mode.
|
||||
|
||||
@@ -78,6 +130,51 @@ let ``Example of automatically updating`` () =
|
||||
}
|
||||
```
|
||||
|
||||
## Bulk update of snapshots
|
||||
|
||||
*Warning*: when doing this, you should probably make sure your test fixture is `[<Parallelizable(ParallelScope.Children)>]` or less parallelizable,
|
||||
or the equivalent in your test runner of choice.
|
||||
Otherwise, the global state used by this mechanism may interfere with other fixtures.
|
||||
|
||||
You can put WoofWare.Expect into "bulk update" mode as follows:
|
||||
|
||||
```fsharp
|
||||
open NUnit.Framework
|
||||
open WoofWare.Expect
|
||||
|
||||
[<TestFixture>]
|
||||
[<NonParallelizable>]
|
||||
module BulkUpdateExample =
|
||||
|
||||
[<OneTimeSetUp>]
|
||||
let ``Prepare to bulk-update tests`` () =
|
||||
// If you don't want to enter bulk-update mode, just replace this line with a no-op `()`.
|
||||
// The `updateAllSnapshots` tear-down below will simply do nothing in that case.
|
||||
GlobalBuilderConfig.enterBulkUpdateMode ()
|
||||
|
||||
[<OneTimeTearDown>]
|
||||
let ``Update all tests`` () =
|
||||
GlobalBuilderConfig.updateAllSnapshots ()
|
||||
|
||||
[<Test>]
|
||||
let ``Snapshot 2`` () =
|
||||
// this snapshot fails: the "expected" isn't even JSON!
|
||||
expect {
|
||||
snapshotJson ""
|
||||
|
||||
return Map.ofList [ "1", "hi" ; "2", "my" ; "3", "name" ; "4", "is" ]
|
||||
}
|
||||
|
||||
[<Test>]
|
||||
let ``Snapshot 1`` () =
|
||||
// this snapshot fails: the "expected" is not equal to the "actual"
|
||||
expect {
|
||||
snapshotJson @"124"
|
||||
return 123
|
||||
}
|
||||
```
|
||||
|
||||
Observe the `OneTimeSetUp` which sets global state to enter "bulk update" mode, and the `OneTimeTearDown` which performs all the updates to rectify failures which were accumulated during this test run.
|
||||
|
||||
# Limitations
|
||||
|
||||
|
48
WoofWare.Expect.Test/BulkUpdateExample.fs
Normal file
48
WoofWare.Expect.Test/BulkUpdateExample.fs
Normal file
@@ -0,0 +1,48 @@
|
||||
namespace WoofWare.Expect.Test
|
||||
|
||||
open WoofWare.Expect
|
||||
open NUnit.Framework
|
||||
|
||||
[<TestFixture>]
|
||||
[<Parallelizable(ParallelScope.Children)>]
|
||||
module BulkUpdateExample =
|
||||
|
||||
[<OneTimeSetUp>]
|
||||
let ``Prepare to bulk-update tests`` () =
|
||||
// Uncomment the `enterBulkUpdateMode` to cause all failing tests to accumulate their results
|
||||
// into a global mutable collection.
|
||||
// At the end of the test run, you should then call `updateAllSnapshots ()`
|
||||
// to commit these accumulated failures to the source files.
|
||||
//
|
||||
// When in bulk update mode, all tests will fail, to remind you to exit bulk update mode afterwards.
|
||||
//
|
||||
// We *strongly* recommend making these test fixtures `[<Parallelizable(ParallelScope.Children)>]`
|
||||
// or less parallelisable.
|
||||
|
||||
// GlobalBuilderConfig.enterBulkUpdateMode ()
|
||||
()
|
||||
|
||||
[<OneTimeTearDown>]
|
||||
let ``Update all tests`` () =
|
||||
GlobalBuilderConfig.updateAllSnapshots ()
|
||||
|
||||
[<Test>]
|
||||
let ``Snapshot 2`` () =
|
||||
expect {
|
||||
snapshotJson
|
||||
@"{
|
||||
""1"": ""hi"",
|
||||
""2"": ""my"",
|
||||
""3"": ""name"",
|
||||
""4"": ""is""
|
||||
}"
|
||||
|
||||
return Map.ofList [ "1", "hi" ; "2", "my" ; "3", "name" ; "4", "is" ]
|
||||
}
|
||||
|
||||
[<Test>]
|
||||
let ``Snapshot 1`` () =
|
||||
expect {
|
||||
snapshotJson @"123"
|
||||
return 123
|
||||
}
|
@@ -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" ]))
|
||||
}
|
||||
}
|
||||
|
@@ -8,6 +8,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Assembly.fs" />
|
||||
<Compile Include="BulkUpdateExample.fs" />
|
||||
<Compile Include="SimpleTest.fs" />
|
||||
<Compile Include="TestSnapshotFinding.fs" />
|
||||
<Compile Include="TestSurface.fs" />
|
||||
|
@@ -3,39 +3,10 @@
|
||||
open System.IO
|
||||
open System.Runtime.CompilerServices
|
||||
open System.Text.Json
|
||||
open System.Text.Json.Serialization
|
||||
|
||||
type private CallerInfo =
|
||||
{
|
||||
MemberName : string
|
||||
FilePath : string
|
||||
LineNumber : int
|
||||
}
|
||||
|
||||
type private SnapshotValue =
|
||||
| BareString of string
|
||||
| Json of string
|
||||
|
||||
/// 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
|
||||
|
||||
/// The state accumulated by the `expect` builder. You should never find yourself interacting with this type.
|
||||
type ExpectState<'T> =
|
||||
private
|
||||
{
|
||||
Snapshot : (SnapshotValue * CallerInfo) option
|
||||
Actual : 'T option
|
||||
}
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module private Text =
|
||||
let predent (c : char) (s : string) =
|
||||
s.Split '\n' |> Seq.map (sprintf "%c %s" c) |> String.concat "\n"
|
||||
|
||||
/// <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>
|
||||
@@ -64,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
|
||||
@@ -78,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"
|
||||
@@ -110,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
|
||||
}
|
||||
|
||||
@@ -120,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,
|
||||
@@ -131,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"
|
||||
@@ -148,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
|
||||
}
|
||||
@@ -163,6 +228,9 @@ type ExpectBuilder (mode : Mode) =
|
||||
member _.Return (value : 'T) : ExpectState<'T> =
|
||||
{
|
||||
Snapshot = None
|
||||
Formatter = None
|
||||
JsonDocOptions = None
|
||||
JsonSerialiserOptions = None
|
||||
Actual = Some value
|
||||
}
|
||||
|
||||
@@ -171,71 +239,49 @@ type ExpectBuilder (mode : Mode) =
|
||||
|
||||
/// Computation expression `Run`, which runs a `Delay`ed snapshot assertion, throwing if the assertion fails.
|
||||
member _.Run (f : unit -> ExpectState<'T>) : unit =
|
||||
let state = f ()
|
||||
let state = f () |> CompletedSnapshotGeneric.make
|
||||
|
||||
let options = JsonFSharpOptions.Default().ToJsonSerializerOptions ()
|
||||
|
||||
match state.Snapshot, state.Actual with
|
||||
| Some (snapshot, source), Some actual ->
|
||||
let raiseError (snapshot : string) (actual : string) : unit =
|
||||
match mode with
|
||||
| Mode.AssertMockingSource (mockSource, line) ->
|
||||
let raiseError (snapshot : string) (actual : string) : unit =
|
||||
match mode with
|
||||
| Mode.AssertMockingSource (mockSource, line) ->
|
||||
sprintf
|
||||
"snapshot mismatch! snapshot at %s:%i (%s) was:\n\n%s\n\nactual was:\n\n%s"
|
||||
mockSource
|
||||
line
|
||||
state.Caller.MemberName
|
||||
(snapshot |> Text.predent '-')
|
||||
(actual |> Text.predent '+')
|
||||
|> ExpectException
|
||||
|> raise
|
||||
| Mode.Assert ->
|
||||
if GlobalBuilderConfig.bulkUpdate.Value > 0 then
|
||||
GlobalBuilderConfig.registerTest state
|
||||
else
|
||||
sprintf
|
||||
"snapshot mismatch! snapshot at %s:%i (%s) was:\n\n%s\n\nactual was:\n\n%s"
|
||||
mockSource
|
||||
line
|
||||
source.MemberName
|
||||
state.Caller.FilePath
|
||||
state.Caller.LineNumber
|
||||
state.Caller.MemberName
|
||||
(snapshot |> Text.predent '-')
|
||||
(actual |> Text.predent '+')
|
||||
|> ExpectException
|
||||
|> raise
|
||||
| Mode.Assert ->
|
||||
sprintf
|
||||
"snapshot mismatch! snapshot at %s:%i (%s) was:\n\n%s\n\nactual was:\n\n%s"
|
||||
source.FilePath
|
||||
source.LineNumber
|
||||
source.MemberName
|
||||
(snapshot |> Text.predent '-')
|
||||
(actual |> Text.predent '+')
|
||||
|> ExpectException
|
||||
|> raise
|
||||
| Mode.Update ->
|
||||
let lines = File.ReadAllLines source.FilePath
|
||||
let oldContents = String.concat "\n" lines
|
||||
let lines = SnapshotUpdate.updateSnapshotAtLine lines source.LineNumber actual
|
||||
File.WriteAllLines (source.FilePath, lines)
|
||||
failwith ("Snapshot successfully updated. Previous contents:\n" + oldContents)
|
||||
| Mode.Update ->
|
||||
let lines = File.ReadAllLines state.Caller.FilePath
|
||||
let oldContents = String.concat "\n" lines
|
||||
let lines = SnapshotUpdate.updateSnapshotAtLine lines state.Caller.LineNumber actual
|
||||
File.WriteAllLines (state.Caller.FilePath, lines)
|
||||
failwith ("Snapshot successfully updated. Previous contents:\n" + oldContents)
|
||||
|
||||
match snapshot with
|
||||
| SnapshotValue.Json snapshot ->
|
||||
let canonicalSnapshot = JsonDocument.Parse snapshot
|
||||
|
||||
let canonicalActual =
|
||||
JsonSerializer.Serialize (actual, options) |> JsonDocument.Parse
|
||||
|
||||
if not (JsonElement.DeepEquals (canonicalActual.RootElement, canonicalSnapshot.RootElement)) then
|
||||
raiseError (canonicalSnapshot.RootElement.ToString ()) (canonicalActual.RootElement.ToString ())
|
||||
else
|
||||
match mode with
|
||||
| Mode.Update ->
|
||||
failwith
|
||||
"Snapshot assertion passed, but we are in snapshot-updating mode. Use the `expect` builder instead of `expect'` to assert the contents of a snapshot."
|
||||
| _ -> ()
|
||||
|
||||
| SnapshotValue.BareString snapshot ->
|
||||
let actual = actual.ToString ()
|
||||
|
||||
if actual <> snapshot then
|
||||
raiseError snapshot actual
|
||||
else
|
||||
match mode with
|
||||
| Mode.Update ->
|
||||
failwith
|
||||
"Snapshot assertion passed, but we are in snapshot-updating mode. Use the `expect` builder instead of `expect'` to assert the contents of a snapshot."
|
||||
| _ -> ()
|
||||
|
||||
| None, _ -> failwith "Must specify snapshot"
|
||||
| _, None -> failwith "Must specify actual value with 'return'"
|
||||
match CompletedSnapshotGeneric.passesAssertion state with
|
||||
| None ->
|
||||
match mode, GlobalBuilderConfig.bulkUpdate.Value with
|
||||
| Mode.Update, _
|
||||
| _, 1 ->
|
||||
failwith
|
||||
"Snapshot assertion passed, but we are in snapshot-updating mode. Use the `expect` builder instead of `expect'` to assert the contents of a single snapshot; disable `GlobalBuilderConfig.bulkUpdate` to move back to assertion-checking mode."
|
||||
| _ -> ()
|
||||
| Some (expected, actual) -> raiseError expected actual
|
||||
|
||||
/// Module containing the `expect` builder.
|
||||
[<AutoOpen>]
|
||||
|
52
WoofWare.Expect/Config.fs
Normal file
52
WoofWare.Expect/Config.fs
Normal file
@@ -0,0 +1,52 @@
|
||||
namespace WoofWare.Expect
|
||||
|
||||
open System.Threading
|
||||
|
||||
/// Module holding global mutable state controlling the behaviour of WoofWare.Expect
|
||||
/// when running in bulk-update mode.
|
||||
[<RequireQualifiedAccess>]
|
||||
module GlobalBuilderConfig =
|
||||
let internal bulkUpdate = ref 0
|
||||
|
||||
/// <summary>
|
||||
/// Call this to make the <c>expect</c> builder register all tests for bulk update as it runs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// We *strongly* recommend making test fixtures <c>Parallelizable(ParallelScope.Children)</c> or less parallelizable (for NUnit) if you're running in bulk update mode.
|
||||
/// The implied global mutable state is liable to interfere with other expect builders in other fixtures otherwise.
|
||||
/// </remarks>
|
||||
let enterBulkUpdateMode () =
|
||||
if Interlocked.Increment bulkUpdate <> 1 then
|
||||
failwith
|
||||
"WoofWare.Expect requires bulk updates to happen serially: for example, make the test fixture `[<NonParallelizable>]` if you're using NUnit."
|
||||
|
||||
let private allTests : ResizeArray<CompletedSnapshot> = ResizeArray ()
|
||||
|
||||
/// <summary>
|
||||
/// Clear the set of failing tests registered by any previous bulk-update runs.
|
||||
/// </summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// You probably don't need to do this, because your test runner is probably tearing down
|
||||
/// anyway after the tests have failed; this is mainly here for WoofWare.Expect's own internal testing.
|
||||
/// </remarks>
|
||||
let clearTests () = lock allTests allTests.Clear
|
||||
|
||||
let internal registerTest (s : CompletedSnapshotGeneric<'T>) : unit =
|
||||
let toAdd = s |> CompletedSnapshot.make
|
||||
lock allTests (fun () -> allTests.Add toAdd)
|
||||
|
||||
/// <summary>
|
||||
/// For all tests whose failures have already been registered,
|
||||
/// transform the files on disk so that the failing snapshots now pass.
|
||||
/// </summary>
|
||||
let updateAllSnapshots () =
|
||||
let bulkUpdate' = Interlocked.Decrement bulkUpdate
|
||||
|
||||
try
|
||||
if bulkUpdate' = 0 then
|
||||
let allTests = lock allTests (fun () -> Seq.toArray allTests)
|
||||
SnapshotUpdate.updateAll allTests
|
||||
|
||||
finally
|
||||
clearTests ()
|
131
WoofWare.Expect/Domain.fs
Normal file
131
WoofWare.Expect/Domain.fs
Normal file
@@ -0,0 +1,131 @@
|
||||
namespace WoofWare.Expect
|
||||
|
||||
open System.Text.Json
|
||||
open System.Text.Json.Serialization
|
||||
|
||||
/// <summary>
|
||||
/// Information about where in source code a specific snapshot is located.
|
||||
/// </summary>
|
||||
type CallerInfo =
|
||||
internal
|
||||
{
|
||||
MemberName : string
|
||||
FilePath : string
|
||||
LineNumber : int
|
||||
}
|
||||
|
||||
type private SnapshotValue =
|
||||
| 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
|
||||
}
|
||||
|
||||
/// The state accumulated by the `expect` builder. You should never find yourself interacting with this type.
|
||||
type internal CompletedSnapshotGeneric<'T> =
|
||||
private
|
||||
{
|
||||
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
|
||||
Actual = actual
|
||||
}
|
||||
| None, _ -> failwith "Must specify snapshot"
|
||||
| _, None -> failwith "Must specify actual value with 'return'"
|
||||
|
||||
let internal replacement (s : CompletedSnapshotGeneric<'T>) =
|
||||
match s.SnapshotValue with
|
||||
| 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
|
||||
| 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, jsonDocOptions) |> Some
|
||||
with _ ->
|
||||
None
|
||||
|
||||
let canonicalActual =
|
||||
JsonSerializer.Serialize (state.Actual, jsonSerOptions) |> JsonDocument.Parse
|
||||
|
||||
match canonicalSnapshot with
|
||||
| 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
|
||||
|
||||
/// Represents a snapshot test that has failed and is awaiting update or report to the user.
|
||||
type CompletedSnapshot =
|
||||
internal
|
||||
{
|
||||
CallerInfo : CallerInfo
|
||||
Replacement : string
|
||||
}
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module internal CompletedSnapshot =
|
||||
let make (s : CompletedSnapshotGeneric<'T>) =
|
||||
{
|
||||
CallerInfo = s.Caller
|
||||
Replacement = CompletedSnapshotGeneric.replacement s
|
||||
}
|
@@ -254,15 +254,19 @@ module internal SnapshotUpdate =
|
||||
|
||||
/// <remarks>Example usage:
|
||||
/// <c>updateSnapshotAtLine [|lines-of-file|] 42 "new test output"</c>
|
||||
///
|
||||
/// <br />
|
||||
/// This will find a snapshot call on line 42 like:
|
||||
/// snapshot "old value" -> snapshot @"new test output"
|
||||
/// snapshot @"old value" -> snapshot @"new test output"
|
||||
/// snapshot """old value""" -> snapshot @"new test output"
|
||||
/// snapshot """multi
|
||||
/// line""" -> snapshot """multi
|
||||
/// line"""
|
||||
/// snapshot "has \"\"\" in it" -> snapshot @"has """""" in it"
|
||||
/// <ul>
|
||||
/// <li><c>snapshot "old value"</c> -> <c>snapshot @"new test output"</c></li>
|
||||
/// <li><c>snapshot @"old value"</c> -> <c>snapshot @"new test output"</c></li>
|
||||
/// <li><c>snapshot """old value"""</c> -> <c>snapshot @"new test output"</c></li>
|
||||
/// <li><c>snapshot "has \"\"\" in it"</c> -> <c>snapshot @"has """""" in it"</c></li>
|
||||
/// <li>
|
||||
/// <code>snapshot """multi
|
||||
/// line"""</code> -> <code>snapshot """multi
|
||||
/// line"""</code>
|
||||
/// </li>
|
||||
/// </ul>
|
||||
/// </remarks>
|
||||
let updateSnapshotAtLine (fileLines : string[]) (snapshotLine : int) (newValue : string) : string[] =
|
||||
match findSnapshotString fileLines snapshotLine with
|
||||
@@ -270,3 +274,31 @@ module internal SnapshotUpdate =
|
||||
Console.Error.WriteLine ("String literal to update: " + string<StringLiteralInfo> info)
|
||||
updateSnapshot fileLines info newValue
|
||||
| None -> failwithf "Could not find string literal after snapshot at line %d" snapshotLine
|
||||
|
||||
/// <summary>
|
||||
/// Bulk-apply all the snapshot replacements.
|
||||
/// </summary>
|
||||
/// <param name="fileLines">The original file contents, as an array of lines.</param>
|
||||
/// <param name="sources">The (unsorted) line numbers of the snapshots which need to be replaced, and the replacement value for each.</param>
|
||||
/// <returns>The entire desired new contents of the file, as an array of lines.</returns>
|
||||
let private updateAllLines (fileLines : string[]) (sources : (int * string) seq) : string[] =
|
||||
sources
|
||||
|> Seq.sortByDescending fst
|
||||
|> Seq.fold (fun lines (lineNum, replacement) -> updateSnapshotAtLine lines lineNum replacement) fileLines
|
||||
|
||||
/// <summary>
|
||||
/// Update every failed snapshot in the input, editing the files on disk.
|
||||
/// </summary>
|
||||
let updateAll (sources : CompletedSnapshot seq) : unit =
|
||||
sources
|
||||
|> Seq.groupBy (fun csc -> csc.CallerInfo.FilePath)
|
||||
|> Seq.iter (fun (callerFile, callers) ->
|
||||
let contents = System.IO.File.ReadAllLines callerFile
|
||||
|
||||
let sources =
|
||||
callers |> Seq.map (fun csc -> csc.CallerInfo.LineNumber, csc.Replacement)
|
||||
|
||||
let newContents = updateAllLines contents sources
|
||||
|
||||
System.IO.File.WriteAllLines (callerFile, newContents)
|
||||
)
|
||||
|
@@ -4,26 +4,34 @@ WoofWare.Expect.Builder.expect' [static property]: [read-only] WoofWare.Expect.E
|
||||
WoofWare.Expect.Builder.expectWithMockedFilePath [static method]: (string, int) -> WoofWare.Expect.ExpectBuilder
|
||||
WoofWare.Expect.Builder.get_expect [static method]: unit -> WoofWare.Expect.ExpectBuilder
|
||||
WoofWare.Expect.Builder.get_expect' [static method]: unit -> WoofWare.Expect.ExpectBuilder
|
||||
WoofWare.Expect.CallerInfo inherit obj, implements WoofWare.Expect.CallerInfo System.IEquatable, System.Collections.IStructuralEquatable, WoofWare.Expect.CallerInfo System.IComparable, System.IComparable, System.Collections.IStructuralComparable
|
||||
WoofWare.Expect.CallerInfo.Equals [method]: (WoofWare.Expect.CallerInfo, System.Collections.IEqualityComparer) -> bool
|
||||
WoofWare.Expect.CompletedSnapshot inherit obj, implements WoofWare.Expect.CompletedSnapshot System.IEquatable, System.Collections.IStructuralEquatable, WoofWare.Expect.CompletedSnapshot System.IComparable, System.IComparable, System.Collections.IStructuralComparable
|
||||
WoofWare.Expect.CompletedSnapshot.Equals [method]: (WoofWare.Expect.CompletedSnapshot, System.Collections.IEqualityComparer) -> bool
|
||||
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
|
||||
WoofWare.Expect.Mode.Equals [method]: (WoofWare.Expect.Mode, System.Collections.IEqualityComparer) -> bool
|
6
WoofWare.Expect/Text.fs
Normal file
6
WoofWare.Expect/Text.fs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace WoofWare.Expect
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module internal Text =
|
||||
let predent (c : char) (s : string) =
|
||||
s.Split '\n' |> Seq.map (sprintf "%c %s" c) |> String.concat "\n"
|
@@ -17,7 +17,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="AssemblyInfo.fs" />
|
||||
<Compile Include="Text.fs" />
|
||||
<Compile Include="Domain.fs" />
|
||||
<Compile Include="SnapshotUpdate.fs" />
|
||||
<Compile Include="Config.fs" />
|
||||
<Compile Include="Builder.fs" />
|
||||
<None Include="..\README.md">
|
||||
<Pack>True</Pack>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "0.2",
|
||||
"version": "0.4",
|
||||
"publicReleaseRefSpec": [
|
||||
"^refs/heads/main$"
|
||||
],
|
||||
|
Reference in New Issue
Block a user