Refactor nunit tests off EF InMemory provider and web server

Extract IMicCheckDbContext so DB access can be mocked with Moq instead
of the InMemory provider, per project testing guidelines. Add shared
async DbSet test doubles (TestSupport/) to back the mocks. Rewrite
HTTP-pipeline tests (WebApplicationFactory) to exercise controllers
and auth handlers directly instead of running the web server. Drop
DbContext/seeder tests that only made sense against a real provider,
and remove stale duplicate test files left over from a namespace move.
This commit is contained in:
2026-07-04 19:54:22 -07:00
parent 1556b486d2
commit b4a666ebf0
53 changed files with 1293 additions and 2340 deletions

View File

@@ -0,0 +1,56 @@
using System.Linq.Expressions;
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
using Moq;
namespace MicCheck.Api.Tests.Unit.TestSupport;
public static class MockDbSetFactory
{
public static Mock<DbSet<T>> Create<T>(List<T> data) where T : class
{
var queryable = data.AsQueryable();
var mockSet = new Mock<DbSet<T>>();
mockSet.As<IAsyncEnumerable<T>>()
.Setup(m => m.GetAsyncEnumerator(It.IsAny<CancellationToken>()))
.Returns(() => new TestAsyncEnumerator<T>(data.GetEnumerator()));
mockSet.As<IQueryable<T>>().Setup(m => m.Provider).Returns(() => new TestAsyncQueryProvider<T>(data.AsQueryable().Provider));
mockSet.As<IQueryable<T>>().Setup(m => m.Expression).Returns(() => data.AsQueryable().Expression);
mockSet.As<IQueryable<T>>().Setup(m => m.ElementType).Returns(queryable.ElementType);
mockSet.As<IQueryable<T>>().Setup(m => m.GetEnumerator()).Returns(() => data.GetEnumerator());
mockSet.Setup(m => m.Add(It.IsAny<T>())).Callback<T>(item => data.Add(item));
mockSet.Setup(m => m.AddRange(It.IsAny<IEnumerable<T>>())).Callback<IEnumerable<T>>(items => data.AddRange(items));
mockSet.Setup(m => m.Remove(It.IsAny<T>())).Callback<T>(item => data.Remove(item));
mockSet.Setup(m => m.RemoveRange(It.IsAny<IEnumerable<T>>())).Callback<IEnumerable<T>>(items =>
{
foreach (var item in items.ToList())
data.Remove(item);
});
return mockSet;
}
public static void SetupDbSet<T>(this Mock<IMicCheckDbContext> context, Expression<Func<IMicCheckDbContext, DbSet<T>>> selector, List<T> data)
where T : class =>
context.Setup(selector).Returns(Create(data).Object);
/// <summary>
/// Sets up a DbSet whose Add() simulates database-generated identity assignment (needed because
/// Moq does not run SaveChanges and entity Id properties are commonly init-only).
/// </summary>
public static void SetupDbSetWithGeneratedIds<T>(this Mock<IMicCheckDbContext> context, Expression<Func<IMicCheckDbContext, DbSet<T>>> selector, List<T> data)
where T : class
{
var idProperty = typeof(T).GetProperty("Id") ?? throw new InvalidOperationException($"{typeof(T).Name} has no Id property.");
var mockSet = Create(data);
mockSet.Setup(m => m.Add(It.IsAny<T>())).Callback<T>(item =>
{
idProperty.SetValue(item, data.Count + 1);
data.Add(item);
});
context.Setup(selector).Returns(mockSet.Object);
}
}

View File

@@ -0,0 +1,29 @@
using System.Collections;
using System.Linq.Expressions;
namespace MicCheck.Api.Tests.Unit.TestSupport;
public class TestAsyncEnumerable<T> : EnumerableQuery<T>, IAsyncEnumerable<T>, IQueryable<T>
{
public TestAsyncEnumerable(IEnumerable<T> enumerable) : base(enumerable) { }
public TestAsyncEnumerable(Expression expression) : base(expression) { }
public IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default) =>
new TestAsyncEnumerator<T>(this.AsEnumerable().GetEnumerator());
IQueryProvider IQueryable.Provider => new TestAsyncQueryProvider<T>(this);
}
public sealed class TestAsyncEnumerator<T>(IEnumerator<T> inner) : IAsyncEnumerator<T>
{
public T Current => inner.Current;
public ValueTask<bool> MoveNextAsync() => ValueTask.FromResult(inner.MoveNext());
public ValueTask DisposeAsync()
{
inner.Dispose();
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,43 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore.Query;
namespace MicCheck.Api.Tests.Unit.TestSupport;
public class TestAsyncQueryProvider<T>(IQueryProvider innerProvider) : IAsyncQueryProvider
{
public IQueryable CreateQuery(Expression expression) =>
new TestAsyncEnumerable<T>(StripIncludes(expression));
public IQueryable<TElement> CreateQuery<TElement>(Expression expression) =>
new TestAsyncEnumerable<TElement>(StripIncludes(expression));
public object? Execute(Expression expression) =>
innerProvider.Execute(StripIncludes(expression));
public TResult Execute<TResult>(Expression expression) =>
innerProvider.Execute<TResult>(StripIncludes(expression));
public TResult ExecuteAsync<TResult>(Expression expression, CancellationToken cancellationToken = default)
{
var stripped = StripIncludes(expression);
var resultType = typeof(TResult).GetGenericArguments() is [var inner] ? inner : typeof(TResult);
var executionResult = typeof(IQueryProvider)
.GetMethod(nameof(IQueryProvider.Execute), 1, [typeof(Expression)])!
.MakeGenericMethod(resultType)
.Invoke(innerProvider, [stripped]);
return (TResult)typeof(Task).GetMethod(nameof(Task.FromResult))!
.MakeGenericMethod(resultType)
.Invoke(null, [executionResult])!;
}
private static Expression StripIncludes(Expression expression) => new IncludeStripVisitor().Visit(expression);
private sealed class IncludeStripVisitor : ExpressionVisitor
{
protected override Expression VisitMethodCall(MethodCallExpression node) =>
node.Method.Name is "Include" or "ThenInclude"
? Visit(node.Arguments[0])
: base.VisitMethodCall(node);
}
}