HybridCache: Unit Testing Support
- Dominant language
- C#
- Stars
- 3.2k
- Forks
- 894
- Avg merge
- 1d 12h
- Merged PRs (30d)
- 23
Description
Currently, writing tests with the `HybridCache` abstraction is a little clunky. Essentially, you have to mock out the more complex `GetOrCreateAsync()` method. While this isn't hard to write a simple set of extensions to do so (which I'll include below), having something akin to what `TimeProvider` has, [Microsoft.Extensions.TimeProvider.Testing](https://www.nuget.org/packages/Microsoft.Extensions.TimeProvider.Testing) that provides a `FakeTimeProvider`, would be most welcome.
I don't foresee a hypothetical `FakeHybridCache` having to do much, as the only contracted method is `GetOrCreateAsync()`. There's no need to add support for building out fake memory / distributed caches to control cache hits/misses. Rather, having a simple way to control what gets returned when called with a specific key (or tags perhaps too) would be all that's required.
Here's a little something I whipped up, using `NSubstitute`, to support both "I want to mock some fake return data from my cache" and "I want to assert my cache was called / used." Obviously this is quick and dirty and I just pass `null` or "any argument allowed" for the majority of things, but you can see where something like this would be beneficial in an official capacity. (Not strictly for `NSubstitute`, mind; methods that simply let us set up fake data or test that calls happened (a simple counter) would be all that's needed.)
```csharp
using Microsoft.Extensions.Caching.Hybrid;
using NSubstitute;
using NSubstitute.Core;
namespace Example;
///
/// Extension methods to make testing easier in NSubstitute
///
public static class NSubstituteHybridCacheExtensions
{
public static ConfiguredCall SetupGetOrCreateAsync(this HybridCache mockCache, string key, string expectedValue)
{
return mockCache.GetOrCreateAsync(
key,
Arg.Any(),
Arg.Any>>(),
Arg.Any(),
Arg.Any?>(),
Arg.Any()
).Returns(expectedValue);
}
public static async Task AssertGetOrCreateAsyncCalledAsync(this HybridCache mockCache, string key, int requiredNumberOfCalls)
{
await mockCache.Received(requiredNumberOfCalls).GetOrCreateAsync(
key,
Arg.Any(),
Arg.Any>>(),
null,
null,
Arg.Any()
);
}
}
```
And my unit test code has something like this:
```csharp
using FluentAssertions;
using Microsoft.Extensions.Caching.Hybrid;
using NSubstitute;
using XUnit;
namespace UnitTesting;
public class MyTests
{
private readonly HybridCache _mockCache = Substitute.For();
[Fact]
public async Task Something()
{
// Mocking a return value
_mockCache.SetupGetOrCreateAsync("some-key", expectedValue);
// Asserting the cache was called
await _mockCache.AssertGetOrCreateAsyncCalledAsync("some-key", 1);
}
}
```
There's tons to improve here, but here's a super basic not-fully-implemented `FakeHybridCache` that could be a jumping off point.
```csharp
using Microsoft.Extensions.Caching.Hybrid;
namespace Microsoft.Extensions.Caching.Hybrid.Testing;
public class FakeHybridCache : HybridCache
{
// Not sure if this should be static, nor do I know what to do about Tags
// Could make public since it's a fake cache anyway, and that could be useful for testing purposes
private readonly Dictionary _cache = new();
public override async ValueTask GetOrCreateAsync(string key, TState state, Func> factory, HybridCacheEntryOptions? options = null,
IEnumerable? tags = null, CancellationToken cancellationToken = default)
{
bool cached = _cache.TryGetValue(key, out object? value);
if (cached) return (T?)value!;
_cache.Add(key, await factory(state, cancellationToken));
return (T?)_cache[key]!;
}
public override ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, IEnumerable? tags = null,
CancellationToken cancellationToken = default)
{
_cache[key] = value;
return ValueTask.CompletedTask;
}
public override ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default)
{
_cache.Remove(key);
return ValueTask.CompletedTask;
}
public override ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
}
```
Contributor guide
Assessment
This issue has not been assessed yet.