dotnet / dotnet/extensions

HybridCache: Unit Testing Support

Open
#5,763 5 comments 14 reactions 0 assignees View on GitHub
area-caching-hybrid
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.