Blex for Blazor

Quality

Testing

Stores are plain objects, so they test like plain objects - and Bit.Blex.Testing adds a harness for asserting on the dispatch pipeline itself.

The simplest test

A store needs no host, no DI container and no Blazor. Construct it, call an action, assert on a property.

C#
CounterTests.cs
[Fact]
public void Increment_adds_the_step()
{
    var store = new CounterStore();

    store.SetStep(5);
    store.Increment();

    Assert.Equal(5, store.Count);
    Assert.Equal(10, store.DoubleCount);
}

That covers state. What it does not cover is whether the right action was dispatched, with the right payload, exactly once. That is what the harness is for.

The harness

BlexTestHarness<TStore> creates a fresh BlexManager, registers your store with it, and starts recording every action.

C#
using Bit.Blex.Testing;

[Fact]
public void Increment_dispatches_one_action()
{
    using var harness = BlexTestHarness.For<CounterStore>();

    harness.Store.Increment();

    Assert.Equal(["Increment"], harness.Log.Names);
    Assert.Equal(1, harness.Snapshot()["Count"]!.GetValue<int>());
}

// For a store with dependencies, construct it yourself:
using var harness = BlexTestHarness.For(new WeatherStore(httpClient));

BlexTestHarness<TStore>

Member Description
BlexTestHarness.For&lt;TStore&gt;() Creates a harness for a store with a parameterless constructor.
BlexTestHarness.For(store) Creates a harness around an already-constructed store - use this when the store has dependencies.
BlexTestHarness.WithFilter&lt;TStore&gt;(predicate) A harness whose manager vetoes any action the predicate rejects. The shorthand for testing a store together with its guard rails.
BlexTestHarness.WithMiddleware&lt;TStore&gt;(…) A harness whose manager runs the given middleware. Also accepts an already-constructed store.
TStore Store The store under test.
BlexManager Manager The manager backing the harness. Use it to add middleware assertions or extra subscriptions.
BlexActionLog Log Records every dispatch.
JsonObject State The current global state tree.
JsonObject Snapshot() The current snapshot of the store under test.
TOther Register&lt;TOther&gt;(TOther store) Adds another store to the harness's manager, for cross-store tests. Returns it, so it can be captured inline.
IReadOnlyList&lt;BlexActionErrorContext&gt; Failures Every action whose body threw - including effect failures, which are otherwise hidden inside XxxError.
void Dispose() Detaches the log and disposes every registered store, cancelling effects still in flight.

The action log

BlexActionLog records each dispatch with its name, sequence, arguments and the global state immediately afterwards.

C#
using var harness = BlexTestHarness.For<CounterStore>();

harness.Store.Increment();
harness.Store.Increment();
harness.Store.SetStep(3);

Assert.Equal(3, harness.Log.Count);
Assert.Equal(2, harness.Log.CountOf("Increment"));
Assert.True(harness.Log.Contains("counter/SetStep"));
Assert.Equal("SetStep", harness.Log.Last!.ActionName);

// The state captured immediately after a specific action:
var afterFirst = harness.Log.Actions[0].State;
Assert.Equal(1, afterFirst["counter"]!["Count"]!.GetValue<int>());

BlexActionLog

Member Description
IReadOnlyList&lt;BlexRecordedAction&gt; Actions Everything recorded, in dispatch order.
BlexRecordedAction? Last The most recent action, or null.
int Count Number of recorded actions.
IReadOnlyList&lt;string&gt; Names The bare action names in order.
IReadOnlyList&lt;string&gt; QualifiedNames The qualified names in order.
bool Contains(string) Whether an action with that bare or qualified name was recorded.
int CountOf(string) How many times it was recorded.
void Clear() Empties the log - handy between arrange and act.

BlexRecordedAction

Member Description
IBlexStore Store The originating store.
string ActionName / QualifiedName "Increment" and "counter/Increment".
int Sequence The monotonic dispatch number.
JsonObject State The global state tree right after this action.
IReadOnlyList&lt;BlexActionArg&gt; Args The captured arguments.

Asserting on payloads

Action arguments are captured automatically, so you can assert on exactly what was dispatched.

C#
harness.Store.SetStep(5);

var action = harness.Log.Last!;
Assert.Equal("SetStep", action.ActionName);
Assert.Single(action.Args);
Assert.Equal("step", action.Args[0].Name);
Assert.Equal(5, action.Args[0].Value);

// A direct assignment records as "Set X", carrying the assigned value.
harness.Store.Count = 42;
Assert.Equal("Set Count", harness.Log.Last!.ActionName);
Assert.Equal(42, harness.Log.Last!.Args[0].Value);

Testing effects

