Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HybridCache: Unit Testing Support #5763

Open
sdepouw opened this issue Jan 2, 2025 · 0 comments
Open

HybridCache: Unit Testing Support #5763

sdepouw opened this issue Jan 2, 2025 · 0 comments

Comments

@sdepouw
Copy link
Contributor

sdepouw commented Jan 2, 2025

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 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.)

using Microsoft.Extensions.Caching.Hybrid;
using NSubstitute;
using NSubstitute.Core;

namespace Example;

/// <summary>
/// Extension methods to make testing <see cref="HybridCache" /> easier in NSubstitute
/// </summary>
public static class NSubstituteHybridCacheExtensions
{
  public static ConfiguredCall SetupGetOrCreateAsync(this HybridCache mockCache, string key, string expectedValue)
  {
    return mockCache.GetOrCreateAsync(
      key,
      Arg.Any<object>(),
      Arg.Any<Func<object, CancellationToken, ValueTask<string>>>(),
      Arg.Any<HybridCacheEntryOptions?>(), 
      Arg.Any<IEnumerable<string>?>(), 
      Arg.Any<CancellationToken>()
    ).Returns(expectedValue);
  }
  
  public static async Task AssertGetOrCreateAsyncCalledAsync(this HybridCache mockCache, string key, int requiredNumberOfCalls)
  {
    await mockCache.Received(requiredNumberOfCalls).GetOrCreateAsync(
      key,
      Arg.Any<object>(),
      Arg.Any<Func<object, CancellationToken, ValueTask<string>>>(),
      null,
      null,
      Arg.Any<CancellationToken>()
    );
  }
}

And my unit test code has something like this:

using FluentAssertions;
using Microsoft.Extensions.Caching.Hybrid;
using NSubstitute;
using XUnit;

namespace UnitTesting;

public class MyTests
{
  private readonly HybridCache _mockCache = Substitute.For<HybridCache>();

  [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.

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<string, object?> _cache = new();
  
  public override async ValueTask<T> GetOrCreateAsync<TState, T>(string key, TState state, Func<TState, CancellationToken, ValueTask<T>> factory, HybridCacheEntryOptions? options = null,
    IEnumerable<string>? 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<T>(string key, T value, HybridCacheEntryOptions? options = null, IEnumerable<string>? 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();
  }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant