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.
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:
- Mock
IEfCoreScopeProvider
andIEfCoreScope
- 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! 😊