在单元测试中模拟 IMemoryCache

52

我正在使用 asp net core 1.0 和 xunit。

我尝试编写一个单元测试来测试使用 IMemoryCache 的代码。然而,每当我尝试在 IMemoryCache 中设置一个值时,我会收到 Null 引用错误。

我的单元测试代码如下:
IMemoryCache 被注入到我想要测试的类中。但是,当我尝试在测试中设置缓存中的值时,我遇到了空引用错误。

public Test GetSystemUnderTest()
{
    var mockCache = new Mock<IMemoryCache>();

    return new Test(mockCache.Object);
}

[Fact]
public void TestCache()
{
    var sut = GetSystemUnderTest();

    sut.SetCache("key", "value"); //NULL Reference thrown here
}

这是一个名为Test的类...

public class Test
{
    private readonly IMemoryCache _memoryCache;
    public Test(IMemoryCache memoryCache)
    {
        _memoryCache = memoryCache;
    }

    public void SetCache(string key, string value)
    {
        _memoryCache.Set(key, value, new MemoryCacheEntryOptions {SlidingExpiration = TimeSpan.FromHours(1)});
    }
}

我的问题是...我需要设置IMemoryCache吗?为DefaultValue设置一个值吗?当IMemoryCache被模拟时,它的默认值是什么?


1
异常是因为sut为空引起的,还是在执行_memoryCache.Set调用时在setcache方法内抛出的? - Macilquham
我遇到了这个问题,但最终我使用了以下链接: https://dev59.com/TVkR5IYBdhLWcg3w4hDN#40685073 - Hypenate
7个回答

55

IMemoryCache.Set 是一个扩展方法,因此无法使用 Moq 框架进行模拟。

但是,可以在此处找到该扩展的代码。

public static TItem Set<TItem>(this IMemoryCache cache, object key, TItem value, MemoryCacheEntryOptions options)
{
    using (var entry = cache.CreateEntry(key))
    {
        if (options != null)
        {
            entry.SetOptions(options);
        }

        entry.Value = value;
    }

    return value;
}

为了进行测试,需要通过扩展方法模拟一个安全路径,使其能够顺利完成。在Set中,它还调用缓存条目的扩展方法,因此也必须加以考虑。这可能会很快变得复杂,因此建议使用具体实现。

//...
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
//...

public Test GetSystemUnderTest() {
    var services = new ServiceCollection();
    services.AddMemoryCache();
    var serviceProvider = services.BuildServiceProvider();

    var memoryCache = serviceProvider.GetService<IMemoryCache>();
    return new Test(memoryCache);
}

[Fact]
public void TestCache() {
    //Arrange
    var sut = GetSystemUnderTest();

    //Act
    sut.SetCache("key", "value");

    //Assert
    //...
}

现在您可以访问一个完全功能的内存缓存。


我需要调用哪个库来使用Mock<Object>方法? - TTCG
@TTCG 请查看Moq库 - Github, nuget - Nkosi
@Nkosi,你能告诉我如何创建IDistributedCache的模拟吗? - Ask
@为了测试和开发目的,您可以使用services.AddDistributedMemoryCache();代替。参考https://learn.microsoft.com/en-us/aspnet/core/performance/caching/distributed?view=aspnetcore-2.1#distributed-memory-cache - Nkosi
尝试在Core 3.1中使用此方法,但出现错误“'IServiceCollection'不包含定义为'AddMemoryCahce'的内容,也没有可访问的扩展方法...”。有人知道如何对使用IMemoryCache的扩展方法的方法进行单元测试吗? - Martijn
3
不需要创建新的“ServiceCollection”并进行所有依赖注入的操作,你可以模拟“IOptions<MemoryCacheOptions>”,确保其返回一个包含“SystemClock”对象的新实例的“MemoryCacheOptions”。以下是代码片段(请注意,我使用的是NSubstitute而不是Moq):var cacheOptions = Substitute.For<IOptions<MemoryCacheOptions>>();cacheOptions.Value.Returns(new MemoryCacheOptions() { Clock = new SystemClock() });var cache = new MemoryCache(cacheOptions); // 您可以使用此方法。 - NavidM

13

简短摘要

向下滚动到代码片段,以间接模拟缓存设置器(使用不同的过期属性)

/简短摘要

虽然扩展方法不能直接使用Moq或大多数其他模拟框架进行模拟,但通常它们可以通过间接方式进行模拟 - 对于围绕IMemoryCache构建的方法来说,这肯定是正确的。

正如我在this answer中指出的那样,从根本上讲,所有扩展方法都在其执行过程中调用三个接口方法之一。

Nkosi's 答案提出了非常有价值的观点:它可能会很快变得复杂,您可以使用具体实现来测试事物。这是一种完全有效的方法。然而,严格来说,如果您选择这条路,您的测试将依赖于第三方代码的实现。理论上,更改可能会破坏您的测试 - 在这种情况下,这种情况高度不太可能发生,因为缓存存储库已被归档。

