Scenarify 0.1.23

dotnet add package Scenarify --version 0.1.23
                    
NuGet\Install-Package Scenarify -Version 0.1.23
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Scenarify" Version="0.1.23" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Scenarify" Version="0.1.23" />
                    
Directory.Packages.props
<PackageReference Include="Scenarify" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Scenarify --version 0.1.23
                    
#r "nuget: Scenarify, 0.1.23"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Scenarify@0.1.23
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Scenarify&version=0.1.23
                    
Install as a Cake Addin
#tool nuget:?package=Scenarify&version=0.1.23
                    
Install as a Cake Tool

Scenarify

CI NuGet NuGet

Given*/When/Then+ scenario testing for xUnit v3 service tests: JSON matching with token expansion, MockServer stubs, a ServiceTestFixture that spins up the service under test, and (via the Scenarify.Redis add-on) steps for asserting on and publishing Redis Streams messages.

Why not just write the HTTP calls directly?

You can, and Scenarify doesn't stop you (see "Escape hatches" below) — but most service tests don't need to:

  • No Task.Delay. Every step that waits (Mock.Received, Streams.Published, .Eventually()) polls with a real timeout and reports the last actual failure, not a generic "still false after N tries."
  • A broken dependency costs one timeout, not one per wait. When three waits in a row each burn their full timeout, the environment is down rather than slow, and the waits after them fail immediately instead of each spending another 30s to learn the same thing — the difference between a two-minute red build and a forty-minute one. A success clears the count, and a deliberately fast negative test never trips it. Tune with Eventually.ConsecutiveTimeoutLimit / EVENTUALLY_TIMEOUT_LIMIT, or set it to 0 to poll every time.
  • JSON checks that don't fight you. Subset matching by default (Matches), matcher tokens for the values you don't control ({{any:guid}}, {{any:datetime}}, {{contains:text}}, {{absent}}), and a failure message that lists every mismatched path before printing the whole body — not just "expected true, got false."
  • Isolation instead of cleanup. Fresh {{customerId}}/{{guid}} tokens per scenario mean tests can run in any order, in parallel, with no ResetAsync() between them.
  • One fixture, not six. ServiceTestFixture handles configuration layering, MockServer setup, health waits, and optional add-on features (RedisFeature for streams). Every project's Fixture.cs becomes a handful of lines.

Features

  • Scenario builder: Given* (setup) → When (trigger) → Then+ (checks). There is exactly one When: earlier sends that only set the stage are .Given(...); a trigger that genuinely is a short run of actions is .When(first, second, ...) (one When, children run in order, sharing variables). WhenConcurrently is for concurrency tests and a .Given(ctx => ...)/.When(...)/.Then(...) escape hatch for anything else.
  • HTTP steps: Http.Get/Post/Put/Patch/Delete, scopes/brand/user headers with token expansion, .Eventually() to poll a Given, .Capture(name, path), snapshot and subset/exact JSON checks.
  • MockServer steps: Mock.Get/Post/Put/Delete/Stub expectations (request body/header/query matching), Mock.Received/Mock.NotReceived against recorded requests, native MockServer expectation JSON.
  • JSON: Json.File/Inline/From/Parse<T> sources, Json.Patch (RFC 7396 merge patch), Json.Format for mixing C# values with {{tokens}} in one template without fighting C# raw-string interpolation, Json.Array.
  • Tokens: built-ins (brand, customerId, guid, now, now+1d/now-30m), .With(name, value) for scenario-scoped variables, .Capture for values pulled out of a response.
  • Snapshots: .MatchesSnapshot() against a reviewed <test>.snapshot.json, $only/$ignoring path selection, reverse token substitution, and a .received.json writer for a manual accept step — nothing is ever auto-accepted.
  • ServiceTestFixture: configuration layering (in-memory defaults → env vars → DOTNET_ env vars), MockServer init with a single reset, parallel health waits (including other services your test calls directly), and EnsureOnceAsync for setup shared across tests.
  • StandardApiTests: one line covering health + the OpenAPI/Swagger document for every service.
  • Scenarify.Redis (add-on): Streams.Publish/Streams.Published/Streams.NotPublished over RedisEvents, Redis.Set/Get/HashSet/SortedSetAdd/JsonSet seeding with a key prefix and default TTL, Redis.Subscribe/Redis.PublishedOn for pub/sub, and a RedisFeature that starts alongside the service under test.
  • Case records (PostThenGet, PostThenResponse, PostThenMockReceived, plus PublishThenGet, PostThenPublished, PublishThenMockReceived in Scenarify.Redis) for theory-driven tests, all xUnit-serializable so [MemberData] rows show up and rerun individually.

