nsubstitute/NSubstitute

NSubstitute Received() verifies different function in substite.

jyrijh opened this issue · 2 comments

jyrijh commented

Code is calling _cache.SetStringAsync, but _cache.Received(1).SetStringAsync fails with error

Message: 
NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching:
	SetAsync("SearchBulletin{"Id":null,"ProgramId":null,"Title":null,"Creator":null,"CurrentUser":null,"IsAdmin":false,"StartTime":null,"EndTime":null}", Byte[], any DistributedCacheEntryOptions, System.Threading.CancellationToken)
Actually received no matching calls.
Received 1 non-matching call (non-matching arguments indicated with '*' characters):
	SetAsync("SearchBulletin{"Id":null,"ProgramId":null,"Title":null,"Creator":null,"CurrentUser":null,"IsAdmin":false,"StartTime":null,"EndTime":null}", *Byte[]*, DistributedCacheEntryOptions, System.Threading.CancellationToken)

So why am I seeing SetAsync, when I am testing for SetStringAsync, and calling SetStringAsync.
SetStringAsync internally calls SetAsync, but that is call from DistributedCacheExtensions

public static Task SetStringAsync(this IDistributedCache cache, string key, string value, DistributedCacheEntryOptions options, CancellationToken token = default(CancellationToken))
{
    ThrowHelper.ThrowIfNull(key);
    ThrowHelper.ThrowIfNull(value);

    return cache.SetAsync(key, Encoding.UTF8.GetBytes(value), options, token);
}

Test

private readonly IUnderlyingBulletinAdministrationRepository _repository;
private readonly IDistributedCache _cache;
private readonly CachedAdminstrationRepository _sut;
ILogger<CachedAdminstrationRepository> logger = Substitute.For<ILogger<CachedAdminstrationRepository>>();
CancellationToken ct = CancellationToken.None;

DistributedCacheEntryOptions cacheOptions = new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(1) };


public CachedAdminRepositoryBulletinSearchTests()
{
    _repository = Substitute.For<IUnderlyingBulletinAdministrationRepository>();
    _cache = Substitute.For<IDistributedCache>();
    _sut = new CachedAdminstrationRepository(_repository, _cache, logger);
}

[Fact]
public async Task BulletinSearch_GetsListOfBulletinsFromCache()
{
    // Arrange
    int id = 1;
    BulletinResponse expected = new BulletinResponse { Id = id, Otsikko = "test" };
    _repository.SearchBulletinAsync(Arg.Any<SearchBulletin>(), ct).Returns([expected]);
    string cacheKey = BulletinCache.Keys.SearchBulletin(new SearchBulletin());
    string cacheValue = JsonSerializer.Serialize<List<BulletinResponse>>([expected]);

    // Act
    _ = await _sut.SearchBulletinAsync(new SearchBulletin(), ct);

    // Assert
    await _cache.Received(1).GetStringAsync(cacheKey, ct);
    await _cache.Received(1).SetStringAsync(cacheKey, cacheValue, Arg.Any<DistributedCacheEntryOptions>(), ct);
}

Cache implementation

public async Task<List<BulletinResponse>> SearchBulletinAsync(SearchBulletin search, CancellationToken ct)
{
    var key = BulletinCache.Keys.SearchBulletin(search);

    return await GetOrSetCacheAsync(
        key,
        token => _repository.SearchBulletinAsync(search, token),
        ct);
}

private async Task<T> GetOrSetCacheAsync<T>(string key, Func<CancellationToken, Task<T>> getActual, CancellationToken ct)
{
    var cacheResult = await _cache.GetStringAsync(key, ct);
    if (string.IsNullOrEmpty(cacheResult))
    {
        _logger.LogInformation("Cache miss for key {key}", key);

        var result = await getActual(ct);
        await _cache.SetStringAsync(
            key,
            JsonSerializer.Serialize(result),
            new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(_expirationMinutes) },
            ct);

        return result;
    }
    else
    {
        _logger.LogInformation("Cache hit for key {key}", key);
        return JsonSerializer.Deserialize<T>(cacheResult)!;
    }
}

Hi @jyrijh
Thanks for raising the issue.
That happens because you are checking received calls to a static method, SetStringAsync in your case.
NSubstitute works with virtual members only and doesn't "see" static methods. That's why it picked the next virtual call which is SetAsync in your case. Please see the docs that explain that in details.
We strongly recommend using NSubstitute.Analyzers to detect these cases.
Let us know if it doesn't help.

jyrijh commented

Thanks, now this makes sense. Lesson learned. Don't try to check if an extension method is called 😖. Unless that is what you want to happen