此外,使用具体实现和一堆依赖项可能涉及很多开销。如果您每次都创建一个干净的依赖项集,并且您有许多测试,则可能会给构建服务器增加相当大的负载(我并不是说在这种情况下会发生这种情况,这取决于许多因素)

最后,您失去了另一个好处:通过自己调查源代码以模拟正确的内容,您更有可能了解所使用的库的工作原理。因此,您可能会更好地使用它,并且几乎肯定会学到其他东西。

对于您调用的扩展方法,您只需要三个设置调用和回调来断言调用参数。这可能不适合您,具体取决于您要测试什么。

[Fact]
public void TestMethod()
{
    var expectedKey = "expectedKey";
    var expectedValue = "expectedValue";
    var expectedMilliseconds = 100;
    var mockCache = new Mock<IMemoryCache>();
    var mockCacheEntry = new Mock<ICacheEntry>();

    string? keyPayload = null;
    mockCache
        .Setup(mc => mc.CreateEntry(It.IsAny<object>()))
        .Callback((object k) => keyPayload = (string)k)
        .Returns(mockCacheEntry.Object); // this should address your null reference exception

    object? valuePayload = null;
    mockCacheEntry
        .SetupSet(mce => mce.Value = It.IsAny<object>())
        .Callback<object>(v => valuePayload = v);

    TimeSpan? expirationPayload = null;
    mockCacheEntry
        .SetupSet(mce => mce.AbsoluteExpirationRelativeToNow = It.IsAny<TimeSpan?>())
        .Callback<TimeSpan?>(dto => expirationPayload = dto);

    // Act
    var success = _target.SetCacheValue(expectedKey, expectedValue,
        new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMilliseconds(expectedMilliseconds)));

    // Assert
    Assert.True(success);
    Assert.Equal(expectedKey, keyPayload);
    Assert.Equal(expectedValue, valuePayload as string);
    Assert.Equal(TimeSpan.FromMilliseconds(expectedMilliseconds), expirationPayload);
}

我本来想发一个类似的帖子,但是看到了你更详细的答案。对我来说,使用mockCacheEntry.Object模拟IMemoryCache上的CreateEntry方法就足够了。这解决了调用Set扩展方法时可能抛出的NullReferenceException问题。 - Hanno

9
public sealed class NullMemoryCache : IMemoryCache
{
    public ICacheEntry CreateEntry(object key)
    {
        return new NullCacheEntry() { Key = key };
    }

    public void Dispose()
    {            
    }

    public void Remove(object key)
    {
        
    }

    public bool TryGetValue(object key, out object value)
    {
        value = null;
        return false;
    }

    private sealed class NullCacheEntry : ICacheEntry
    {
        public DateTimeOffset? AbsoluteExpiration { get; set; }
        public TimeSpan? AbsoluteExpirationRelativeToNow { get; set; }

        public IList<IChangeToken> ExpirationTokens { get; set; }

        public object Key { get; set; }

        public IList<PostEvictionCallbackRegistration> PostEvictionCallbacks { get; set; }

        public CacheItemPriority Priority { get; set; }
        public long? Size { get; set; }
        public TimeSpan? SlidingExpiration { get; set; }
        public object Value { get; set; }

        public void Dispose()
        {
            
        }
    }
}

我学过大多数的自动 Moq 解决方案,它们在大多数情况下都做得很好。但在某些情况下,你只需要制造自己的模拟对象并传递它。这是一个很好的例子和解决方案。 - Kevin B Burns

4

我之前也遇到过类似的问题,但是有时为了调试需要禁用缓存,而频繁清除缓存很麻烦。你可以自己模拟/伪造它们(使用StructureMap依赖注入)。

这样你也可以很容易地在测试中使用它们。

public class DefaultRegistry: Registry
{
    public static IConfiguration Configuration = new ConfigurationBuilder()
        .SetBasePath(HttpRuntime.AppDomainAppPath)
        .AddJsonFile("appsettings.json")
        .Build();

    public DefaultRegistry()
    {
        For<IConfiguration>().Use(() => Configuration);  

#if DEBUG && DISABLE_CACHE <-- compiler directives
        For<IMemoryCache>().Use(
            () => new MemoryCacheFake()
        ).Singleton();
#else
        var memoryCacheOptions = new MemoryCacheOptions();
        For<IMemoryCache>().Use(
            () => new MemoryCache(Options.Create(memoryCacheOptions))
        ).Singleton();
#endif
        For<SKiNDbContext>().Use(() => new SKiNDbContextFactory().CreateDbContext(Configuration));

        Scan(scan =>
        {
            scan.TheCallingAssembly();
            scan.WithDefaultConventions();
            scan.LookForRegistries();
        });
    }
}

public class MemoryCacheFake : IMemoryCache
{
    public ICacheEntry CreateEntry(object key)
    {
        return new CacheEntryFake { Key = key };
    }

    public void Dispose()
    {

    }

