with one click
unit-test-conventions
// MyBlog unit test authoring conventions for domain entities, handlers, helpers, and components. Covers file headers, AAA pattern, mocking patterns, FluentAssertions, and bUnit component testing.
// MyBlog unit test authoring conventions for domain entities, handlers, helpers, and components. Covers file headers, AAA pattern, mocking patterns, FluentAssertions, and bUnit component testing.
[HINT] Download the complete skill directory including SKILL.md and all related files
| name | unit-test-conventions |
| confidence | high |
| description | MyBlog unit test authoring conventions for domain entities, handlers, helpers, and components. Covers file headers, AAA pattern, mocking patterns, FluentAssertions, and bUnit component testing. |
This skill covers unit tests in tests/Unit.Tests/, NOT integration tests
(which have their own fixture patterns in tests/Integration.Tests/).
BlogPostTests.cs)GetBlogPostsHandlerTests.cs)NavMenuTests.cs, ProfileTests.cs)Every .cs test file should have a 7-line copyright block at the top:
//=======================================================
//Copyright (c) 2026. All rights reserved.
//File Name : {FileName}.cs
//Company : mpaulosky
//Author : Matthew Paulosky
//Solution Name : MyBlog
//Project Name : Unit.Tests
//=======================================================
Notes:
Unit.Tests for test files in tests/Unit.Tests///), no blank lines within blockExample from repo (MongoDbBlogPostRepositoryTests.cs):
//=======================================================
//Copyright (c) 2026. All rights reserved.
//File Name : MongoDbBlogPostRepositoryTests.cs
//Company : mpaulosky
//Author : Matthew Paulosky
//Solution Name : MyBlog
//Project Name : Integration.Tests
//=======================================================
MyBlog.Unit.Tests.{Folder}
Examples:
MyBlog.Unit.Tests.Handlers ā GetBlogPostsHandlerTests.csMyBlog.Unit.Tests.Components.Layout ā NavMenuTests.csMyBlog.Unit.Tests.Features.UserManagement ā ProfileTests.csMyBlog.Unit.Tests ā BlogPostTests.cs (domain entity tests live at root)The recommended pattern for test methods is Arrange / Act / Assert with explicit comments:
[Fact]
public void Create_WithValidArgs_ReturnsBlogPost()
{
// Arrange
var title = "Test Title";
var content = "Test Content";
var author = "Test Author";
// Act
var post = BlogPost.Create(title, content, author);
// Assert
post.Title.Should().Be("Test Title");
post.Content.Should().Be("Test Content");
post.Author.Should().Be("Test Author");
}
Current repo state: Some existing tests (e.g., BlogPostTests.cs) do not yet use AAA comments.
When writing new tests or modifying existing ones, adopt AAA comments to improve clarity.
Why comments matter:
Async variant:
[Fact]
public async Task Handle_Success_CreatesPost()
{
// Arrange
var command = new CreateBlogPostCommand("Title", "Content", "Author");
var repo = Substitute.For<IBlogPostRepository>();
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
}
.Should() (Critical Rule: Use everywhere)MyBlog uses FluentAssertions exclusively. All assertions must use the
.Should() fluent chain.
ā Correct:
post.Title.Should().Be("Expected Title");
result.Success.Should().BeTrue();
list.Should().HaveCount(3);
ā Wrong:
Assert.Equal("Expected Title", post.Title);
post.Title == "Expected Title" || throw...
Common assertions for MyBlog:
// Collections
list.Should().BeEmpty();
list.Should().HaveCount(2);
list.Should().Contain(item);
list.Should().OnlyContain(x => x.IsPublished);
// Strings
title.Should().Be("Expected");
title.Should().BeNullOrEmpty();
title.Should().Contain("substring");
// Exceptions
act.Should().Throw<ArgumentException>();
await act.Should().ThrowAsync<DbUpdateConcurrencyException>();
// Objects
result.Should().NotBeNull();
result.Should().BeOfType<BlogPostDto>();
result.Value!.Id.Should().NotBeEmpty();
// Booleans
result.Success.Should().BeTrue();
result.Failure.Should().BeFalse();
MyBlog uses NSubstitute for all mocking. Test classes typically create substitutes in the constructor or as fields.
Pattern for handler tests:
public class GetBlogPostsHandlerTests
{
private readonly IBlogPostRepository _repo = Substitute.For<IBlogPostRepository>();
private readonly IMemoryCache _cache = Substitute.For<IMemoryCache>();
private readonly GetBlogPostsHandler _handler;
public GetBlogPostsHandlerTests()
{
_handler = new GetBlogPostsHandler(_repo, _cache);
}
[Fact]
public async Task Handle_CacheMiss_CallsRepo()
{
// Arrange
_repo.GetAllAsync(Arg.Any<CancellationToken>())
.Returns(new List<BlogPost> { BlogPost.Create("T", "C", "A") });
// Act
var result = await _handler.Handle(new GetBlogPostsQuery(), CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
await _repo.Received(1).GetAllAsync(Arg.Any<CancellationToken>());
}
}
IMemoryCache mocking gotcha:
IMemoryCache.Set<T> is an extension method ā NSubstitute cannot intercept it.
Set<T> calls CreateEntry() internally ā mock CreateEntry() instead:
var cacheEntry = Substitute.For<ICacheEntry>();
_cache.CreateEntry(Arg.Any<object>()).Returns(cacheEntry);
// Later, verify with:
_cache.Received(1).CreateEntry(Arg.Any<object>());
IMemoryCache.TryGetValue out-param pattern:
object? outVal = null;
_cache.TryGetValue(Arg.Any<object>(), out outVal)
.Returns(x => { x[1] = (object)cachedValue; return true; });
Domain entities like BlogPost are tested directly without mocking. Test the Create() factory and
lifecycle methods (Update(), Publish(), Unpublish()).
Real example from repo (tests/Unit.Tests/BlogPostTests.cs):
public class BlogPostTests
{
[Fact]
public void Create_WithValidArgs_ReturnsBlogPost()
{
var post = BlogPost.Create("Test Title", "Test Content", "Test Author");
post.Id.Should().NotBeEmpty();
post.Title.Should().Be("Test Title");
post.Content.Should().Be("Test Content");
post.Author.Should().Be("Test Author");
post.IsPublished.Should().BeFalse();
post.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
}
[Theory]
[InlineData("", "content", "author")]
[InlineData("title", "", "author")]
[InlineData("title", "content", "")]
public void Create_WithBlankArgs_ThrowsArgumentException(
string title, string content, string author)
{
var act = () => BlogPost.Create(title, content, author);
act.Should().Throw<ArgumentException>();
}
[Fact]
public void Update_ChangesTitle_AndContent()
{
var post = BlogPost.Create("Old Title", "Old Content", "Author");
post.Update("New Title", "New Content");
post.Title.Should().Be("New Title");
post.Content.Should().Be("New Content");
post.UpdatedAt.Should().NotBeNull();
}
}
Note: These tests do not follow AAA comments (current repo state). When adding new entity tests, consider adopting the AAA pattern above.
Guidelines:
[Fact] for single-case tests[Theory] + [InlineData] for parameterized testsHandler tests mock ALL external dependencies (repo, cache, etc.) and verify handler logic in isolation.
Pattern for query handlers:
public class EditBlogPostHandlerTests
{
private readonly IBlogPostRepository _repo = Substitute.For<IBlogPostRepository>();
private readonly IMemoryCache _cache = Substitute.For<IMemoryCache>();
private readonly EditBlogPostHandler _handler;
public EditBlogPostHandlerTests()
{
_cache.CreateEntry(Arg.Any<object>()).Returns(Substitute.For<ICacheEntry>());
_handler = new EditBlogPostHandler(_repo, _cache);
}
[Fact]
public async Task HandleEdit_Success_UpdatesPostAndInvalidatesCaches()
{
// Arrange
var post = BlogPost.Create("Old Title", "Old Content", "Author");
var command = new EditBlogPostCommand(post.Id, "New Title", "New Content");
_repo.GetByIdAsync(post.Id, Arg.Any<CancellationToken>()).Returns(post);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
await _repo.Received(1).UpdateAsync(post, Arg.Any<CancellationToken>());
_cache.Received(1).Remove("blog:all");
post.Title.Should().Be("New Title");
post.Content.Should().Be("New Content");
}
[Fact]
public async Task HandleEdit_NotFound_ReturnsFailResult()
{
// Arrange
var id = Guid.NewGuid();
var command = new EditBlogPostCommand(id, "T", "C");
_repo.GetByIdAsync(id, Arg.Any<CancellationToken>()).Returns((BlogPost?)null);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Failure.Should().BeTrue();
result.Error.Should().Contain(id.ToString());
}
[Fact]
public async Task HandleEdit_ConcurrentUpdate_ReturnsConcurrencyErrorCode()
{
// Arrange
var post = BlogPost.Create("Title", "Content", "Author");
var command = new EditBlogPostCommand(post.Id, "New Title", "New Content");
_repo.GetByIdAsync(post.Id, Arg.Any<CancellationToken>()).Returns(post);
_repo.UpdateAsync(Arg.Any<BlogPost>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new DbUpdateConcurrencyException("conflict", new Exception()));
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Failure.Should().BeTrue();
result.ErrorCode.Should().Be(ResultErrorCode.Concurrency);
}
}
Component tests in MyBlog use bUnit's TestContext base class. Tests render components with auth context
by calling a RenderForUser(ClaimsPrincipal) method and assert rendered markup.
Real example from repo (tests/Unit.Tests/Features/UserManagement/ProfileTests.cs):
public class ProfileTests : BunitContext
{
[Fact]
public void Profile_RendersIdentityDetailsRolesPictureAndClaims()
{
// Arrange
var principal = CreatePrincipal(
name: "Admin User",
email: "admin@example.com",
userId: "auth0|123",
pictureUrl: "https://example.com/avatar.png",
rolesJson: "[\"Admin\",\"Author\"]",
extraClaims: [new Claim("department", "Engineering")]);
// Act
var cut = RenderForUser(principal);
// Assert
cut.Markup.Should().Contain("Admin User");
cut.Markup.Should().Contain("auth0|123");
}
}
How it works:
BunitContext (bUnit's TestContext base; made available via global using Bunit;)RenderForUser(principal) to render the component with authenticated contextcut.Markup.Should() to assert the rendered HTML outputRenderForUser() and CreatePrincipal() helper methodsā NEVER compare two {Entity}Dto.Empty calls
// WRONG:
var dto1 = BlogPostDto.Empty;
var dto2 = BlogPostDto.Empty;
dto1.Should().Be(dto2); // FAILS ā Empty calls DateTime.UtcNow each time
ā CORRECT ā Assert individual fields:
var dto = BlogPostDto.Empty;
dto.Id.Should().BeEmpty();
dto.Title.Should().Be("");
dto.Content.Should().Be("");
GenerateSlug trailing underscore is correct
"C# Is Great!".GenerateSlug().Should().Be("c_is_great_");
// Trailing underscore is EXPECTED, not a bug
tests/Unit.Tests/
āāā BlogPostTests.cs # Domain entity tests
āāā ResultTests.cs # Result<T> utility tests
āāā Handlers/
ā āāā GetBlogPostsHandlerTests.cs # Query handler tests
ā āāā CreateBlogPostHandlerTests.cs
ā āāā EditBlogPostHandlerTests.cs
ā āāā DeleteBlogPostHandlerTests.cs
āāā Components/
ā āāā Layout/
ā ā āāā NavMenuTests.cs # Component tests
ā āāā RazorSmokeTests.cs
āāā Features/
ā āāā UserManagement/
ā āāā ProfileTests.cs
āāā Security/
ā āāā RoleClaimsHelperTests.cs
āāā Testing/
āāā TestAuthorizationService.cs # Auth mocking helper for bUnit tests
Before pushing, run the full unit test suite:
dotnet test tests/Unit.Tests --logger "console;verbosity=detailed"
Verify all tests pass (zero failures required per Gimli's charter rule #1).