Quickstart

dotnet add package Scenarify
dotnet add package Scenarify.Redis   # only if your test publishes or observes Redis Streams messages

A fixture, once per test project:

public sealed class Fixture() : ServiceTestFixture(new ServiceTestOptions
{
    MockServer = true,                                     // wait, reset once, then load MockServerFiles
    MockServerFiles = ["mockserver/pricing-service.json"], // loaded before the health wait
    DefaultScopes = "orders-r:{{brand}} orders-w",         // sent as auth-claim-scopes (tokens expand)
    Features = [new RedisFeature(publish: ["orders"], record: ["order-shipped"])],
});

[CollectionDefinition(nameof(ServiceTestCollection))]
public sealed class ServiceTestCollection : ICollectionFixture<Fixture>;

A scenario:

[Fact]
public Task order_created_then_listed() =>
    fixture.Scenario()
        .When(Http.Post("v1/orders/{{brand}}", "cases/orders/create.json"))
        .Capture("orderId", "$")
        .Then(Http.Get("v1/orders/{{brand}}/{{orderId}}").Matches("""{ "status": "Pending" }"""))
        .RunAsync();

[Fact]
public Task order_shipped_notifies_customer() =>
    fixture.Scenario()
        .Given(Mock.Post("/notifications/v1/send/{{customerId}}"))
        .When(Streams.Publish("orders", "cases/orders/shipped.json",
            type: typeof(OrderShipped).FullName, key: "{{orderId}}"))
        .Then(Mock.Received("POST", "/notifications/v1/send/{{customerId}}",
            """{ "template": "OrderShipped" }"""))
        .RunAsync();

A more complete example: several mocks, a capture, and a stream check

Given* steps aren't limited to one mock, and a scenario can check several things about the same trigger — here a checkout charges a payment gateway, reserves stock, and its own status flips once both finish:

[Fact]
public Task checkout_charges_payment_and_reserves_stock() =>
    fixture.Scenario()
        .Given(Mock.Post("/payments/v1/charge/{{customerId}}", """{ "status": "Approved" }"""))
        .Given(Mock.Post("/inventory/v1/reserve", requestBody: """{ "sku": "{{sku}}" }"""))
        .When(Http.Post("v1/checkout/{{brand}}", "cases/checkout/cart.json"))
        .Capture("orderId", "$.orderId")
        .Then(Http.Get("v1/orders/{{brand}}/{{orderId}}").Matches("""{ "status": "Paid" }"""))
        .Then(Mock.Received("POST", "/payments/v1/charge/{{customerId}}", """{ "amount": 49.99 }"""))
        .Then(Mock.Received("POST", "/inventory/v1/reserve", """{ "sku": "{{sku}}" }"""))
        .RunAsync();

Redis Streams (Scenarify.Redis)

Seed Redis state with a Given, trigger over HTTP or by publishing a message, then assert on a message a downstream consumer published — Streams.Published<T> waits for a subset match, the same way Mock.Received does for HTTP:

[Fact]
public Task order_shipped_reserves_stock() =>
    fixture.Scenario()
        .Given(Redis.Set("inventory:{{sku}}", """{ "quantity": 10 }"""))
        .When(Streams.Publish("orders", "cases/orders/shipped.json",
            type: typeof(OrderShipped).FullName, key: "{{orderId}}"))
        .Then(Streams.Published<StockReserved>("inventory-events",
            """{ "sku": "{{sku}}", "quantity": 1 }"""))
        .RunAsync();

Redis.Set/HashSet/SortedSetAdd/JsonSet are discouraged outside cases like this one, where nothing else can reach the state a test needs to seed — prefer driving setup through the API or an event. Seeded keys expire after RedisFeature(seedTtl:) (10 minutes by default) so a forgotten write doesn't linger.

Workers with no HTTP listener

By default the fixture polls GET {SvcHttpUrl}/health. For a worker, say so and (optionally) supply a readiness probe:

new ServiceTestOptions
{
    ServesHttp = false,   // no /health wait; StandardApiTests becomes a no-op
    Readiness = ReadinessProbe.PublishAndObserve(
        publish: (f, ct) => PublishPing(f, ct),          // repeated each attempt
        observe: (f, ct) => PingWasHandled(f, ct)),      // true once the effect is seen
}

ReadinessProbe.From(...) wraps any check, ReadinessProbe.None skips waiting, or implement IReadinessProbe. A probe runs after features start and is retried until StartupTimeout; other Services are still health-checked.

StartupTimeout is applied per startup phase — MockServer, then Features, then the health and readiness waits — not as one budget shared across all of them. A feature that takes its time slows startup; it does not leave the health wait with a token that is already cancelled and a failure that blames the service.

