diff --git a/SUMMARY.md b/SUMMARY.md index 3b41ee1..ba3a98d 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -10,6 +10,7 @@ * [How Configuring the Command Processor Works](/contents/HowConfiguringTheCommandProcessorWorks.md) * [How Configuring a Dispatcher for an External Bus Works](/contents/HowConfiguringTheDispatcherWorks.md) * [InMemory Options for Development and Testing](/contents/InMemoryOptions.md) +* [Test Double Options for Command Processor](/contents/TestDoubleOptions.md) ## Darker Configuration diff --git a/contents/TestDoubleOptions.md b/contents/TestDoubleOptions.md new file mode 100644 index 0000000..36cd89b --- /dev/null +++ b/contents/TestDoubleOptions.md @@ -0,0 +1,263 @@ +# Test Double Options for Command Processor + +## Overview + +When handlers depend on `IAmACommandProcessor` to publish events or send commands, you need a way to verify those interactions in tests. The `Paramore.Brighter.Testing` package provides `SpyCommandProcessor` - a test double that records all calls for later verification. + +## Installation + +Add a reference to the `Paramore.Brighter.Testing` package in your test project: + +```xml + +``` + +Or reference the project directly: + +```xml + +``` + +## Using SpyCommandProcessor + +### Basic Usage + +Inject `SpyCommandProcessor` as your `IAmACommandProcessor` dependency and verify interactions after exercising the handler: + +```csharp +// Arrange +var spy = new SpyCommandProcessor(); +var handler = new PlaceOrderHandler(spy); +var command = new PlaceOrder { ProductId = "WIDGET-1", Quantity = 3 }; + +// Act +handler.Handle(command); + +// Assert - verify the handler published an OrderPlaced event +spy.WasCalled(CommandType.Publish).ShouldBeTrue(); +var published = spy.Observe(); +published.ProductId.ShouldBe("WIDGET-1"); +``` + +### API Layers + +SpyCommandProcessor provides a layered API, from simple checks to detailed inspection: + +#### Layer 1: Quick Checks + +For the most common verification needs: + +```csharp +// Was a specific method type called? +spy.WasCalled(CommandType.Send) // true/false +spy.WasCalled(CommandType.Publish) // true/false +spy.WasCalled(CommandType.Post) // true/false + +// How many times? +spy.CallCount(CommandType.Send) // int + +// Dequeue requests in FIFO order (consuming) +var command = spy.Observe(); +var @event = spy.Observe(); +``` + +`Observe()` dequeues the next request of type `T` from the queue. This is useful for verifying multiple calls in sequence. It throws `InvalidOperationException` if no matching request is found. + +#### Layer 2: Request Inspection + +For examining all calls without consuming them: + +```csharp +// Get all requests of a specific type (non-destructive, can call repeatedly) +IEnumerable commands = spy.GetRequests(); + +// Get all recorded calls for a command type (includes timestamp and context) +IEnumerable sendCalls = spy.GetCalls(CommandType.Send); + +// Get the sequence of command types in call order +IReadOnlyList types = spy.Commands; +// e.g. [Send, Publish, Send] after three calls +``` + +#### Layer 3: Full Details + +For advanced scenarios requiring complete call information: + +```csharp +// All recorded calls with full details +IReadOnlyList allCalls = spy.RecordedCalls; + +foreach (var call in allCalls) +{ + Console.WriteLine($"{call.Type} at {call.Timestamp}: {call.Request.GetType().Name}"); + // call.Context provides the RequestContext if one was passed +} +``` + +### CommandType Values + +Each `IAmACommandProcessor` method maps to a `CommandType`: + +| Method | CommandType | +|--------|-------------| +| `Send` | `Send` | +| `SendAsync` | `SendAsync` | +| `Publish` | `Publish` | +| `PublishAsync` | `PublishAsync` | +| `Post` | `Post` | +| `PostAsync` | `PostAsync` | +| `DepositPost` | `Deposit` | +| `DepositPostAsync` | `DepositAsync` | +| `ClearOutbox` | `Clear` | +| `ClearOutboxAsync` | `ClearAsync` | +| `Call` | `Call` | +| Scheduled (sync) | `Scheduler` | +| Scheduled (async) | `SchedulerAsync` | + +## Verifying Send/Publish/Post Calls + +### Verifying Send + +```csharp +var spy = new SpyCommandProcessor(); +var handler = new MyHandler(spy); + +handler.Handle(new TriggerCommand()); + +// Quick check +spy.WasCalled(CommandType.Send).ShouldBeTrue(); + +// Inspect the sent command +var sent = spy.Observe(); +sent.SomeProperty.ShouldBe("expected"); +``` + +### Verifying Publish + +```csharp +var spy = new SpyCommandProcessor(); +var handler = new OrderHandler(spy); + +handler.Handle(new PlaceOrder { OrderId = "ORD-1" }); + +// Check event was published +spy.CallCount(CommandType.Publish).ShouldBe(1); + +// Inspect the event +var events = spy.GetRequests(); +events.First().OrderId.ShouldBe("ORD-1"); +``` + +### Verifying Post (External Bus) + +```csharp +var spy = new SpyCommandProcessor(); +var handler = new NotificationHandler(spy); + +handler.Handle(new SendNotification { UserId = "user-1" }); + +spy.WasCalled(CommandType.Post).ShouldBeTrue(); +var posted = spy.Observe(); +posted.UserId.ShouldBe("user-1"); +``` + +## Verifying the Outbox Pattern (DepositPost + ClearOutbox) + +When handlers use the outbox pattern, `SpyCommandProcessor` tracks deposits separately: + +```csharp +var spy = new SpyCommandProcessor(); +var handler = new TransactionalHandler(spy); + +handler.Handle(new ProcessPayment { Amount = 99.99m }); + +// Verify the deposit +spy.WasCalled(CommandType.Deposit).ShouldBeTrue(); +spy.DepositedRequests.Count.ShouldBe(1); + +// Get the deposited request +var (id, request) = spy.DepositedRequests.First(); +var deposited = request.ShouldBeOfType(); +deposited.Amount.ShouldBe(99.99m); + +// Simulate ClearOutbox (moves deposits to observation queue) +spy.ClearOutbox(new[] { id }); + +// Now it's available via Observe +var cleared = spy.Observe(); +cleared.Amount.ShouldBe(99.99m); +``` + +## State Management + +Use `Reset()` between test scenarios when reusing a spy: + +```csharp +var spy = new SpyCommandProcessor(); + +// First scenario +spy.Send(new CommandA()); +spy.WasCalled(CommandType.Send).ShouldBeTrue(); + +// Reset for next scenario +spy.Reset(); + +spy.WasCalled(CommandType.Send).ShouldBeFalse(); +spy.RecordedCalls.Count.ShouldBe(0); +spy.DepositedRequests.Count.ShouldBe(0); +``` + +## Extending SpyCommandProcessor + +All methods on `SpyCommandProcessor` are `virtual`, allowing you to create specialized subclasses: + +```csharp +public class ThrowingSpyCommandProcessor : SpyCommandProcessor +{ + public override void Send(TRequest command, RequestContext? requestContext = null) + { + base.Send(command, requestContext); // Still records the call + throw new InvalidOperationException("Send should not be called in this test"); + } +} +``` + +This is useful for testing error handling paths in your handlers. + +## Alternative: Using Mocking Frameworks + +If you prefer mocking frameworks, you can mock `IAmACommandProcessor` directly. `SpyCommandProcessor` is a convenience for when you want a lightweight, framework-independent test double. + +### Moq + +```csharp +var mock = new Mock(); +var handler = new MyHandler(mock.Object); + +handler.Handle(new MyCommand()); + +mock.Verify(p => p.Publish(It.IsAny(), It.IsAny()), Times.Once); +``` + +### NSubstitute + +```csharp +var substitute = Substitute.For(); +var handler = new MyHandler(substitute); + +handler.Handle(new MyCommand()); + +substitute.Received(1).Publish(Arg.Any(), Arg.Any()); +``` + +## Best Practices + +1. **Prefer `SpyCommandProcessor` over mocking frameworks** for simple verification - it requires no additional dependencies and provides a clearer API. + +2. **Use `Observe()`** for sequential verification when order matters. Use `GetRequests()` when you just need all requests of a type. + +3. **Use `Reset()`** if sharing a spy across multiple test methods in a fixture rather than creating new instances. Creating a new instance per test class constructor is preferred. + +4. **Test behaviors, not interactions** - verify that the right events/commands were produced with the right data, rather than asserting on exact call sequences. + +5. **Extend with subclasses** when you need the spy to throw exceptions or return specific values from `Call()`.