    public void Remove(object key)
    {

    }

    public bool TryGetValue(object key, out object value)
    {
        value = null;
        return false;
    }
}

public class CacheEntryFake : ICacheEntry
{
    public object Key {get; set;}

    public object Value { get; set; }
    public DateTimeOffset? AbsoluteExpiration { get; set; }
    public TimeSpan? AbsoluteExpirationRelativeToNow { get; set; }
    public TimeSpan? SlidingExpiration { get; set; }

    public IList<IChangeToken> ExpirationTokens { get; set; }

    public IList<PostEvictionCallbackRegistration> PostEvictionCallbacks { get; set; }

    public CacheItemPriority Priority { get; set; }
    public long? Size { get; set; }

    public void Dispose()
    {

    }
}

2

我在一个 .Net 5 项目中也遇到了这个问题,我通过封装内存缓存并仅公开所需的功能来解决它。这样我符合 ISP 原则,并且更容易与我的单元测试一起使用。

我创建了一个接口

public interface IMemoryCacheWrapper
{
    bool TryGetValue<T>(string Key, out T cache);
    void Set<T>(string key, T cache);
}

我在我的包装类中使用了MS依赖注入实现了内存缓存逻辑,这样我就不需要依赖于我的测试类中的那些实现细节,此外还有遵循SRP的额外好处。

public class MemoryCacheWrapper : IMemoryCacheWrapper
{
    private readonly IMemoryCache _memoryCache;

    public MemoryCacheWrapper(IMemoryCache memoryCache)
    {
        _memoryCache = memoryCache;
    }

    public void Set<T>(string key, T cache)
    {
        _memoryCache.Set(key, cache);
    }

    public bool TryGetValue<T>(string Key, out T cache)
    {
        if (_memoryCache.TryGetValue(Key, out T cachedItem))
        {
            cache = cachedItem;
            return true;
        }
        cache = default(T);
        return false;
    }
}

我将我的内存缓存包装器添加到依赖注入中,并用包装器替换了我的代码中的系统内存缓存,在测试中我模拟了它。总的来说,这是一个相对快速的工作,而且我认为这样做的结构更好。
在我的测试中,我添加了这个内容,以模仿缓存更新。
        _memoryCacheWrapperMock = new Mock<IMemoryCacheWrapper>();
        _memoryCacheWrapperMock.Setup(s => s.Set(It.IsAny<string>(), It.IsAny<IEnumerable<IClientSettingsDto>>()))
            .Callback<string, IEnumerable<IClientSettingsDto>>((key, cache) =>
            {
                _memoryCacheWrapperMock.Setup(s => s.TryGetValue(key, out cache))
                    .Returns(true);
            });

0

我通过创建一个包装类来解决了这个问题

public interface IMemoryCacheService<TKey, TValue>
{       
      TValue AddOrUpdate(TKey key, TValue value, TimeSpan expiration);
      TValue TryGetValue(TKey key);
}
public class MemoryCacheService<TKey, TValue> : IMemoryCacheService<TKey, TValue>
{

    private readonly IMemoryCache _cache;

    private readonly object _lock = new object();

    public MemoryCacheService(IMemoryCache cache)
    {
        if (cache == null)
        {
            throw new ArgumentNullException(nameof(cache));
        }

        _cache = cache;
    }

    public TValue AddOrUpdate(TKey key, TValue value, TimeSpan expiration)
    {
        if (key == null)
        {
            throw new ArgumentNullException(nameof(key));
        }

        lock (_lock)
        {
            return _cache.Set(key, value, expiration);
        }
    }
    public TValue TryGetValue(TKey key)
    {
        if (_cache.TryGetValue(key, out TValue value))
        {
            return value;
        }

        return default;
    }
}

在我的单元测试课上比在其他课程中更多
 private readonly IMemoryCacheService<string, MyType> _memoryCacheService = A.Fake<IMemoryCacheService<string, MyType>>();

最后在测试中

    [Fact]
    public async Task GetCache_ShouldMockCaching_GivenValidCache()
    {
        A.CallTo(() => _memoryCacheService.TryGetValue(A<string>._)).ReturnsNextFromSequence(myMockupsCaching.ToArray());
    }

-1

可以通过对IMemoryCache的TryGetValue方法进行模拟来实现,而不是Set方法(正如先前提到的那样,Set方法是扩展方法,因此无法进行模拟)。

  var mockMemoryCache = Substitute.For<IMemoryCache>();
  mockMemoryCache.TryGetValue(Arg.Is<string>(x => x.Equals(key)), out string expectedValue)
                .Returns(x =>
                {
                    x[1] = value;
                    return true;
                });

  var converter = new sut(mockMemoryCache);

1
TryGetValue也是来自于Microsoft.Extensions.Caching.Memory.CacheExtensions的一个扩展方法。也许你所说的重载版本是TryGetValue(object key, out object value)。 - Jhonny D. Cano -Leftware-

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接