在单元测试中模拟IMemoryCache

时间:2016-07-12 00:35:38

标签: c# unit-testing mocking asp.net-core moq

我正在使用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
}

这是课程测试...

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被模拟时,默认值是什么?

5 个答案:

答案 0 :(得分:11)

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

虽然扩展程序的代码可用here

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
    //...
}

现在您可以访问功能完整的内存缓存。

答案 1 :(得分:3)

TLDR

向下滚动到代码段以间接模拟缓存设置器(具有不同的expiry属性)

/ TLDR

虽然确实不能使用Moq或大多数其他模拟框架直接 模拟扩展方法,但通常可以间接模拟这些扩展方法-对于那些内置的扩展程序,确实如此在IMemoryCache

附近

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

Nkosi's answer提出了非常有效的观点:它会很快变得复杂,您可以使用具体的实现来测试事物。这是一种完全有效的使用方法。但是,严格来说,如果您走这条路,您的测试将取决于第三方代码的实现。从理论上讲,对此所做的更改有可能破坏您的测试-在这种情况下,由于caching存储库已被存档,因此这种情况极不可能发生。

此外,有可能使用带有一堆依赖项的具体实现可能会涉及很多开销。如果您每次都创建一组干净的依赖项并且您进行了许多测试,那么这可能会给构建服务器增加相当大的负担(我并不是说这里就是这种情况,这取决于许多因素)

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

对于正在调用的扩展方法,您只需要三个带有回调的setup调用即可对调用参数进行断言。这可能不适合您,具体取决于您要测试的内容。

[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("key", keyPayload);
    Assert.Equal("expectedValue", valuePayload as string);
    Assert.Equal(expirationPayload, TimeSpan.FromMilliseconds(expectedMilliseconds));
}

答案 2 :(得分:1)

我有一个类似的问题,但我想偶尔禁用缓存进行调试,因为它不得不清除缓存。只需自己模拟/伪造它们(使用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()
    {

    }
}

答案 3 :(得分:0)

这可以通过模拟IMemoryCache的TryGetValue方法而不是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);

答案 4 :(得分:0)

我也在 .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);
            });