How To Unit Test Framework Services In Orleans
Testing interactions between components in a distributed system context tends to involve full-scale integration testing rather than fast unit testing. This requires developers to wait for a deployment to execute, rather than having immediate feedback along the test-driven-development cycle. The use of the provider model in Orleans takes much of this pain away, as we can instead mock and fake the base services and providers, while relying on them to do the right thing at run time. We can still have deployment-time integration testing, but with Orleans, it is easy to have immediate testing feedback at development time.
This article describes how we can go about this, and it is based on the unit testing sample I’ve added to the Orleans repository.
TLDR
- Clone the Orleans Unit Testing Sample
- Explore the tests!
Overview
Though not obvious at first glance, Orleans allows mocking and faking of all of its own services and interfaces. This enables very high test coverage, which is can be hard to attain when developing a composed system.
Orleans favours testing using two approaches, Isolated Testing and Hosted Testing.
Isolated Testing
These are tests where both dependencies and Orleans services are mocked on a test-by-test basis. This style enables fine-grained isolated unit testing of the code under question. However, like any testing using mocks, it can also lead to more verbose test code due to redundant mocking constructs.
Hosted Testing
These are tests that run on the Orleans Test Cluster, where fake Orleans services are used. This style enables course-grained integration testing of multiple components, using fake data sources for higher coverage. It leads to both shorter and more reliable test code due to better reproduction of live running conditions. However, if using a shared test cluster, the developer must take care to prepare shared fake components and partition data as appropriate to avoid clashes during parallel testing. That said, the developer can spin up multiple test clusters as required and in parallel. The code samples in the appendix show how to wire this up in xUnit.
How It Works
The examples below demonstrate how to test various scenarios that depend on Orleans services. The actual grains under test are the same regardless of approach.
There is no setup required to code isolated unit tests. We call them isolated for a reason. Each one stands on its own.
However we do need some setup for the hosted integration tests - we need to spin up one or more test clusters where our tests can run. We also need to configure this cluster (or clusters!) with some fake services that we can verify afterwards. That said, this is easy to do - you can find that setup in the appendix at the end of this post.
Testing A Basic Grain
Here is a simple grain that allows setting and getting a value.
public class BasicGrain : Grain, IBasicGrain
{
private int value;
public Task<int> GetValueAsync() => Task.FromResult(value);
public Task SetValueAsync(int value)
{
this.value = value;
return Task.CompletedTask;
}
}
Isolated Test
Testing this grain in isolation is no different from testing any other code.
[Fact]
public async Task Gets_And_Sets_Value()
{
// create a new grain
var grain = new BasicGrain();
// assert the default value is zero
Assert.Equal(0, await grain.GetValueAsync());
// set a new value
await grain.SetValueAsync(123);
// assert the new value is as set
Assert.Equal(123, await grain.GetValueAsync());
}
It may feel funny that while we never create grain ourselves in feature code, we’re doing it here. However this is still a valid - and fast - way of unit testing them.
Hosted Test
We can also test this grain on an in-memory test cluster. Again, see the appendix for instructions on how to set this up. This allows us to grab grains from said cluster at test time, in the same way we write feature code.
[Fact]
public async Task Gets_And_Sets_Value()
{
// get a new basic grain from the cluster
var grain = fixture.Cluster.GrainFactory.GetGrain<IBasicGrain>(Guid.NewGuid());
// assert the default value is zero
Assert.Equal(0, await grain.GetValueAsync());
// set a new value
await grain.SetValueAsync(123);
// assert the new value is as set
Assert.Equal(123, await grain.GetValueAsync());
}
In the example above, fixture.Cluster
references the test cluster, which makes available a GrainFactory
for us to use as we would in feature code.
This approach didn’t save any lines of code here (and we’ve had to pay to set this up), but now we’re testing a live grain, living in a live cluster and with the live behaviours we expect to see. We will get our investment money back soon enough.
Testing A Grain That Calls Another Grain
One of the neat things of actor frameworks like Orleans is the ability of actors (or grains in this case) to call each other across the cluster, regardless of where they are. This enables application modelling techniques very hard to do achieve in stateless designs. Here is a sample grain that keeps a running count and publishes that counter to some other grain when requested, wherever it is. And don’t worry if it looks simple - we’ll complicate this soon enough.
public class CallingGrain : Grain, ICallingGrain
{
private int counter;
public Task IncrementAsync()
{
counter += 1;
return Task.CompletedTask;
}
public Task PublishAsync() => GrainFactory.GetGrain<ISummaryGrain>(Guid.Empty).SetAsync(GrainKey, counter);
/// <summary>
/// Opens up the grain factory for mocking.
/// </summary>
public virtual new IGrainFactory GrainFactory => base.GrainFactory;
/// <summary>
/// Opens up the grain key name for mocking.
/// </summary>
public virtual string GrainKey => this.GetPrimaryKeyString();
}
On Dependency Surfacing
Note how we are opening up the GrainFactory
and GrainKey
properties for mocking.
Under normal circumstances, Orleans is the one providing these features, and hence they will not work when we instantiate the grain ourselves.
We must therefore open them up for mocking in the isolated test code.
On the other hand, we don’t need this at all for hosted tests! If you’re going with the test cluster, by all means, remove that code. When grains are running in a live cluster, those features work just as you expect them to.
Isolated Test
Testing this grain in isolation now requires a bit more care…
[Fact]
public async Task Publishes_On_Demand()
{
// mock a summary grain
var summary = Mock.Of<ISummaryGrain>();
// mock the grain factory
var factory = Mock.Of<IGrainFactory>(_ => _.GetGrain<ISummaryGrain>(Guid.Empty, null) == summary);
// mock the grain type so we can mock the base orleans methods
var grain = new Mock<CallingGrain>() { CallBase = true };
grain.Setup(_ => _.GrainFactory).Returns(factory);
grain.Setup(_ => _.GrainKey).Returns("MyCounter");
// increment the value in the grain
await grain.Object.IncrementAsync();
// publish the value to the summary
await grain.Object.PublishAsync();
// assert the summary was called as expected
Mock.Get(summary).Verify(_ => _.SetAsync("MyCounter", 1));
}
As testing this grain now requires mocking of framework services, we now have to use var grain = new Mock<CallingGrain>() { CallBase = true };
in order to override them. Overriding also adds a few more lines of code. Still, the test remains isolated as we intend it to be.
Hosted Test
The hosted test on the other hand, looks as simple as feature code is with Orleans.
[Fact]
public async Task Publishes_On_Demand()
{
// we should use a unique grain key as we are using a shared test cluster
var key = Guid.NewGuid().ToString();
// get a new grain from the test host
var grain = fixture.Cluster.GrainFactory.GetGrain<ICallingGrain>(key);
// increment the value in the grain
await grain.IncrementAsync();
// publish the value to the summary
await grain.PublishAsync();
// assert the summary was called as expected
Assert.Equal(1, await fixture.Cluster.GrainFactory.GetGrain<ISummaryGrain>(Guid.Empty).TryGetAsync(key));
}
There are no framework services to fake in the unit test, as the test cluster is providing the correct functionality out-of-the-box, leading to cleaner test code.
Testing A Grain That Performs Work On A Timer
Another common scenario is for grains to perform some work on a timer tick. For the benefit of simplicity, here is a grain that just increments an internal counter on a schedule.
public class TimerGrain : Grain, ITimerGrain
{
private int value;
public Task<int> GetValueAsync() => Task.FromResult(value);
public override Task OnActivateAsync()
{
RegisterTimer(_ =>
{
++value;
return Task.CompletedTask;
}, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
return base.OnActivateAsync();
}
/// <summary>
/// This opens up the timer registration method for mocking.
/// </summary>
public virtual new IDisposable RegisterTimer(Func<object, Task> asyncCallback, object state, TimeSpan dueTime, TimeSpan period) =>
base.RegisterTimer(asyncCallback, state, dueTime, period);
}
Note how we are opening up the RegisterTimer()
framework method for mocking, just like we did before with other services.
Again, we only need this for isolated tests and not for hosted tests.
Isolated Test
Testing this in isolation requires us to override the timer behaviour. This is because Orleans is no longer there to issue timer ticks in the first place.
[Fact]
public async Task Increments_Value_On_Timer()
{
// mock the grain to override methods
var grain = new Mock<TimerGrain>() { CallBase = true };
// mock the timer registration method and capture the action
Func<object, Task> action = null;
object state = null;
var dueTime = TimeSpan.FromSeconds(1);
var period = TimeSpan.FromSeconds(1);
grain.Setup(_ => _.RegisterTimer(It.IsAny<Func<object, Task>>(), It.IsAny<object>(), It.IsAny<TimeSpan>(), It.IsAny<TimeSpan>()))
.Callback<Func<object, Task>, object, TimeSpan, TimeSpan>((a, b, c, d) => { action = a; state = b; dueTime = c; period = d; })
.Returns(Mock.Of<IDisposable>());
// simulate activation
await grain.Object.OnActivateAsync();
// assert the timer was registered
Assert.NotNull(action);
Assert.Null(state);
Assert.Equal(TimeSpan.FromSeconds(1), dueTime);
Assert.Equal(TimeSpan.FromSeconds(1), period);
// assert the initial value is zero
Assert.Equal(0, await grain.Object.GetValueAsync());
// tick the timer
await action(null);
// assert the new value is one
Assert.Equal(1, await grain.Object.GetValueAsync());
}
Here we are overriding the RegisterTimer()
framework method in order to tick the timer when we want to, keeping the test very fast.
Note as well how we are explicitly calling OnActivateAsync()
. We must do this as Orleans is not there to call it for us as needed.
This does start leading to more verbose code now, though nothing out of the ordinary for this type of functionality.
Hosted Test
Testing this using the test cluster is far more straightforward. However, this requires some common setup to work, which you can find in the appendix.
[Fact]
public async Task Increments_Value_On_Timer()
{
// using a random key allows parallel testing on the shared cluster
var key = Guid.NewGuid();
// get a new instance of the grain
var grain = fixture.Cluster.GrainFactory.GetGrain<ITimerGrain>(key);
// assert the initial state is zero - this will activate the grain and register the timer
Assert.Equal(0, await grain.GetValueAsync());
// assert the timer was registered on some silo
var timer = fixture.GetTimers(grain).SingleOrDefault();
Assert.NotNull(timer);
// tick the timer
await timer.TickAsync();
// assert the new value is one
Assert.Equal(1, await grain.GetValueAsync());
}
As compared to the isolated test, this one looks far more readable. Get the grain proxy, call something to activate it, ensure the timer got registered, tick it, and check the results. Nothing to it.
Testing A Grain That Calls Another Grain On A Timer
Didn’t I say we’d start complicating soon enough? Well this is yet another common use of grains, calling some other grain on a schedule in order to do some work, often to propagate some transient state, or to act as a watchdog for some activity.
public class CallingTimerGrain : Grain, ICallingTimerGrain
{
private int counter;
/// <summary>
/// Orleans calls this on grain activation.
/// For isolated unit tests we must call this to simulate activation.
/// However, the test host will call this on its own.
/// </summary>
public override Task OnActivateAsync()
{
// register a timer to call another grain every second
RegisterTimer(_ => GrainFactory.GetGrain<ISummaryGrain>(Guid.Empty).SetAsync(GrainKey, counter),
null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
return base.OnActivateAsync();
}
/// <summary>
/// This opens up the grain key for mocking.
/// </summary>
public virtual string GrainKey => this.GetPrimaryKeyString();
/// <summary>
/// This opens up the grain factory property for mocking.
/// </summary>
public virtual new IGrainFactory GrainFactory =>
base.GrainFactory;
/// <summary>
/// This opens up the timer registration method for mocking.
/// </summary>
public virtual new IDisposable RegisterTimer(Func<object, Task> asyncCallback, object state, TimeSpan dueTime, TimeSpan period) =>
base.RegisterTimer(asyncCallback, state, dueTime, period);
/// <summary>
/// Increments the counter by one.
/// </summary>
public Task IncrementAsync()
{
counter += 1;
return Task.CompletedTask;
}
}
Again, the opening up of the GrainKey, GrainFactory and RegisterTimer services for mocking is only to enable isolated testing.
Isolated Test
Testing this scenario in isolation is now a matter of combining the mocking of the timer service with the mocking of the grain factory.
public class CallingTimerGrainTests
{
[Fact]
public async Task Publishes_Counter_To_Summary_On_Timer()
{
// mock the summary grain
var summary = Mock.Of<ISummaryGrain>();
// mock the grain factory and summary grain
var factory = Mock.Of<IGrainFactory>(_ => _.GetGrain<ISummaryGrain>(Guid.Empty, null) == summary);
// mock the grain under test and override affected orleans methods
var grain = new Mock<CallingTimerGrain>() { CallBase = true };
grain.Setup(_ => _.GrainFactory).Returns(factory);
grain.Setup(_ => _.GrainKey).Returns("MyGrainKey");
Func<object, Task> action = null;
object state = null;
var dueTime = TimeSpan.Zero;
var period = TimeSpan.Zero;
grain.Setup(_ => _.RegisterTimer(It.IsAny<Func<object, Task>>(), It.IsAny<object>(), It.IsAny<TimeSpan>(), It.IsAny<TimeSpan>()))
.Callback<Func<object, Task>, object, TimeSpan, TimeSpan>((a, b, c, d) => { action = a; state = b; dueTime = c; period = d; })
.Returns(Mock.Of<IDisposable>());
// increment the value while simulating activation
await grain.Object.OnActivateAsync();
await grain.Object.IncrementAsync();
// assert the timer was registered
Assert.NotNull(action);
Assert.Null(state);
Assert.Equal(TimeSpan.FromSeconds(1), dueTime);
Assert.Equal(TimeSpan.FromSeconds(1), period);
// tick the timer
await action(null);
// assert the summary got the first result
Mock.Get(factory.GetGrain<ISummaryGrain>(Guid.Empty)).Verify(_ => _.SetAsync("MyGrainKey", 1));
// increment the value
await grain.Object.IncrementAsync();
// tick the timer again
await action(null);
// assert the summary got the next result
Mock.Get(summary).Verify(_ => _.SetAsync("MyGrainKey", 2));
}
}
Like mocking code always does, though, it does start getting quite verbose.
Hosted Test
Testing this grain on the hosted test cluster, however, still resembles feature code.
[Fact]
public async Task Publishes_Counter_To_Summary_On_Timer()
{
// using a random key allows parallel testing on the shared cluster
var key = Guid.NewGuid().ToString();
// get a new grain instance to test
var grain = fixture.Cluster.GrainFactory.GetGrain<ICallingTimerGrain>(key);
// increment the counter - this will also activate the grain and register the timer
await grain.IncrementAsync();
// assert the timer was registered on some silo in the cluster
var timer = fixture.GetTimers(grain).Single();
// tick the timer once
await timer.TickAsync();
// assert the summary grain got the first result
Assert.Equal(1, await fixture.Cluster.GrainFactory.GetGrain<ISummaryGrain>(Guid.Empty).TryGetAsync(key));
// increment the counter again
await grain.IncrementAsync();
// tick the timer again
await timer.TickAsync();
// assert the summary grain got the second result
Assert.Equal(2, await fixture.Cluster.GrainFactory.GetGrain<ISummaryGrain>(Guid.Empty).TryGetAsync(key));
}
We get the grain proxy, ask to increment the counter, tick the timer, ensure the target grain got the result and then do it again just to make sure. Nice and tidy, we’re starting to get our investment back.
Testing A Grain That Does Work On A Reminder
Reminders are just long-lived timers that survive the grain lifecycle. Testing grains that use reminders is therefore similiar to testing grain that use timers. Here is a sample grain that increments a counter upon a reminder tick.
public class ReminderGrain : Grain, IReminderGrain, IRemindable
{
private int value;
public override async Task OnActivateAsync()
{
await RegisterOrUpdateReminder(nameof(IncrementAsync), TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
await base.OnActivateAsync();
}
public Task<int> GetValueAsync() => Task.FromResult(value);
private Task IncrementAsync()
{
value += 1;
return Task.CompletedTask;
}
public virtual new Task<IGrainReminder> RegisterOrUpdateReminder(string reminderName, TimeSpan dueTime, TimeSpan period) =>
base.RegisterOrUpdateReminder(reminderName, dueTime, period);
public async Task ReceiveReminder(string reminderName, TickStatus status)
{
switch (reminderName)
{
case nameof(IncrementAsync):
await IncrementAsync();
break;
default:
await UnregisterReminder(await GetReminder(reminderName));
break;
}
}
}
Isolated Testing
Due to the way grains implement the IRemindable
interface, ticking the reminder is as simple as calling the ReceiveReminder()
method on its own with a given TickStatus
value.
[Fact]
public async Task Increments_Value_On_Reminder()
{
// mock the grain
var grain = new Mock<ReminderGrain>() { CallBase = true };
grain.Setup(_ => _.RegisterOrUpdateReminder("IncrementAsync", TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)))
.Returns(Task.FromResult(Mock.Of<IGrainReminder>(_ => _.ReminderName == "IncrementAsync")));
// simulate activation
await grain.Object.OnActivateAsync();
// ensure the reminder was registered
grain.Verify(_ => _.RegisterOrUpdateReminder("IncrementAsync", TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)));
// assert the initial value is zero
Assert.Equal(0, await grain.Object.GetValueAsync());
// tick the reminder
await grain.Object.ReceiveReminder("IncrementAsync", new TickStatus());
// assert the new value is one
Assert.Equal(1, await grain.Object.GetValueAsync());
}
Overriding the RegisterOrUpdateReminder()
method allows us to Verify()
that the grain indeed registered the reminder as we expect.
Hosted Testing
We can also do this using the test cluster, again leading to cleaner code.
[Fact]
public async Task Increments_Value_On_Reminder()
{
// get a new grain to test
var grain = fixture.Cluster.GrainFactory.GetGrain<IReminderGrain>(Guid.NewGuid());
// assert the initial value is zero - this will also activate the grain
Assert.Equal(0, await grain.GetValueAsync());
// assert the reminder was registered on one of the fake registries
var reminder = fixture.GetReminder(grain, "IncrementAsync");
Assert.NotNull(reminder);
// tick the reminder
await grain.AsReference<IRemindable>().ReceiveReminder("IncrementAsync", new TickStatus());
// assert the new value is one
Assert.Equal(1, await grain.GetValueAsync());
}
Testing A Grain That Persists State To Storage
As a final example, here is a grain that persists its own state to storage upon request.
public class PersistentGrain : Grain, IPersistentGrain
{
private readonly IPersistentState<MyState> value;
public PersistentGrain([PersistentState("State")] IPersistentState<MyState> value)
{
this.value = value;
}
public Task SetValueAsync(int value)
{
this.value.State.Value = value;
return this.value.WriteStateAsync();
}
[ProtoContract]
public class MyState
{
[ProtoMember(1)]
public int Value { get; set; }
}
}
The ProtoContract
stuff relates to protocol buffers, not a target for testing right now.
Isolated Testing
For testing in isolation, we just mock the IPersistentState<PersistentGrain.MyState>
and Verify
its use afterwards.
[Fact]
public async Task Saves_State()
{
// mock a persistent state item
var state = Mock.Of<IPersistentState<PersistentGrain.MyState>>(_ => _.State == Mock.Of<PersistentGrain.MyState>());
// create a new grain - we dont mock the grain here because we do not need to override any base methods
var grain = new PersistentGrain(state);
// set a new value
await grain.SetValueAsync(123);
// assert the state was saved
Mock.Get(state).Verify(_ => _.WriteStateAsync());
Assert.Equal(123, state.State.Value);
}
Hosted Testing
For hosting testing, we instead make use of the pre-registered fake storage provider to tell us what the state looks like.
[Fact]
public async Task Saves_State()
{
// get a brand new grain to test
var grain = fixture.Cluster.GrainFactory.GetGrain<IPersistentGrain>(Guid.NewGuid());
// set its value to something we can check
await grain.SetValueAsync(123);
// assert that state was saved by one of the silos
var state = fixture.GetGrainState(typeof(PersistentGrain), "State", grain);
Assert.NotNull(state);
// assert that state is of the corect type
var obj = state.State as PersistentGrain.MyState;
Assert.NotNull(obj);
// assert that state has the correct value
Assert.Equal(123, obj.Value);
}
Appendix: Setting Up Hosted Tests
While use of the test cluster makes for more straightforward testing, it does require some up-front setup. That said, this is a one-time cost. If you’re using xUnit you can probably lift the fixture code from the samples outright and go with that.
Let’s looks at the class we need to add to an xUnit test project
ClusterFixture
The ClusterFixture
works as an xUnit managed fixture - a class that xUnit will instantiate and dispose of, while running a given collection of tests.
The fixture below enables spinning up multiple test clusters, each with its own set of silos.
As each cluster can have multiple silos, each with its own set of fake services, the fixture provides some helper methods to locate the right services in the right places during testing.
public class ClusterFixture : IDisposable
{
/// <summary>
/// Identifier for this test cluster instance to facilitate parallel testing with multiple clusters that need fake services.
/// </summary>
public string TestClusterId { get; } = Guid.NewGuid().ToString();
/// <summary>
/// Exposes the shared cluster for unit tests to use.
/// </summary>
public TestCluster Cluster { get; }
/// <summary>
/// Keeps all the fake grain storage instances in use by different clusters to facilitate parallel unit testing.
/// </summary>
public static ConcurrentDictionary<string, ConcurrentBag<FakeGrainStorage>> GrainStorageGroups { get; } = new ConcurrentDictionary<string, ConcurrentBag<FakeGrainStorage>>();
/// <summary>
/// Gets the fake grain storage item for the given grain by searching across all silos.
/// </summary>
public IGrainState GetGrainState(Type implementationType, string name, IGrain grain)
{
return GrainStorageGroups[TestClusterId]
.SelectMany(_ => _.Storage)
.Where(_ => _.Key.Item1 == $"{implementationType.FullName}{(name == null ? "" : $",{typeof(PersistentGrain).Namespace}.{name}")}")
.Where(_ => _.Key.Item2.Equals((GrainReference)grain))
.Select(_ => _.Value)
.SingleOrDefault();
}
/// <summary>
/// Keeps all the fake timer registries in use by different clusters to facilitate parallel unit testing.
/// </summary>
public static ConcurrentDictionary<string, ConcurrentBag<FakeTimerRegistry>> TimerRegistryGroups { get; } = new ConcurrentDictionary<string, ConcurrentBag<FakeTimerRegistry>>();
/// <summary>
/// Gets all the fake timers for the target grain across all silos.
/// </summary>
public IEnumerable<FakeTimerEntry> GetTimers(IGrain grain)
{
return TimerRegistryGroups[TestClusterId]
.SelectMany(_ => _.GetAll())
.Where(_ => _.Grain.GrainReference.Equals((GrainReference)grain));
}
/// <summary>
/// Keeps all the fake reminder registries in use by different clusters to facilitate parallel unit testing.
/// </summary>
public static ConcurrentDictionary<string, ConcurrentBag<FakeReminderRegistry>> ReminderRegistryGroups { get; } = new ConcurrentDictionary<string, ConcurrentBag<FakeReminderRegistry>>();
/// <summary>
/// Gets the target fake reminder by searching across all silos.
/// </summary>
public FakeReminder GetReminder(IGrain grain, string name)
{
return ReminderRegistryGroups[TestClusterId]
.Select(_ => _.GetReminder((GrainReference)grain, name).Result)
.Where(_ => _ != null)
.SingleOrDefault();
}
public ClusterFixture()
{
// prepare to receive the fake services from individual silos
GrainStorageGroups[TestClusterId] = new ConcurrentBag<FakeGrainStorage>();
TimerRegistryGroups[TestClusterId] = new ConcurrentBag<FakeTimerRegistry>();
ReminderRegistryGroups[TestClusterId] = new ConcurrentBag<FakeReminderRegistry>();
var builder = new TestClusterBuilder();
// add the cluster id for this instance
// this allows the silos to safely lookup shared data for this cluster deployment
// without this we can only share data via static properties and that messes up parallel testing
builder.ConfigureHostConfiguration(config =>
{
config.AddInMemoryCollection(new Dictionary<string, string>()
{
{ nameof(TestClusterId), TestClusterId }
});
});
// a configurator allows the silos to configure themselves
// at this time, configurators cannot take injected parameters
// therefore we must other means of sharing objects as you can see above
builder.AddSiloBuilderConfigurator<SiloBuilderConfigurator>();
Cluster = builder.Build();
Cluster.Deploy();
}
private class SiloBuilderConfigurator : ISiloBuilderConfigurator
{
public void Configure(ISiloHostBuilder hostBuilder)
{
hostBuilder.ConfigureServices(services =>
{
// add the fake storage provider as default in a way that lets us extract it afterwards
services.AddSingleton(_ => new FakeGrainStorage());
services.AddSingleton<IGrainStorage>(_ => _.GetService<FakeGrainStorage>());
// add the fake timer registry in a way that lets us extract it afterwards
services.AddSingleton<FakeTimerRegistry>();
services.AddSingleton<ITimerRegistry>(_ => _.GetService<FakeTimerRegistry>());
// add the fake reminder registry in a way that lets us extract it afterwards
services.AddSingleton<FakeReminderRegistry>();
services.AddSingleton<IReminderRegistry>(_ => _.GetService<FakeReminderRegistry>());
});
hostBuilder.UseServiceProviderFactory(services =>
{
var provider = services.BuildServiceProvider();
var config = provider.GetService<IConfiguration>();
// grab the cluster id that owns this silo
var clusterId = config[nameof(TestClusterId)];
// extract the fake services from the silo so unit tests can access them
GrainStorageGroups[clusterId].Add(provider.GetService<FakeGrainStorage>());
TimerRegistryGroups[clusterId].Add(provider.GetService<FakeTimerRegistry>());
ReminderRegistryGroups[clusterId].Add(provider.GetService<FakeReminderRegistry>());
return provider;
});
}
}
public void Dispose() => Cluster.StopAllSilos();
}
To be able to tell xUnit where and when to use this fixture, we need to create at least one CollectionDefinition
for it.
This is an xUnit specific pattern.
[CollectionDefinition(nameof(ClusterCollection))]
public class ClusterCollection : ICollectionFixture<ClusterFixture>
{
}
Nothing stops us from creating more collection definitions, each with its own name, instead of the default in the example. This is what allows us to spin up multiple clusters at the same time.
This xUnit collection allows us to inject a cluster fixture instance into specifc xUnit unit tests.
[Collection(nameof(ClusterCollection))]
public class BasicGrainTests
{
private readonly ClusterFixture fixture;
public BasicGrainTests(ClusterFixture fixture)
{
this.fixture = fixture;
}
/* ... */
}
And voila - shared test cluster on-demand.
Fakes
The example test cluster fixture makes generous use of fake services to promote easier tests. Below are some example, and nothing stops us from adding more as we need to.
The FakeGrainStorage
holds grain state in memory, in the same spirit of the built-in memory storage provider, but one that we can search across all silos for easy state verification.
public class FakeGrainStorage : IGrainStorage
{
public ConcurrentDictionary<Tuple<string, GrainReference>, IGrainState> Storage { get; } = new ConcurrentDictionary<Tuple<string, GrainReference>, IGrainState>();
public Task ClearStateAsync(string grainType, GrainReference grainReference, IGrainState grainState)
{
Storage.TryRemove(Tuple.Create(grainType, grainReference), out _);
return Task.CompletedTask;
}
public Task ReadStateAsync(string grainType, GrainReference grainReference, IGrainState grainState)
{
Storage.TryGetValue(Tuple.Create(grainType, grainReference), out grainState);
return Task.CompletedTask;
}
public Task WriteStateAsync(string grainType, GrainReference grainReference, IGrainState grainState)
{
Storage[Tuple.Create(grainType, grainReference)] = grainState;
return Task.CompletedTask;
}
}
The FakeReminder
is used by the FakeReminderRegistry
to hold reminder information.
public class FakeReminder : IGrainReminder
{
public FakeReminder(string reminderName, TimeSpan dueTime, TimeSpan period)
{
ReminderName = reminderName;
DueTime = dueTime;
Period = period;
}
public string ReminderName { get; }
public TimeSpan DueTime { get; }
public TimeSpan Period { get; }
}
The FakeReminderRegistry
overrides the built-in reminder services, storing reminder entries in-memory and in searchable form.
public class FakeReminderRegistry : GrainServiceClient<IReminderService>, IReminderRegistry
{
private readonly ConcurrentDictionary<GrainReference, ConcurrentDictionary<string, FakeReminder>> reminders =
new ConcurrentDictionary<GrainReference, ConcurrentDictionary<string, FakeReminder>>();
public FakeReminderRegistry(IServiceProvider provider) : base(provider)
{
}
private ConcurrentDictionary<string, FakeReminder> GetRemindersFor(GrainReference reference) =>
reminders.GetOrAdd(reference, _ => new ConcurrentDictionary<string, FakeReminder>());
#region Fake Service Calls
public Task<IGrainReminder> GetReminder(string reminderName)
{
GetRemindersFor(CallingGrainReference).TryGetValue(reminderName, out var reminder);
return Task.FromResult((IGrainReminder)reminder);
}
public Task<List<IGrainReminder>> GetReminders() =>
Task.FromResult(GetRemindersFor(CallingGrainReference).Values.Cast<IGrainReminder>().ToList());
public Task<IGrainReminder> RegisterOrUpdateReminder(string reminderName, TimeSpan dueTime, TimeSpan period)
{
var reminder = new FakeReminder(reminderName, dueTime, period);
GetRemindersFor(CallingGrainReference)[reminderName] = reminder;
return Task.FromResult((IGrainReminder)reminder);
}
public Task UnregisterReminder(IGrainReminder reminder)
{
GetRemindersFor(CallingGrainReference).TryRemove(reminder.ReminderName, out _);
return Task.CompletedTask;
}
#endregion Unvalidated Service Calls
#region Test Helpers
public Task<FakeReminder> GetReminder(GrainReference grainRef, string reminderName)
{
GetRemindersFor(grainRef).TryGetValue(reminderName, out var reminder);
return Task.FromResult(reminder);
}
#endregion Test Helpers
}
The FakeTimerEntry
is used by the FakeTimerRegistry
to hold timer information in a searchable form.
/// <summary>
/// Implements a fake timer entry to facilitate unit testing.
/// </summary>
public class FakeTimerEntry : IDisposable
{
private readonly TaskScheduler scheduler;
private readonly FakeTimerRegistry owner;
public Grain Grain { get; }
public Func<object, Task> AsyncCallback { get; }
public object State { get; }
public TimeSpan DueTime { get; }
public TimeSpan DuePeriod { get; }
public FakeTimerEntry(FakeTimerRegistry owner, TaskScheduler scheduler, Grain grain, Func<object, Task> asyncCallback, object state, TimeSpan dueTime, TimeSpan period)
{
this.scheduler = scheduler;
this.owner = owner;
Grain = grain;
AsyncCallback = asyncCallback;
State = state;
DueTime = dueTime;
DuePeriod = period;
}
/// <summary>
/// Ticks the timer action within the activation context.
/// </summary>
public async Task TickAsync() => await await Task.Factory.StartNew(AsyncCallback, State, default, TaskCreationOptions.None, scheduler);
public void Dispose()
{
try
{
owner.Remove(this);
}
catch (Exception)
{
// noop
}
}
}
The FakeTimerRegistry
overrides the built-in timer registry and enables easier searching for timers from test code.
/// <summary>
/// Implements a fake timer registry to facilitate unit tests using the test cluster.
/// </summary>
public class FakeTimerRegistry : ITimerRegistry
{
/// <summary>
/// We dont have a ConcurrentHashSet yet so this does the job.
/// </summary>
private readonly ConcurrentDictionary<FakeTimerEntry, FakeTimerEntry> timers = new ConcurrentDictionary<FakeTimerEntry, FakeTimerEntry>();
/// <summary>
/// Registers a new fake timer entry and returns it.
/// Note how we are capturing the activation task scheduler to ensure we can tick the fake timers within the activation context.
/// </summary>
public IDisposable RegisterTimer(Grain grain, Func<object, Task> asyncCallback, object state, TimeSpan dueTime, TimeSpan period)
{
var timer = new FakeTimerEntry(this, TaskScheduler.Current, grain, asyncCallback, state, dueTime, period);
timers[timer] = timer;
return timer;
}
/// <summary>
/// Returns all fake timer entries.
/// </summary>
public IEnumerable<FakeTimerEntry> GetAll() => timers.Keys.ToList();
/// <summary>
/// Removes a timer.
/// </summary>
public void Remove(FakeTimerEntry entry) => timers.TryRemove(entry, out _);
}
Final Notes
To see everything running, go check out the Orleans Unit Testing Sample.
Any questions, let me know in the comments, or just pop over at the Orleans gitter channel.