Awaiting the effect wrapper is usually enough. When you need to observe an intermediate state - a loading flag, say - WaitForAsync beats sprinkling Task.Delay through the test: it re-evaluates the condition on every store change and throws TimeoutException if it never becomes true.

C#
[Fact]
public async Task LoadUser_populates_the_user()
{
    using var harness = BlexTestHarness.For(new UserStore(new FakeApi()));

    await harness.Store.LoadUser(42);

    Assert.False(harness.Store.LoadUserIsLoading);
    Assert.Null(harness.Store.LoadUserError);
    Assert.Equal("Ada", harness.Store.User!.Name);
}

[Fact]
public async Task LoadUser_reports_loading_while_in_flight()
{
    using var harness = BlexTestHarness.For(new UserStore(new SlowApi()));

    var pending = harness.Store.LoadUser(42);          // do not await yet
    Assert.True(harness.Store.LoadUserIsLoading);

    await pending;
    await harness.Store.WaitForAsync(() => !harness.Store.LoadUserIsLoading);
}

Errors and cancellation

C#
[Fact]
public async Task A_failing_effect_captures_the_exception()
{
    using var harness = BlexTestHarness.For(new UserStore(new ThrowingApi()));

    await harness.Store.LoadUser(42);   // never throws

    Assert.IsType<HttpRequestException>(harness.Store.LoadUserError);
}

[Fact]
public async Task Cancelling_is_not_an_error()
{
    using var harness = BlexTestHarness.For(new UserStore(new SlowApi()));

    var pending = harness.Store.LoadUser(42);
    harness.Store.CancelLoadUser();
    await pending;

    Assert.Null(harness.Store.LoadUserError);   // our own cancellation is a normal outcome
}

Asserting on batching

CountNotifications counts how many times a store raised StateChanged while an action ran - the direct way to prove that a multi-field action really does render once.

C#
[Fact]
public void An_action_notifies_once_however_many_fields_it_touches()
{
    var store = new CounterStore();

    // OnApplyPreset assigns both Count and Step.
    var notifications = store.CountNotifications(() => store.ApplyPreset(10, 5));

    Assert.Equal(1, notifications);
}

Testing middleware

A store is rarely alone in production - a veto filter, an audit logger or a metrics recorder usually wraps it, and those guard rails deserve tests too. BlexTestHarness.WithFilter and WithMiddleware give you the same zero-setup harness with the pipeline attached.

C#
[Fact]
public void A_filter_vetoes_the_action()
{
    using var harness = BlexTestHarness.WithFilter<CounterStore>(_ => false);

    harness.Store.Increment();

    Assert.Equal(0, harness.Store.Count);   // the mutation never ran
    Assert.Empty(harness.Log.Actions);      // and nothing was recorded
}

[Fact]
public void The_audit_middleware_sees_every_action()
{
    var audit = new AuditMiddleware();
    using var harness = BlexTestHarness.WithMiddleware<CounterStore>(audit);

    harness.Store.Increment();

    Assert.Equal(["counter/Increment"], audit.Entries);
}

// More than one store, or a manager configured up front: build it yourself.
[Fact]
public void Two_stores_share_one_filter()
{
    var manager = new BlexManager([new BlexFilterMiddleware(ctx => ctx.Store.Name != "cart")]);
    manager.Register(new CartStore());
    manager.Register(new ProfileStore());
    using var log = manager.RecordActions();
    ...
}

Reach for a hand-built BlexManager only when the test needs a manager configured before any store is registered.

More than one store

Cross-store coordination is exactly the kind of thing worth testing, and abandoning the harness to hand-wire a manager for it defeats the point. harness.Register(store) brings a second store onto the same manager: its actions land in the same log and its slice in the same State tree, and it is disposed with the harness.

C#
[Fact]
public void PlacingAnOrder_NotifiesTheUser()
{
    using var harness = BlexTestHarness.For<OrdersStore>();
    var notifications = harness.Register(new NotificationsStore());

    using var wiring = harness.Manager.SubscribeTo<OrdersStore>(
        ctx => notifications.Push($"order: {ctx.ActionName}"));

    harness.Store.Place("Widget", 2);

    // One log, both stores:
    Assert.Equal(
        new[] { "orders/Place", "notifications/Push" },
        harness.Log.QualifiedNames);

    Assert.Equal(1, notifications.Unread);
}

[Fact]
public async Task CheckoutSettlesAcrossStores()
{
    using var harness = BlexTestHarness.For<CartStore>();
    var orders = harness.Register(new OrdersStore());

    var checkout = harness.Store.Checkout();

    await harness.Manager.WaitForAsync(
        () => !harness.Store.CheckoutIsLoading && orders.Count == 1);

    await checkout;
}

