scaffold-unit-tests
Scaffold unit test classes cho tất cả handlers của một entity (Create, Update, Delete, Restore, GetList, GetById)
SKILL.md.claude/skills/dev/be/scaffold-unit-tests/SKILL.md
GitHubScaffold Unit Tests $ARGUMENTS
Usage: /scaffold-unit-tests ServiceName EntityName
Bước 0 — Tạo test project (nếu chưa có)
# Tạo xUnit test project
dotnet new xunit -n {Service}.UnitTests -o services/{Service}/tests/{Service}.UnitTests
dotnet sln add services/{Service}/tests/{Service}.UnitTests/{Service}.UnitTests.csproj
# Thêm reference tới Application project
dotnet add services/{Service}/tests/{Service}.UnitTests/{Service}.UnitTests.csproj \
reference services/{Service}/src/{Service}.Application/{Service}.Application.csproj
# Cài packages bắt buộc
cd services/{Service}/tests/{Service}.UnitTests
dotnet add package Moq
dotnet add package FluentAssertions
dotnet add package MockQueryable.Moq
dotnet add package xunit.runner.visualstudio
Tại sao cần MockQueryable.Moq?
GetAllAsync()trảIQueryable<T>không thể mock trực tiếp bằng Moq vìIQueryablecần provider để chạy.Where(),.ToListAsync(). Package này cung cấp.BuildMock()để tạo in-memory queryable hợp lệ.
Yêu cầu: Test project phải tồn tại với packages: xUnit, Moq, FluentAssertions, MockQueryable.Moq
Bước 1 — Đọc handlers thực tế
Trước khi viết tests, đọc TẤT CẢ handler files của entity để biết:
- Dependencies được inject
- Business logic và error cases
- External services được gọi
Bước 2 — Tạo test files
Command Test: tests/{Service}.UnitTests/Handlers/Commands/{Entity}CreateCommandHandlerTests.cs
using FluentAssertions;
using MockQueryable.Moq;
using Moq;
using Xunit;
using {Service}.Application.Commands.{Entity}Create;
using {Service}.Application.Interfaces;
using {Service}.Domain.Entities;
namespace {Service}.UnitTests.Handlers.Commands;
public class {Entity}CreateCommandHandlerTests
{
private readonly Mock<IServiceUnitOfWork> _uowMock;
private readonly Mock<IGenericRepository<{Entity}>> _repoMock;
private readonly {Entity}CreateCommandHandler _handler;
public {Entity}CreateCommandHandlerTests()
{
_uowMock = new Mock<IServiceUnitOfWork>();
_repoMock = new Mock<IGenericRepository<{Entity}>>();
_uowMock.Setup(u => u.{Entity}s).Returns(_repoMock.Object);
_uowMock.Setup(u => u.BeginTransactionAsync()).Returns(Task.CompletedTask);
_uowMock.Setup(u => u.CommitTransactionAsync()).Returns(Task.CompletedTask);
_uowMock.Setup(u => u.RollbackTransactionAsync()).Returns(Task.CompletedTask);
_handler = new {Entity}CreateCommandHandler(_uowMock.Object);
}
[Fact]
public async Task Handle_ValidInput_ReturnsSuccess()
{
_repoMock.Setup(r => r.AnyAsync(It.IsAny<Expression<Func<{Entity}, bool>>>()))
.ReturnsAsync(false);
var result = await _handler.Handle(
new {Entity}CreateCommand { Name = "Test" }, CancellationToken.None);
result.IsSuccess.Should().BeTrue();
result.Data.Should().NotBeNull();
_repoMock.Verify(r => r.AddAsync(It.IsAny<{Entity}>()), Times.Once);
_uowMock.Verify(u => u.CommitTransactionAsync(), Times.Once);
}
[Fact]
public async Task Handle_DuplicateName_ReturnsFailure()
{
_repoMock.Setup(r => r.AnyAsync(It.IsAny<Expression<Func<{Entity}, bool>>>()))
.ReturnsAsync(true);
var result = await _handler.Handle(
new {Entity}CreateCommand { Name = "Existing" }, CancellationToken.None);
result.IsSuccess.Should().BeFalse();
_repoMock.Verify(r => r.AddAsync(It.IsAny<{Entity}>()), Times.Never);
}
[Fact]
public async Task Handle_DbError_RollsBackTransaction()
{
_repoMock.Setup(r => r.AnyAsync(It.IsAny<Expression<Func<{Entity}, bool>>>()))
.ReturnsAsync(false);
_uowMock.Setup(u => u.CommitTransactionAsync())
.ThrowsAsync(new Exception("DB error"));
var result = await _handler.Handle(
new {Entity}CreateCommand { Name = "Test" }, CancellationToken.None);
result.IsSuccess.Should().BeFalse();
_uowMock.Verify(u => u.RollbackTransactionAsync(), Times.Once);
}
}
Query Test: tests/{Service}.UnitTests/Handlers/Queries/{Entity}GetListQueryHandlerTests.cs
public class {Entity}GetListQueryHandlerTests
{
private readonly Mock<IServiceUnitOfWork> _uowMock;
private readonly Mock<IGenericRepository<{Entity}>> _repoMock;
private readonly {Entity}GetListQueryHandler _handler;
public {Entity}GetListQueryHandlerTests()
{
_uowMock = new Mock<IServiceUnitOfWork>();
_repoMock = new Mock<IGenericRepository<{Entity}>>();
_uowMock.Setup(u => u.{Entity}s).Returns(_repoMock.Object);
_handler = new {Entity}GetListQueryHandler(_uowMock.Object);
}
[Fact]
public async Task Handle_ReturnsPagedResults()
{
var data = Enumerable.Range(1, 15).Select(i => new {Entity}
{
Id = Guid.NewGuid(), Name = $"Item {i}", IsDeleted = false, CreatedAt = DateTime.UtcNow
}).ToList();
// ⚠️ Dùng MockQueryable.Moq cho IQueryable
_repoMock.Setup(r => r.GetAllAsync()).Returns(data.AsQueryable().BuildMock());
var result = await _handler.Handle(
new {Entity}GetListQuery { PageNumber = 1, PageSize = 10, IsDeleted = false },
CancellationToken.None);
result.IsSuccess.Should().BeTrue();
result.Data!.Items.Should().HaveCount(10);
result.Data.TotalItems.Should().Be(15);
result.Data.HasNextPage.Should().BeTrue();
}
[Fact]
public async Task Handle_ExcludesDeleted()
{
var data = new List<{Entity}>
{
new() { Id = Guid.NewGuid(), Name = "Active", IsDeleted = false },
new() { Id = Guid.NewGuid(), Name = "Deleted", IsDeleted = true },
};
_repoMock.Setup(r => r.GetAllAsync()).Returns(data.AsQueryable().BuildMock());
var result = await _handler.Handle(
new {Entity}GetListQuery { IsDeleted = false }, CancellationToken.None);
result.Data!.TotalItems.Should().Be(1);
}
}
Validation Test: tests/{Service}.UnitTests/Validators/{Entity}CreateCommandValidationTests.cs
public class {Entity}CreateCommandValidationTests
{
[Fact]
public async Task Validate_ValidInput_ReturnsSuccess()
{
var cmd = new {Entity}CreateCommand { Name = "Valid Name" };
var result = await cmd.ValidateAsync();
result.IsSuccess.Should().BeTrue();
result.ListErrors.Should().BeEmpty();
}
[Theory]
[InlineData("")]
[InlineData(null)]
public async Task Validate_EmptyName_ReturnsError(string? name)
{
var cmd = new {Entity}CreateCommand { Name = name! };
var result = await cmd.ValidateAsync();
result.IsSuccess.Should().BeFalse();
result.ListErrors.Should().Contain(e => e.Field == "Name");
}
}
Bước 3 — Run tests
cd services/{Service}/tests/{Service}.UnitTests
dotnet test --verbosity normal
Rules
- Đọc ACTUAL handler code trước khi viết tests
- Mock TẤT CẢ dependencies qua constructor injection
- Dùng
MockQueryable.Moq(.BuildMock()) choGetAllAsync()IQueryable - Test: happy path, validation fail, not found, deleted, DB error, rollback
- Naming:
MethodName_Scenario_ExpectedBehavior - Verify:
mock.Verify(x => x.Method(), Times.Once/Never)