Test-Driven Development for ASP.NET Core: RED/GREEN/REFACTOR cycle using xUnit + Moq, controller unit tests, WebApplicationFactory web tests, Testcontainers integration tests, and GitHub Actions CI setup.
How this skill is triggered — by the user, by Claude, or both
Slash command
/everything-claude-code:aspnetcore-tddThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Test-Driven Development workflow for ASP.NET Core services.
Test-Driven Development workflow for ASP.NET Core services.
RED → GREEN → REFACTOR
// New feature: Cancel order
[Fact]
public async Task CancelOrder_SetsStatusCancelled_WhenPending()
{
// Arrange
var order = new Order { Id = 1, Status = OrderStatus.Pending };
_mockRepo.Setup(r => r.GetByIdAsync(1, default)).ReturnsAsync(order);
_mockRepo.Setup(r => r.UpdateAsync(It.IsAny<Order>(), default))
.Returns(Task.CompletedTask);
// Act
await _sut.CancelOrderAsync(1, CancellationToken.None); // doesn't exist yet
// Assert
_mockRepo.Verify(r => r.UpdateAsync(
It.Is<Order>(o => o.Status == OrderStatus.Cancelled), default),
Times.Once);
}
Run: dotnet test → RED (compilation error or test failure)
// Add to OrderService
public async Task CancelOrderAsync(int orderId, CancellationToken ct)
{
var order = await _orderRepository.GetByIdAsync(orderId, ct)
?? throw new OrderNotFoundException(orderId);
order.Status = OrderStatus.Cancelled;
await _orderRepository.UpdateAsync(order, ct);
}
Run: dotnet test → GREEN
[Fact]
public async Task CancelOrder_ThrowsInvalidOperation_WhenAlreadyShipped()
{
var order = new Order { Id = 1, Status = OrderStatus.Shipped };
_mockRepo.Setup(r => r.GetByIdAsync(1, default)).ReturnsAsync(order);
var act = () => _sut.CancelOrderAsync(1, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*cannot cancel*shipped*");
}
Update implementation, refactor duplication → GREEN
public class OrdersControllerTests
{
private readonly Mock<IOrderService> _mockService = new();
private readonly OrdersController _sut;
public OrdersControllerTests()
{
_sut = new OrdersController(_mockService.Object);
}
[Fact]
public async Task Get_Returns200_WithOrder()
{
var dto = new OrderDto(1, "Alice", 99.99m);
_mockService.Setup(s => s.GetByIdAsync(1, default)).ReturnsAsync(dto);
var result = await _sut.Get(1, CancellationToken.None);
var ok = result.Result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(dto);
}
[Fact]
public async Task Get_Returns404_WhenNotFound()
{
_mockService.Setup(s => s.GetByIdAsync(99, default))
.ReturnsAsync((OrderDto?)null);
var result = await _sut.Get(99, CancellationToken.None);
result.Result.Should().BeOfType<NotFoundResult>();
}
}
public class OrderEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrderEndpointTests(WebApplicationFactory<Program> factory)
{
_client = factory.WithWebHostBuilder(builder =>
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IOrderService>();
services.AddSingleton(_mockService.Object);
})
).CreateClient();
}
[Fact]
public async Task POST_Orders_Returns201_WithValidBody()
{
var request = new { CustomerId = 1, Items = new[] { new { ProductId = 1, Qty = 2 } } };
var response = await _client.PostAsJsonAsync("/api/orders", request);
response.StatusCode.Should().Be(HttpStatusCode.Created);
response.Headers.Location.Should().NotBeNull();
}
[Fact]
public async Task POST_Orders_Returns400_WithMissingCustomerId()
{
var response = await _client.PostAsJsonAsync("/api/orders", new { });
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Status.Should().Be(400);
}
}
public class OrderRepositoryTddTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder().Build();
private AppDbContext _ctx = null!;
private OrderRepository _sut = null!;
public async Task InitializeAsync()
{
await _db.StartAsync();
_ctx = CreateContext(_db.GetConnectionString());
await _ctx.Database.MigrateAsync();
_sut = new OrderRepository(_ctx);
}
public async Task DisposeAsync() => await _db.DisposeAsync();
[Fact]
public async Task AddAndFind_Order_PersistsToDatabase()
{
// RED: this test drove AddAsync and GetByIdAsync
var order = new Order { CustomerName = "Bob", TotalAmount = 50m };
await _sut.AddAsync(order, CancellationToken.None);
var found = await _sut.GetByIdAsync(order.Id, CancellationToken.None);
found.Should().NotBeNull();
found!.CustomerName.Should().Be("Bob");
}
}
// ✅ UseInMemoryDatabase: fast unit tests, no SQL dialect
services.AddDbContext<AppDbContext>(o =>
o.UseInMemoryDatabase(Guid.NewGuid().ToString()));
// ⚠️ In-memory doesn't validate constraints, no transactions
// Use for simple repository logic tests
// ✅ Testcontainers: real PostgreSQL/SQL Server dialect
// Use for migration tests, complex queries, constraint validation
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0'
- run: dotnet restore
- run: dotnet build --no-restore
- run: dotnet test --no-build --collect:"XPlat Code Coverage" --verbosity normal
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: '**/coverage.cobertura.xml'
Thread.Sleep — use CancellationToken timeouts or eventsIClassFixture over static state for shared setupnpx claudepluginhub jed1978/everything-claude-codeCreates bite-sized, testable implementation plans from specs or requirements, with file structure and task decomposition. Activates before coding multi-step tasks.