Testing the service layer is the highest-value unit testing activity — services contain the business rules, and mocking repositories allows these rules to be verified in complete isolation without a database. Well-tested services give the team confidence to refactor, add features, and fix bugs without regression fear. The pattern is consistent: mock dependencies, call the service method, assert on the return value or exception.
Service Layer Tests
public class PostsService_CreateAsync_Tests
{
private readonly Mock<IPostsRepository> _repo = new(MockBehavior.Strict);
private readonly Mock<ICurrentUserService> _user = new();
private readonly Mock<ISlugGenerator> _slugGen = new();
private readonly PostsService _sut;
public PostsService_CreateAsync_Tests()
{
_sut = new PostsService(_repo.Object, _user.Object, _slugGen.Object);
_user.Setup(u => u.UserId).Returns("author-123");
}
[Fact]
public async Task CreateAsync_WithValidRequest_SavesAndReturnsDto()
{
// Arrange
var request = new CreatePostRequest("Great Post", null, "Body content.", "draft");
_slugGen.Setup(s => s.Generate("Great Post")).Returns("great-post");
_repo.Setup(r => r.SlugExistsAsync("great-post", default)).ReturnsAsync(false);
_repo.Setup(r => r.AddAsync(It.IsAny<Post>(), default))
.ReturnsAsync((Post p, CancellationToken _) => { p.Id = 1; return p; });
// Act
var result = await _sut.CreateAsync(request, default);
// Assert
result.Should().NotBeNull();
result.Slug.Should().Be("great-post");
result.AuthorId.Should().Be("author-123"); // injected from current user
result.Status.Should().Be("draft");
_repo.Verify(r => r.AddAsync(
It.Is<Post>(p => p.Slug == "great-post" && p.AuthorId == "author-123"),
default), Times.Once);
}
[Fact]
public async Task CreateAsync_WithDuplicateSlug_ThrowsConflictException()
{
var request = new CreatePostRequest("Title", "taken-slug", "Body", "draft");
_slugGen.Setup(s => s.Generate("Title")).Returns("taken-slug");
_repo.Setup(r => r.SlugExistsAsync("taken-slug", default)).ReturnsAsync(true);
Func<Task> act = () => _sut.CreateAsync(request, default);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*taken-slug*");
// Verify that no post was saved when slug is taken
_repo.Verify(r => r.AddAsync(It.IsAny<Post>(), default), Times.Never);
}
[Fact]
public async Task CreateAsync_WhenUserNotAuthenticated_ThrowsUnauthorizedException()
{
_user.Setup(u => u.UserId).Returns((string?)null);
var request = new CreatePostRequest("Title", null, "Body", "draft");
Func<Task> act = () => _sut.CreateAsync(request, default);
await act.Should().ThrowAsync<UnauthorizedException>();
}
}
// ── JWT Token Service Tests ───────────────────────────────────────────────
public class JwtTokenServiceTests
{
private readonly ITokenService _sut;
public JwtTokenServiceTests()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> {
["Jwt:Key"] = "test-key-that-is-at-least-256-bits-long-for-hmac",
["Jwt:Issuer"] = "test-issuer",
["Jwt:Audience"] = "test-audience",
["Jwt:AccessTokenMinutes"] = "15",
}).Build();
_sut = new JwtTokenService(config);
}
[Fact]
public void GenerateAccessToken_ContainsRequiredClaims()
{
var user = new ApplicationUser { Id = "u1", Email = "alice@test.com",
DisplayName = "Alice" };
var roles = new[] { "Author", "Admin" };
var token = _sut.GenerateAccessToken(user, roles);
var handler = new JwtSecurityTokenHandler();
var parsed = handler.ReadJwtToken(token);
parsed.Claims.Should().Contain(c => c.Type == "sub" && c.Value == "u1");
parsed.Claims.Should().Contain(c => c.Type == "email" && c.Value == "alice@test.com");
parsed.Claims.Should().Contain(c => c.Type == "role" && c.Value == "Author");
parsed.Claims.Should().Contain(c => c.Type == "role" && c.Value == "Admin");
}
[Fact]
public void GenerateAccessToken_ExpiresIn15Minutes()
{
var user = new ApplicationUser { Id = "u1", Email = "alice@test.com" };
var token = _sut.GenerateAccessToken(user, []);
var parsed = new JwtSecurityTokenHandler().ReadJwtToken(token);
parsed.ValidTo.Should().BeCloseTo(DateTime.UtcNow.AddMinutes(15),
precision: TimeSpan.FromSeconds(10));
}
}
MockBehavior.Strict on a Mock<T> causes any call to an un-setup method to throw a MockException. This prevents the test from accidentally succeeding because a method returns a default null value that was never explicitly set up. Use MockBehavior.Strict for repositories and critical dependencies — it ensures every interaction is explicitly set up and verified. Use the default MockBehavior.Loose for services where you only care about specific calls.JwtSecurityTokenHandler.ReadJwtToken() — do not test the token service’s private state. This tests what matters: the token that will be sent to clients contains the correct claims. Parsing the token also verifies that the token is valid JWT format. If the token generation is broken (wrong algorithm, malformed header), the parsing will throw and fail the test explicitly._repo.Verify(..., Times.Once) assertion checks that the mock was called exactly once with the specified parameters. Verification-based assertions are useful but can make tests brittle — if the service is refactored to call the repository method twice (e.g., for caching), the test breaks even if the behaviour is correct. Prefer asserting on return values and side effects. Use Verify sparingly — primarily when a side effect (like sending an email or writing an audit log) has no observable return value to assert on.Common Mistakes
Mistake 1 — Not verifying that invalid paths skip persistence calls
❌ Wrong — test for ConflictException doesn’t verify that AddAsync was never called; implementation might save and then throw.
✅ Correct — _repo.Verify(r => r.AddAsync(...), Times.Never) confirms no save happened on error paths.
Mistake 2 — Using in-memory config with too-short JWT keys
❌ Wrong — "Jwt:Key": "short-key"; HMACSHA256 requires minimum 256 bits (32 chars); SecurityTokenKeyTooShortException thrown.
✅ Correct — use a 32+ character key in test configuration; document the minimum length requirement.