Mixing C# values with tokens

Tokens use {{name}}, and so does C# raw-string interpolation — a $$""" string reads {{customerId}} as a C# expression, so mixing a C# value with a scenario token forces an unreadable $$$""" string with C# values in triple braces. Use Json.Format instead: write the template with tokens only, pass the C# values as an object (looked up before the scenario's own variables):

Json.Format("""{ "jobId": "{{jobId}}", "attempt": "{{attempt}}", "status": "{{status}}" }""",
    new { jobId = Json.Token($"job{n}"), attempt = n, status })

Or .With(name, value) when the value belongs to the whole scenario rather than one body — tokens inside the value still expand when it's used:

.With("lineItems", items.Select((item, i) => new { id = $"{i + 1}", item, addedAtUtc = "{{now}}" }))
.When(PublishOrder("""{ "lineItems": "{{lineItems}}" }"""))

Checks

Method Compares
Matches(json) Only the fields written; extras are ignored (the default)
MatchesExactly(json) All fields; extras fail
MatchesSnapshot() The recorded snapshots/<Class>/<test>.snapshot.json
Contains(json, within?) Some element subset-matches, in the body array or every array a path selects
Satisfies<T>(t => ...) A C# assertion, for computed expectations

Matcher values: {{contains:text}}, {{absent}} (missing, or a default the service omitted), {{any}}, {{any:guid}}, {{any:datetime}}, {{any:number}}, {{any:string}}, {{any:bool}}, {{any:object}}, {{any:array}}. Arrays are ordered unless .UnorderedArrays() is set. A failure lists every difference by JSON path, then the whole actual body.

Accepting a snapshot change

  1. .MatchesSnapshot() fails and writes <test>.received.json next to the expected snapshot file.
  2. Review it — known values already come back as {{tokens}}, unknown ids/times as {{any:guid}}/{{any:datetime}}.
  3. Accept it by moving the file over the expected one (dropping the .received suffix), e.g.:
    for f in **/*.received.json; do mv "$f" "${f%.received.json}.snapshot.json"; done
    
    Nothing is ever accepted automatically.

Escape hatches

A test that doesn't fit this shape (perf, concurrency, HTML flows) uses the primitives directly: fixture.Client(...) (with PostJsonAsync/PutJsonAsync(path, JsonSource, fixture.NewVariables()) for bodies), fixture.ClientsFor("name"), Eventually.Assert(...), JsonMatch.AssertMatches(...), fixture.Feature<RedisFeature>().Recorder.

Performance and security

Scenarify wraps HttpClient, MockServerClient, and (via Scenarify.Redis) RedisEvents — its own overhead is negligible next to the network calls it's making; the RedisEvents performance notes apply to Scenarify.Redis's use of it. Scenarify makes no security decisions of its own: it sends whatever headers/tokens your fixture is configured with, and MockServer/ Redis credentials are exactly whatever connection details you give it. Treat test-fixture tokens and MockServer expectations as test data, not production secrets.

Repository layout

src/Scenarify/        Core: JSON sources/tokens/matching/snapshots, Eventually, MockServer helpers,
                       TestClients, ServiceTestFixture, the scenario builder, StandardApiTests
src/Scenarify.Redis/   Redis Streams add-on: StreamSender/StreamRecorder, RedisFeature, Streams.*/Redis.* steps
tst/Scenarify.UnitTests/       Fast unit tests (TestType=UnitTest) — no Docker required
tst/Scenarify.Redis.Tests/     Testcontainers tests against real Redis + MockServer (TestType=ServiceTest)

Expansion

EventHub and Kafka can be added

Building & testing

dotnet build Scenarify.slnx
dotnet test Scenarify.slnx --filter "TestType=UnitTest"      # fast, no Docker
dotnet test Scenarify.slnx --filter "TestType=ServiceTest"   # needs Docker (Testcontainers: Redis, MockServer)

License

MIT — see LICENSE.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed.  net11.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on Scenarify:

Package Downloads
Scenarify.Redis

Redis Streams (RedisEvents) scenario steps for Scenarify: publish/assert on stream messages, seed Redis state, and a RedisFeature that starts alongside the service under test.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.23 1,726 10/24/2026
0.1.21 5,950 10/17/2026
0.1.19 1,361 10/16/2026
0.1.17 178 10/16/2026
0.1.12 4,810 10/4/2026
0.1.11 107 10/4/2026
0.1.9 2,763 9/28/2026
0.1.7 373 9/24/2026
0.1.5 941 9/18/2026
0.1.4 120 9/18/2026