Repository abstractions exist specifically to enable testing without a real database. A service that depends on IPostRepository can be tested with a mock (Mock<IPostRepository>) that returns predefined data — making tests instant, deterministic, and free of database setup. Deeper integration tests can use EF Core’s InMemory provider or a real LocalDB database. Understanding which test approach is appropriate for which scenario, and where each sits in the test pyramid, prevents the common mistake of over-mocking (never testing the real SQL) or under-mocking (slow integration tests for simple business logic).
Unit Testing with Moq
// ── Unit test for PostService using mock IPostRepository ──────────────────
public class PostServiceTests
{
private readonly Mock<IPostRepository> _mockRepo;
private readonly PostService _sut;
public PostServiceTests()
{
_mockRepo = new Mock<IPostRepository>();
_sut = new PostService(_mockRepo.Object,
Mock.Of<ILogger<PostService>>());
}
[Fact]
public async Task GetByIdAsync_ExistingPost_ReturnsDto()
{
// Arrange
var post = Post.Create("Test Title", "test-title", "A".PadRight(50), "user-1");
_mockRepo.Setup(r => r.GetByIdAsync(42, It.IsAny<CancellationToken>()))
.ReturnsAsync(post);
// Act
var result = await _sut.GetByIdAsync(42, CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.Equal("Test Title", result.Title);
_mockRepo.Verify(r => r.GetByIdAsync(42, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CreateAsync_DuplicateSlug_ThrowsConflictException()
{
// Arrange
_mockRepo.Setup(r => r.SlugExistsAsync("taken-slug", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var request = new CreatePostRequest
{
Title = "Test", Body = "A".PadRight(50), Slug = "taken-slug",
};
// Act & Assert
await Assert.ThrowsAsync<ConflictException>(
() => _sut.CreateAsync(request, "user-1", CancellationToken.None));
// Verify AddAsync was never called (exception before it)
_mockRepo.Verify(r => r.AddAsync(It.IsAny<Post>(), It.IsAny<CancellationToken>()),
Times.Never);
}
}
public abstract class ServiceTestBase { protected Mock<IUnitOfWork> MockUow = new(); protected Mock<ILogger<T>> MockLogger<T>() => Mock.Of<ILogger<T>>(); }. Test classes inherit from this and override specific mock setups. Also consider using AutoFixture or Bogus for generating test data — var post = _fixture.Create<Post>() is faster than writing object construction code for every test.TestContainers.Integration Tests with InMemory Provider
// ── EF Core InMemory integration test ─────────────────────────────────────
public class PostRepositoryTests : IDisposable
{
private readonly AppDbContext _db;
private readonly PostRepository _sut;
public PostRepositoryTests()
{
var opts = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) // unique DB per test
.Options;
_db = new AppDbContext(opts);
_sut = new PostRepository(_db);
}
[Fact]
public async Task AddAsync_ValidPost_AssignsId()
{
var post = Post.Create("Title", "slug", "Body text here and more.", "user-1");
var added = await _sut.AddAsync(post);
Assert.True(added.Id > 0);
}
[Fact]
public async Task SlugExistsAsync_ExistingSlug_ReturnsTrue()
{
var post = Post.Create("Title", "my-slug", "Body text.", "user-1");
await _sut.AddAsync(post);
Assert.True(await _sut.SlugExistsAsync("my-slug"));
}
public void Dispose() => _db.Dispose();
}
Common Mistakes
Mistake 1 — Reusing the same InMemory database across tests (test pollution)
❌ Wrong — tests share state; data from test A affects test B results.
✅ Correct — use a unique database name per test: Guid.NewGuid().ToString() for the database name.
Mistake 2 — Trusting InMemory tests for constraint validation (no constraints enforced)
❌ Wrong — InMemory test passes with duplicate slug; real database throws unique constraint violation.
✅ Correct — test constraint enforcement with real SQL Server (LocalDB or Docker); use InMemory only for query logic.