Testing Reservoir
Overview
Reservoir's architecture is designed for testability. The pattern separates pure functions (reducers, selectors) from async operations (effects), making each layer independently testable.
For applications requiring high test coverage, Reservoir provides a testing pyramid:
- Selectors - Test business logic as pure functions (fastest, highest coverage)
- Reducers - Test state transitions with Given/When/Then
- Effects - Test async operations with the StoreTestHarness
- Components - Thin wiring verification only
See Why Use Selectors? for detailed guidance on achieving high coverage without complex UI tests.
Testing Approach by Component
| Component | Testing Approach | Tooling |
|---|---|---|
| Selectors | Pure function calls with test state | Standard xUnit/NUnit, no special tooling |
| Reducers | StoreTestHarness Given/When/Then | Mississippi.Reservoir.Testing |
| Effects | StoreTestHarness with mocked services | Mississippi.Reservoir.Testing |
| Components | Verify wiring, not logic | bUnit (optional, minimal tests) |
Testing Selectors
Selectors are pure functions-the easiest code to test. They require no special infrastructure:
[Fact]
public void IsAccountOpen_WhenProjectionExists_ReturnsOpenStatus()
{
// Arrange - create state directly
var state = new ProjectionsFeatureState()
.WithEntry("account-123", new ProjectionEntry<BankAccountBalanceProjectionDto>(
new BankAccountBalanceProjectionDto { IsOpen = true },
Version: 1, IsLoading: false, IsConnected: true, Error: null));
// Act - call the pure function
var selector = BankAccountProjectionSelectors.IsAccountOpen("account-123");
bool result = selector(state);
// Assert
Assert.True(result);
}
For comprehensive selector testing patterns including factory selectors, composite selectors, and memoization testing, see Testing Selectors.
Testing Reducers and Effects with StoreTestHarness
The Mississippi.Reservoir.Testing package provides a fluent test harness for testing reducers and effects in isolation. It supports Given/When/Then style scenarios without requiring the full Store infrastructure.
Installation
Add the testing package to your test project:
<PackageReference Include="Mississippi.Reservoir.Testing" />
Quick Start
using Mississippi.Reservoir.Testing;
[Fact]
public void IncrementAction_IncreasesCounter()
{
// Arrange - Create harness with reducers
var harness = StoreTestHarnessFactory.ForFeature<CounterState>()
.WithReducer<IncrementAction>((state, _) => state with { Count = state.Count + 1 });
// Act & Assert - Given/When/Then
harness.CreateScenario()
.Given(new CounterState { Count = 5 })
.When(new IncrementAction())
.ThenState(state => state.Count.Should().Be(6));
}
StoreTestHarness Components
StoreTestHarnessFactory
Entry point for creating test harnesses. Use ForFeature<TState>() to create a harness for your feature state type:
var harness = StoreTestHarnessFactory.ForFeature<NavigationState>();
StoreTestHarness<TState>
Configures reducers, effects, and services for testing. All methods return the harness for fluent chaining.
| Method | Purpose |
|---|---|
WithReducer<TAction>(reducer) | Registers a reducer delegate |
WithEffect<TEffect>() | Registers an effect by type (DI-resolved) |
WithEffect(instance) | Registers an effect instance directly |
WithService<TService>(instance) | Registers a service for effect DI |
WithInitialState(state) | Sets the default initial state for scenarios |
CreateScenario() | Creates a new scenario for Given/When/Then testing |
StoreScenario<TState>
Executes the Given/When/Then pattern. Scenarios are disposable and should be wrapped in using.
| Method | Purpose |
|---|---|
Given(actions...) | Applies actions through reducers to establish initial state |
GivenState(state) | Sets state directly without running reducers |
When(action) | Dispatches an action through reducers and effects |
WhenAsync(action) | Async version for testing async effects |
ThenState(assertion) | Asserts on the resulting state |
ThenEmits<TAction>(assertion?) | Asserts an action was emitted by effects |
ThenEmitsNothing() | Asserts no actions were emitted |
Testing Reducers
Reducers are pure functions-given the same state and action, they always return the same result. This makes them easy to test:
[Fact]
public void SetValueAction_UpdatesValue()
{
// Arrange
var harness = StoreTestHarnessFactory.ForFeature<MyState>()
.WithReducer<SetValueAction>((state, action) => state with { Value = action.Value });
// Act & Assert
using var scenario = harness.CreateScenario();
scenario
.Given(new MyState { Value = "old" })
.When(new SetValueAction("new"))
.ThenState(state => state.Value.Should().Be("new"));
}
Testing Multiple Reducers
Register multiple reducers and chain actions:
[Fact]
public void MultipleActions_ApplySequentially()
{
var harness = StoreTestHarnessFactory.ForFeature<CounterState>()
.WithReducer<IncrementAction>((s, _) => s with { Count = s.Count + 1 })
.WithReducer<DecrementAction>((s, _) => s with { Count = s.Count - 1 })
.WithReducer<ResetAction>((s, _) => s with { Count = 0 });
using var scenario = harness.CreateScenario();
scenario
.GivenState(new CounterState { Count = 10 })
.When(new IncrementAction()) // 11
.When(new IncrementAction()) // 12
.When(new DecrementAction()) // 11
.ThenState(s => s.Count.Should().Be(11));
}
Using Given to Establish State
Use Given to apply actions through reducers to reach a specific state:
[Fact]
public void ResetAction_ClearsCounter()
{
var harness = StoreTestHarnessFactory.ForFeature<CounterState>()
.WithReducer<IncrementAction>((s, _) => s with { Count = s.Count + 1 })
.WithReducer<ResetAction>((s, _) => s with { Count = 0 });
using var scenario = harness.CreateScenario();
scenario
.Given(new IncrementAction(), new IncrementAction(), new IncrementAction()) // Count = 3
.When(new ResetAction())
.ThenState(s => s.Count.Should().Be(0));
}
Testing Effects
Effects handle async side effects and may emit new actions. The harness captures emitted actions for assertions.
Testing Effect Action Emissions
[Fact]
public void SubmitFormAction_EmitsSuccessOrFailure()
{
// Arrange - register effect instance
var mockService = Substitute.For<IFormService>();
mockService.SubmitAsync(Arg.Any<FormData>()).Returns(Task.FromResult(true));
var harness = StoreTestHarnessFactory.ForFeature<FormState>()
.WithEffect(new SubmitFormEffect(mockService));
// Act
using var scenario = harness.CreateScenario();
scenario.When(new SubmitFormAction(new FormData { Name = "Test" }));
// Assert - effect emitted success action
scenario.ThenEmits<FormSubmittedAction>(a => a.Success.Should().BeTrue());
}
Testing Effects with Dependencies
Register services for constructor injection:
[Fact]
public async Task NavigationEffect_NavigatesToUri()
{
// Arrange
var mockNavManager = new TestableNavigationManager();
var harness = StoreTestHarnessFactory.ForFeature<NavigationState>()
.WithService(mockNavManager as NavigationManager)
.WithEffect<NavigationEffect>();
// Act
using var scenario = harness.CreateScenario();
await scenario.WhenAsync(new NavigateAction("/dashboard"));
// Assert
mockNavManager.NavigatedUri.Should().Be("/dashboard");
}
Asserting No Emissions
Use ThenEmitsNothing() to verify effects don't emit actions:
[Fact]
public void UnhandledAction_EmitsNothing()
{
var harness = StoreTestHarnessFactory.ForFeature<MyState>()
.WithEffect(new MyEffect()); // Effect only handles SpecificAction
using var scenario = harness.CreateScenario();
scenario
.When(new UnrelatedAction())
.ThenEmitsNothing();
}
Complete Example
Here's a complete example testing a login feature:
// Feature State
public sealed record LoginState : IFeatureState
{
public static string FeatureKey => "login";
public bool IsLoading { get; init; }
public string? Username { get; init; }
public string? Error { get; init; }
}
// Actions
public sealed record LoginAction(string Username, string Password) : IAction;
public sealed record LoginSucceededAction(string Username) : IAction;
public sealed record LoginFailedAction(string Error) : IAction;
// Reducers
public static class LoginReducers
{
public static LoginState OnLogin(LoginState state, LoginAction _)
=> state with { IsLoading = true, Error = null };
public static LoginState OnSuccess(LoginState state, LoginSucceededAction action)
=> state with { IsLoading = false, Username = action.Username };
public static LoginState OnFailed(LoginState state, LoginFailedAction action)
=> state with { IsLoading = false, Error = action.Error };
}
// Tests
public sealed class LoginTests
{
private StoreTestHarness<LoginState> CreateHarness() =>
StoreTestHarnessFactory.ForFeature<LoginState>()
.WithReducer<LoginAction>(LoginReducers.OnLogin)
.WithReducer<LoginSucceededAction>(LoginReducers.OnSuccess)
.WithReducer<LoginFailedAction>(LoginReducers.OnFailed);
[Fact]
public void LoginAction_SetsLoadingState()
{
using var scenario = CreateHarness().CreateScenario();
scenario
.GivenState(new LoginState())
.When(new LoginAction("user", "pass"))
.ThenState(s =>
{
s.IsLoading.Should().BeTrue();
s.Error.Should().BeNull();
});
}
[Fact]
public void LoginSucceeded_ClearsLoadingAndSetsUsername()
{
using var scenario = CreateHarness().CreateScenario();
scenario
.Given(new LoginAction("testuser", "password")) // Sets IsLoading = true
.When(new LoginSucceededAction("testuser"))
.ThenState(s =>
{
s.IsLoading.Should().BeFalse();
s.Username.Should().Be("testuser");
});
}
[Fact]
public void LoginFailed_ClearsLoadingAndSetsError()
{
using var scenario = CreateHarness().CreateScenario();
scenario
.Given(new LoginAction("user", "wrong"))
.When(new LoginFailedAction("Invalid credentials"))
.ThenState(s =>
{
s.IsLoading.Should().BeFalse();
s.Error.Should().Be("Invalid credentials");
});
}
}
Best Practices
1. One Scenario Per Test
Create a fresh scenario for each test to ensure isolation:
[Fact]
public void Test1()
{
using var scenario = harness.CreateScenario();
// ...
}
[Fact]
public void Test2()
{
using var scenario = harness.CreateScenario(); // Fresh scenario
// ...
}
2. Use GivenState for Direct State Setup
When you need specific state values without caring how they got there:
scenario.GivenState(new MyState
{
Items = [item1, item2, item3],
SelectedIndex = 1
});
3. Use Given to Test State Transitions
When you want to verify the path to a state matters:
scenario
.Given(new AddItemAction(item1))
.Given(new SelectItemAction(0))
.When(new DeleteSelectedAction())
.ThenState(s => s.Items.Should().BeEmpty());
4. Dispose Scenarios
Always use using to dispose scenarios and release DI containers:
using var scenario = harness.CreateScenario();
5. Use Deterministic Time in Effects
Inject FakeTimeProvider for time-dependent logic:
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero));
var harness = StoreTestHarnessFactory.ForFeature<MyState>()
.WithService<TimeProvider>(fakeTime)
.WithEffect<MyTimeBasedEffect>();
Summary
- Reservoir testing works best when selectors, reducers, and effects stay isolated and deterministic.
StoreTestHarnessgives reducers and effects a focused Given/When/Then test surface without requiring the full store runtime.- Deterministic time and one-scenario-per-test keep failures local and repeatable.
Next Steps
- Selectors - Test business logic as pure functions.
- Reservoir Overview - Return to the full Reservoir model.
- Reducers - Review reducer patterns that fit the test harness.
- Effects - Review effect behavior before testing async flows.
- Built-in Navigation - Test navigation reducers.
- Built-in Lifecycle - Test lifecycle reducers.