November 22, 2013

Testing handlers/filters that access IDependencyScope in Web API

One improvement in ASP.NET Web API was the introduction of dependency scopes; a way to easily scope dependencies per request. Whilst this was easy enough in ASP.NET MVC it usually required HttpContext which would not work for self hosted APIs.

If you’ve implemented the IDependencyScope and IDependencyResolver interfaces correctly (see my StructureMap implementation) a nested or child container will be created and disposed per request, as are any dependencies it creates.

This makes dependency scopes ideal for implementing “session-per-request” or “unit of work” patterns. For example you may want to inject an NHibernate/EF/RavenDB “session” into your controllers and then commit/save all your changes at the end of the request using a filter or message handler.

For this to work you need to make sure that the filter/handler uses the same dependency scope, which often leads to to code like this:

public override void OnActionExecuted(HttpActionExecutedContext ctx)
{
    var session = ctx.Request.GetDependencyScope().GetService<ISession>();
}

This makes testing your filters/handlers a bit trickier since you’re now coupled directly to the dependency resolver. This is why it is a good idea to avoid such “poor man’s dependency resolution” where possible.

The solution I came up with is to provide a constructor overload that accepts a Func<HttpRequestMessage, ISomeService>. The default constructor can simply make use of request.GetDependencyScope():

public class RavenDbUnitOfWorkAttribute : ActionFilterAttribute
{
    private readonly Func<HttpRequestMessage, IDocumentSession> sessionFactory;

    public RavenDbUnitOfWorkAttribute()
        : this(request => request.GetDependencyScope().GetService<IDocumentSession>())
    {
    }

    public RavenDbUnitOfWorkAttribute(Func<HttpRequestMessage, IDocumentSession> sessionFactory)
    {
        Ensure.Argument.NotNull(sessionFactory, "sessionFactory");
        this.sessionFactory = sessionFactory;
    }
    
    public override void OnActionExecuted(HttpActionExecutedContext actionExecutedContext)
    {
        if (actionExecutedContext.Request.Method != HttpMethod.Get && actionExecutedContext.Exception == null)
        {
            var session = sessionFactory(actionExecutedContext.Request);

            if (session != null)
            {
                session.SaveChanges();
            }
        }

        base.OnActionExecuted(actionExecutedContext);
    }
}

This makes the filter very easy to test without having to mock calls to GetDependencyScope():

[TestFixture]
public class RavenDbUnitOfWorkAttributeTests
{
    RavenDbUnitOfWorkAttribute uow;
    IDocumentSession session;
  
    [SetUp]
    public void SetUp()
    {
        session = Substitute.For<IDocumentSession>();
        uow = new RavenDbUnitOfWorkAttribute(request => session);
    }

    [Test]
    public void Should_not_save_for_GET_requests()
    {
        ExecuteFilter(HttpMethod.Get);
        session.DidNotReceive().SaveChanges();
    }

    [Test]
    public void Should_not_save_if_an_exception_occurred()
    {
        ExecuteFilter(HttpMethod.Post, new Exception());
        session.DidNotReceive().SaveChanges();
    }

    [Test]
    public void Should_save_for_POST_requests()
    {
        ExecuteFilter(HttpMethod.Post);
        session.Received().SaveChanges();
    }

    [Test]
    public void Should_save_for_PUT_requests()
    {
        ExecuteFilter(HttpMethod.Put);
        session.Received().SaveChanges();
    }

    [Test]
    public void Should_save_for_PATCH_requests()
    {
        ExecuteFilter(new HttpMethod("PATCH"));
        session.Received().SaveChanges();
    }

    [Test]
    public void Should_save_for_DELETE_requests()
    {
        ExecuteFilter(HttpMethod.Delete);
        session.Received().SaveChanges();
    }

    private void ExecuteFilter(HttpMethod httpMethod, Exception exception = null)
    {
        var request = new HttpRequestMessage(httpMethod, "http://localhost/");
        var controllerContext = new HttpControllerContext
        {
            Request = request
        };

        var actionContext = new HttpActionContext(controllerContext, Substitute.For<HttpActionDescriptor>());
        var actionExecutedContext = new HttpActionExecutedContext(actionContext, exception);

        uow.OnActionExecuted(actionExecutedContext);
    }
}

© 2022 Ben Foster