Ivan
Ivan

Reputation: 789

Unit-testing of custom NLog filter class

While implementing custom NLog filter https://github.com/NLog/NLog/wiki/Filtering-log-messages#fully-dynamic-filtering I tried to rely on WhenMethodFilter, which accepts lambda callback ShouldLogEvent. However to unit test that callback lambda I need to make it public in the class that generates the config, which is not ideal - I hate making methods public just for the sake of testing.

    private void ReconfigureNlog(object state)
    {
        var nlogConfig = ConstructNlogConfiguration();
        foreach (var rule in nlogConfig.LoggingRules)
        {
            rule.Filters.Add(new WhenMethodFilter(ShouldLogEvent));
        }

        // TODO: maybe no need to reconfigure every time but just modify filter reference?
        NLog.Web.NLogBuilder.ConfigureNLog(nlogConfig);
    }

Another approach would be to inherit Filter base class and try to cover it with unit tests. But the issue that it does not have a public interface :

internal FilterResult GetFilterResult(LogEventInfo logEvent)
protected abstract FilterResult Check(LogEventInfo logEvent);

which makes it also not testable unless I make my own methods public. As much as this seems to be a small issue, I'm curious if there is a better way. From my perspective, making GetFilterResult internal is absolutely unnecessary, although it kinda following best design practices. Thoughts?

Update 1. Code of the class to be tested:

public class TenancyLogFilter: Filter
{
    private readonly ITenancyLoggingConfiguration _loggingConfigurationConfig;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public TenancyLogFilter(ITenancyLoggingConfiguration loggingConfigurationConfig, IHttpContextAccessor httpContextAccessor)
    {
        _loggingConfigurationConfig = loggingConfigurationConfig;
        _httpContextAccessor = httpContextAccessor;
    }

    protected override FilterResult Check(LogEventInfo logEvent)
    {
        var result = FilterResult.Neutral;

        if (CanEmitEvent(logEvent.Level))
        {
            result = FilterResult.Log;
        }

        return result;
    }

    private LogLevel GetMinLogLevel()
    {
        var level = LogLevel.Trace;
        if (!_loggingConfigurationConfig.TenantMinLoggingLevel.Any())
            return level;
        var context = _httpContextAccessor?.HttpContext;

        if (context == null)
            return level;


        if (context.Request.Headers.TryGetValue(CustomHeaders.TenantId, out var tenantIdHeaders))
        {
            var currentTenant = tenantIdHeaders.First();
            if (_loggingConfigurationConfig.TenantMinLoggingLevel.ContainsKey(currentTenant))
            {
                level = _loggingConfigurationConfig.TenantMinLoggingLevel[currentTenant];
            }
        }

        return level;
    }

    private bool CanEmitEvent(LogLevel currentLogLevel)
    {
        return currentLogLevel >= GetMinLogLevel();
    }
}

Upvotes: 2

Views: 228

Answers (2)

Nkosi
Nkosi

Reputation: 247133

For the purpose of testing the class you can derive from the subject class to expose what is needed to invoke the member under test since the target member is protected.

There is no way to change/access the internal member, but in this case the source shows it to be a simple implementation

/// <summary>
/// Gets the result of evaluating filter against given log event.
/// </summary>
/// <param name="logEvent">The log event.</param>
/// <returns>Filter result.</returns>
internal FilterResult GetFilterResult(LogEventInfo logEvent)
{
    return Check(logEvent);
}

Source

From there it is just a matter of providing the necessary dependencies that would allow the test to be exercised to completion.

For example.

[TestClass]
public class TenancyLogFilterTests {
    [TestMethod]
    public void Should_Log_LogEvent() {
        //Arrange            
        string expectedId = Guid.NewGuid().ToString();
        LogLevel expectedLevel = LogLevel.Error;
        FilterResult expected = FilterResult.Log;

        var context = new DefaultHttpContext();
        context.Request.Headers.Add(CustomHeaders.TenantId, expectedId);
        var accessor = Mock.Of<IHttpContextAccessor>(_ => _.HttpContext == context);

        var level = new Dictionary<string, LogLevel> {
            { expectedId, expectedLevel }
        };
        var config = Mock.Of<ITenancyLoggingConfiguration>(_ => _.TenantMinLoggingLevel == level);

        var subject = new TestTenancyLogFilter(config, accessor);

        var info = new LogEventInfo { Level = expectedLevel };

        //Act
        FilterResult actual = subject.GetFilterResult(info);

        //Assert - FluentAssertions
        actual.Should().Be(expected);
    }

    class TestTenancyLogFilter : TenancyLogFilter {
        public TestTenancyLogFilter(ITenancyLoggingConfiguration loggingConfigurationConfig, IHttpContextAccessor httpContextAccessor)
            : base(loggingConfigurationConfig, httpContextAccessor) { }

        public FilterResult GetFilterResult(LogEventInfo logEvent) {
            return Check(logEvent);
        }
    }
}

This allows the test to be isolated and also works around the limitation provided by the external 3rd party dependency.

The actual filter remains as before without having to expose anything additional.

From your original code example, take note that when faced with blocking issue like that, it should be see as a design issue and a sign to extract a service abstraction.

For example

public interface ILogEventAssessor {
    FilterResult GetFilterResult(LogEventInfo logEvent);
}

whose implementation would encapsulate what was done in the custom TenancyLogFilter filter, and injected into the target class

private readonly ILogEventAssessor service;

//...assumed injected service

private void ReconfigureNlog(object state) {
    var nlogConfig = ConstructNlogConfiguration();
    foreach (var rule in nlogConfig.LoggingRules) {
        rule.Filters.Add(new WhenMethodFilter(ShouldLogEvent));
    }

    // TODO: maybe no need to reconfigure every time but just modify filter reference?
    NLog.Web.NLogBuilder.ConfigureNLog(nlogConfig);
}

private FilterResult ShouldLogEvent(LogEventInfo logEvent) {
    return service.GetFilterResult(logEvent);
}

//...

Now there really is no need to test the 3rd party filter to verify your logic.

You can test your ILogEventAssessor implementation to verify your custom logic in isolation.

Upvotes: 2

Rolf Kristensen
Rolf Kristensen

Reputation: 19867

Usually the ugly trick is to make the method internal and then add this to your AssemblyInfo.cs in main-project:

using System;
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleToAttribute("Tenancy.UnitTests")]

Then the unit-test-project Tenancy.UnitTests will be allowed to unit-test the internal-methods of the main-project.

See also https://learn.microsoft.com/en-us/dotnet/api/system.runtime.compilerservices.internalsvisibletoattribute

Upvotes: 1

Related Questions