Remember: 🔴 Red, 🟢 Green, ♻️ Refactor

Umbraco has support for EF Core since Umbraco 12. EF Core is a convenient tool to work with custom database tables. I happened to be in need of that, so I took a dive into EF Core and Umbraco to see if I could use them with Test Driven Development.

  1. Exploring EF Core & Umbraco
  2. My approach and why it was a mistake
  3. What would I recommend now?

Exploring EF Core & Umbraco

Umbraco's documentation on entity framework core is a good place to start. It shows you just enough to get some working example code. However, Umbraco doesn't really give any recommendations beyond the bare minimum. I had some questions still, after reading through the documentation:

  • How does Umbraco expect me to test with DbContext?
  • Where does Umbraco expect me to create this "scope"? In the outermost layer in a controller for example, or in the innermost layer inside my business logic?
  • What is Umbraco's expectation from me for passing the DbContext to different parts of my application? Through method parameters or exclusively with dependency injection?

Although I don't want Umbraco to tell me how to use EF Core, I would appreciate some best practices on Umbraco's wrapper types: the IScopeProvider and IScopeAccessor.

My approach and why it was a mistake

I based my inspiration on the example from their documentation:

[HttpGet("all")]
public async Task<IActionResult> All()
{
    using IEfCoreScope<BlogContext> scope = _efCoreScopeProvider.CreateScope();
    IEnumerable<BlogComment> comments = await scope.ExecuteWithContextAsync(async db => db.BlogComments.ToArray());
    scope.Complete();
    return Ok(comments);
}

At the time that I started, I saw two options to use this with Test Driven Development:

  1. Mock IEfCoreScopeProvider and IEfCoreScope
  2. Abstract Umbraco's types along with the DbContext behind a different interface.

I chose to abstract everything behind an interface because the thought of mocking Umbraco's scope provider and scope scared me and an abstraction seemed to be easier to test. Umbraco's scopes felt like they added a lot of overhead over time as well: I didn't really want to create scopes in all places where I needed to use the DbContext. In the end I regret this choice and I'll explain why:

In my domain, I manage "topics". It started easy: I need to fetch a topic by its ID. I do not want to depend on my context directly, so I create an abstraction:

public interface ITopicStore
{
    Task<TopicDTO?> GetAsync(int id);
}

A test could easily implement ITopicStore with an in-memory list and it would pretty much work just like that.

But the trouble starts brewing quickly:

I want to also fetch the topic by a slug

public interface ITopicStore
{
    Task<TopicDTO?> GetAsync(int id);
    Task<TopicDTO?> GetAsync(string slug);
}

I need to show a summary of the topic in a related place

public interface ITopicStore
{
    Task<TopicDTO?> GetAsync(int id);
    Task<TopicDTO?> GetAsync(string slug);
    Task<TopicSummary?> GetSummaryAsync(int id);
}

I need to get a list of topics that is paginated

public interface ITopicStore
{
    Task<TopicDTO?> GetAsync(int id);
    Task<TopicDTO?> GetAsync(string slug);
    Task<TopicSummary?> GetSummaryAsync(int id);
    Task<IEnumerable<TopicSummary>> GetSummaryPaginatedAsync(int skip, int take);
}

And final example: I need to get all topics that the current user is following

public interface ITopicStore
{
    Task<TopicDTO?> GetAsync(int id);
    Task<TopicDTO?> GetAsync(string slug);
    Task<TopicSummary?> GetSummaryAsync(int id);
    Task<IEnumerable<TopicSummary>> GetSummaryPaginatedAsync(int skip, int take);
    Task<IEnumerable<TopicSummary>> GetSummaryWhereUserFollows(int userid);
}

The point is: I need to ask the database many different questions and if I completely abstract the DbContext behind an interface, I end up with a massive interface that is incredibly unreadable (At the time of writing, my interface has 18 different methods!!).

What would I recommend now?

In hindsight, I would actually say that Umbraco's example code is the best place to start and then extract your business logic into a separate object. I'm personally quite fond of pairing up controllers with "request handlers". That is: Controllers are responsible for validating input and sending output, while a complementing request handler executes the logic of a request. In short:

  • Do not abstract DbContext behind an interface
  • Do not depend on IEfCoreScopeProvider in your business logic. Push your dependency on Umbraco to the edge of your system, keep it in your controller for example.
  • Use a real database. TestContainers works very well.

Test driven development becomes quite easy with this setup. It allows you to build and test your code as if Umbraco's wrapping layer doesn't exist. Given a "request handler" class, you might produce a test like this for example:

public class BlogRequestHandlerTests
{
    // ... necessary set up here to create instances of `DbContext`

    [Fact]
    public async Task ShouldFindCommentsByBlogId()
    {
        // given
        var sut = new BlogDetailPageRequestHandler();
        var blogWithComments = CreateBlog().WithComment("Test comment");
        using (var context = new BlogContext(this.ContextOptions))
        {
            await context.Blogs.AddAsync(blogWithComments);
            await context.SaveChangesAsync();
        }

        // when
        IEnumerable<BlogComment>? result = null;
        using (var context = new BlogContext(this.ContextOptions))
        {
            // 👇 Notice that I'm passing the DbContext as a parameter to the method
            result = await sut.GetCommentsByBlogIdAsync(context, blogWithComments.Id);
        }

        // then
        Assert.NotNull(result);
        Assert.Single(result);
    }
}

And the implementation to make the test pass:

public class BlogDetailPageRequestHandler
{
    public Task<IEnumerable<BlogComment>> GetCommentsByBlogIdAsync(BlogContext context, int blogId, CancellationToken cancellationToken = default)
    {
        return context.Blogs
            .AsNoTracking()
            .Where(blog => blog.Id == blogId)
            .SelectMany(blog => blog.Comments)
            .ToListAsync(cancellationToken);
    }
}

These tests are easy to write and still acceptable to read. I also have control over the lifetime of the DbContext throughout my test.

Once your business logic is done, connecting this to a controller is as simple as copy/pasting Umbraco's example code:

public class BlogDetailPageController(
    BlogDetailPageRequestHandler requestHandler,
    IEfCoreScopeProvider<BlogContext> efCoreScopeProvider)
    : RenderController
{
    public async Task<IActionResult> BlogDetailPageAsync(CancellationToken cancellationToken)
    {
        // 👇 For simplicity of the example, I only produce the "comments" in the request handler 
        // In reality, I would produce the entire `BlogDetailPageViewModel` inside the request handler
        using IEfCoreScope<BlogContext> scope = efCoreScopeProvider.CreateScope();
        var comments = await scope.ExecuteWithContextAsync(context => requestHandler.GetCommentsByBlogIdAsync(context, CurrentPage.BlogId, cancellationToken));
        scope.Complete();
        return CurrentTemplate(new BlogDetailPageViewModel(CurrentPage, comments));
    }
}

Final words

It was an interesting experience. I'm sad that I hadn't heard of TestContainers earlier. If I had, I would've approached this way differently as opposed to what I have done now. I have learned a lot from this and I feel like a better developer now, which also counts for something.

I hope you found this interesting to read, I hope you learned something from my mistake and maybe you'll stick around for my next blog! 😊