Scenarify 0.1.9
See the version list below for details.
dotnet add package Scenarify --version 0.1.9
NuGet\Install-Package Scenarify -Version 0.1.9
<PackageReference Include="Scenarify" Version="0.1.9" />
<PackageVersion Include="Scenarify" Version="0.1.9" />
<PackageReference Include="Scenarify" />
paket add Scenarify --version 0.1.9
#r "nuget: Scenarify, 0.1.9"
#:package Scenarify@0.1.9
#addin nuget:?package=Scenarify&version=0.1.9
#tool nuget:?package=Scenarify&version=0.1.9
Scenarify
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.
Scenarify grew out of a betting platform's own service-test suite: about 590 tests that hit real HTTP services and MockServer over Docker Compose, most sharing one shape — an optional setup step, a trigger, and a JSON check — but each hand-rolled with its own polling helper, its own stream publisher, and its own JSON-diffing code. Scenarify is that shape, extracted.
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 noResetAsync()between them. - One fixture, not six.
ServiceTestFixturehandles configuration layering, MockServer setup, health waits, and optional add-on features (RedisFeaturefor streams). Every project'sFixture.csbecomes a handful of lines.
Features
- Scenario builder:
Given*(setup) →When(trigger) →Then+(checks), withWhenConcurrentlyfor 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/Stubexpectations (request body/header/query matching),Mock.Received/Mock.NotReceivedagainst recorded requests, native MockServer expectation JSON. - JSON:
Json.File/Inline/From/Parse<T>sources,Json.Patch(RFC 7396 merge patch),Json.Formatfor 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,.Capturefor values pulled out of a response. - Snapshots:
.MatchesSnapshot()against a reviewed<test>.snapshot.json,$only/$ignoringpath selection, reverse token substitution, and a.received.jsonwriter 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), andEnsureOnceAsyncfor 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.NotPublishedover RedisEvents,Redis.Set/Get/HashSet/SortedSetAdd/JsonSetseeding with a key prefix and default TTL,Redis.Subscribe/Redis.PublishedOnfor pub/sub, and aRedisFeaturethat starts alongside the service under test.- Case records (
PostThenGet,PostThenResponse,PostThenMockReceived, plusPublishThenGet,PostThenPublished,PublishThenMockReceivedinScenarify.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.
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
.MatchesSnapshot()fails and writes<test>.received.jsonnext to the expected snapshot file.- Review it — known values already come back as
{{tokens}}, unknown ids/times as{{any:guid}}/{{any:datetime}}. - Accept it by moving the file over the expected one (dropping the
.receivedsuffix), e.g.:
Nothing is ever accepted automatically.for f in **/*.received.json; do mv "$f" "${f%.received.json}.snapshot.json"; done
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)
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 | Versions 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. |
-
net10.0
- Microsoft.Extensions.Configuration (>= 10.0.1)
- Microsoft.Extensions.Configuration.EnvironmentVariables (>= 10.0.1)
- Microsoft.Extensions.Configuration.Json (>= 10.0.1)
- MockServerClientNet (>= 2.2.2)
- xunit.v3.assert (>= 1.0.0)
- xunit.v3.extensibility.core (>= 1.0.0)
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.