I have a Blazor server side application that is using events to trigger updates between clients. I have decided to migrate this to instead use MediatR to trigger updates. The actual implementation is working, but I'm kind of stuck on the unit tests. Previously I used FluentAssertions
with IMonitor
to verify an event was raised. Now I wanna do something similar with MediatR. I've seen questions related to this on SO, with people answering "you shouldn't test the internals of MediatR". In my case I'm sure I'm not testing the internals of MediatR. What I want to test is that a notification was actually published, with the expected values.
First I'll show my working implementation with events. The important parts here is a service that does some business logic and fires an event if everything looks good, a class UpdateNotifier
that exposes the event and methods for firing them, and lastly there's a unit test that tests the service.
public class MessageService
{
private readonly IUpdateNotifier _notifier;
public MessageService(IUpdateNotifier notifier)
{
_notifier = notifier;
}
public async Task<bool> PublishMessage(DateTimeOffset timestamp, string username, string message)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(message))
{
return false;
}
_notifier.FireMessagePublished(timestamp, username, message);
return true;
}
}
public class UpdateNotifier : IUpdateNotifier
{
public event EventHandler<MessagePublishedEventArgs>? MessagePublished;
public void FireMessagePublished(DateTimeOffset timestamp, string username, string message)
{
MessagePublished?.Invoke(this, new MessagePublishedEventArgs(timestamp, username, message));
}
}
public class MessagePublishedEventArgs
{
public MessagePublishedEventArgs(DateTimeOffset timestamp, string username, string message)
{
Timestamp = timestamp;
Username = username;
Message = message;
}
public DateTimeOffset Timestamp { get; init; }
public string Username { get; init; }
public string Message { get; init; }
}
public class MessageServiceTests
{
private readonly IUpdateNotifier _notifier;
private readonly MessageService _messageService;
public MessageServiceTests()
{
_notifier = new UpdateNotifier();
_messageService = new MessageService(_notifier);
}
[Fact]
public async Task PublishMessage_MessagePublished_HasExpectedValues()
{
// Arrange
var timestamp = DateTimeOffset.Now;
var username = "Eric";
var message = "Hi! :)";
using var updateNotifierMonitor = _notifier.Monitor();
// Act
var publishMessageResult = await _messageService.PublishMessage(timestamp, username, message);
// Assert
publishMessageResult.Should().BeTrue();
updateNotifierMonitor.Should()
.Raise(nameof(_notifier.MessagePublished))
.WithArgs<MessagePublishedEventArgs>(args => args.Timestamp.Equals(timestamp) && args.Username.Equals(username) && args.Message.Equals(message));
}
}
In my migration, what I've changed is to remove the IUpdateNotifier
/UpdateNotifier
. MessageService
relies on IMediator
instead. And instead of MessagePublishedEventArgs
there's a MessagePublishedNotification
that implements INotification
.
public async Task<bool> PublishMessage(DateTimeOffset timestamp, string username, string message)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(message))
{
return false;
}
await _mediator.Publish(new MessagePublishedNotification(timestamp, username, message));
return true;
}
Is there any way to write a unit test in the same way as my original unit test, but that verifies an INotification
of expected type with expected values was published?
I don't see any reason to mock anything here. I want to use the actual implementation for everything.
I've figured out how to solve this according to my specifications.
In the test project, implement a generic INotificationHandler
that can subscribe to any notifications:
public class TestNotificationHandler<TNotification> : INotificationHandler<TNotification>, IDisposable
where TNotification : INotification
{
internal List<TNotification> Notifications { get; } = new();
public async Task Handle(TNotification notification, CancellationToken cancellationToken)
{
Notifications.Add(notification);
await Task.CompletedTask;
}
public void Dispose()
{
Notifications.Clear();
}
}
Update MessageServiceTests
with the following changes:
TestNotificationHandler
with the notification we want to subscribe to (in this case, MessagePublishedNotification
)IDisposable
so that the messages are cleared between unit testsTestNotificationHandler
received the notificationpublic class MessageServiceTests : IDisposable
{
private readonly MessageService _messageService;
private readonly TestNotificationHandler<MessagePublishedNotification> _notificationHandler;
public MessageServiceTests()
{
var services = new ServiceCollection();
services.AddMediator();
_notificationHandler = new TestNotificationHandler<MessagePublishedNotification>();
services.AddTransient<INotificationHandler<MessagePublishedNotification>>(_ => _notificationHandler);
_messageService = new MessageService(services.BuildServiceProvider().GetRequiredService<IMediator>());
}
[Fact]
public async Task PublishMessage_MessagePublished_HasExpectedValues()
{
// Arrange
var timestamp = DateTimeOffset.Now;
var username = "Eric";
var message = "Hi! :)";
// Act
var publishMessageResult = await _messageService.PublishMessage(timestamp, username, message);
// Assert
publishMessageResult.Should().BeTrue();
_notificationHandler.Notifications.Should()
.Contain(n => n.Timestamp == timestamp && n.Username == username && n.Message == message);
}
public void Dispose()
{
_notificationHandler.Dispose();
}
}
Edit:
During my rather extensive testing with this approach, I've found two ways in which this can break. So please take care to adhere to these notes:
services.BuildServiceProvider().GetRequiredService<IMediator>()
in the test setup is called after adding all the notification handlers. Otherwise they don't get called.IDisposable
on my handler. Somehow that broke the implementation. When I debugged my handler was called, but it appeared to have created two instances of my handler since when the test was asserting there were no notifications in the list. I haven't been able to reproduce this consistently.