For a condition that spans stores - or one the store you are driving will not report - manager.WaitForAsync re-checks after every action and every restore in the application, where store.WaitForAsync only watches one store.

Asserting on failures

An effect captures its exception into XxxError instead of propagating it, so a test that only watches for a throw sees nothing at all. harness.Failures records every action whose body threw, with the arguments it was called with.

C#
[Fact]
public async Task AFailingLoad_IsReportedWithItsArguments()
{
    using var harness = BlexTestHarness.For<DataStore>();
    harness.Store.ShouldThrow = true;

    // The effect wrapper captures the exception, so there is nothing to Assert.Throws on.
    await harness.Store.Load("orders");

    Assert.NotNull(harness.Store.LoadError);

    var failure = Assert.Single(harness.Failures);
    Assert.Equal("data/Load", failure.QualifiedName);
    Assert.Equal("orders", failure.Args[0].Value);
}

Testing persistence

IBlexStorage is a three-method interface, so an in-memory fake is trivial. Remember to FlushAsync when the persistor is debounced.

C#
private sealed class MemoryStorage : IBlexStorage
{
    public Dictionary<string, string> Values { get; } = [];

    public ValueTask<string?> GetAsync(string key, CancellationToken ct = default)
        => new(Values.TryGetValue(key, out var v) ? v : null);

    public ValueTask SetAsync(string key, string value, CancellationToken ct = default)
    {
        Values[key] = value;
        return ValueTask.CompletedTask;
    }

    public ValueTask RemoveAsync(string key, CancellationToken ct = default)
    {
        Values.Remove(key);
        return ValueTask.CompletedTask;
    }
}

[Fact]
public async Task State_round_trips_through_storage()
{
    var storage = new MemoryStorage();
    var manager = new BlexManager();
    var store = new SettingsStore();
    manager.Register(store);

    await using var persistor = new BlexStatePersistor(manager, storage);
    await persistor.StartAsync();

    store.SetAccent("teal");
    await persistor.FlushAsync();

    Assert.Contains("teal", storage.Values["blex:settings"]);

    // A fresh store rehydrates from the same storage.
    var manager2 = new BlexManager();
    var store2 = new SettingsStore();
    manager2.Register(store2);
    await using var persistor2 = new BlexStatePersistor(manager2, storage);
    await persistor2.StartAsync();

    Assert.Equal("teal", store2.Accent);
}

Testing undo/redo

C#
[Fact]
public void Undo_restores_the_previous_state()
{
    var manager = new BlexManager();
    var store = new CounterStore();
    manager.Register(store);

    using var history = new BlexHistory(manager);
    history.Start();

    store.Increment();
    store.Increment();
    Assert.Equal(2, store.Count);

    history.Undo();
    Assert.Equal(1, store.Count);
    Assert.True(history.CanRedo);

    history.Redo();
    Assert.Equal(2, store.Count);
}

Extension methods

BlexTestExtensions

Member Description
manager.RecordActions() Starts recording on any manager and returns the BlexActionLog. Dispose to stop.
store.Snapshot() Alias for SerializeState().
store.WaitForAsync(Func&lt;bool&gt;, TimeSpan?) Waits until the condition holds, re-checking on every store change. Throws TimeoutException after the timeout (default 5 s).
manager.WaitForAsync(Func&lt;bool&gt;, TimeSpan?) The same, for a condition spanning stores - re-checked after every action and every restore in the application.
store.CountNotifications(Action) Counts StateChanged notifications raised while the delegate runs.

Snapshots make good assertions

harness.Snapshot() and harness.State return the same JSON the library uses for persistence and time-travel. Asserting against a snapshot checks the whole store at once, and it fails loudly when a field you forgot about changes.

Dispose the harness

The harness holds a live subscription on its manager. Use using var - or Dispose() - so a long test class does not accumulate recorders. The harness's manager is its own, so tests never interfere with each other.

Testing components

For component-level tests with bUnit, register the store the same way the app does and let the component subscribe. There is nothing Blex-specific to mock.

C#
[Fact]
public void The_component_renders_the_current_count()
{
    using var ctx = new Bunit.TestContext();
    ctx.Services.AddBitBlexServices();
    ctx.Services.AddBitBlexStore<CounterStore>();

    var component = ctx.RenderComponent<Counter>();
    var store = ctx.Services.GetRequiredService<CounterStore>();

    component.Find("button.increment").Click();

    Assert.Equal(1, store.Count);
    component.MarkupMatches("<p>Count: 1</p>*");
}
An unhandled error has occurred. Reload ๐Ÿ—™