Test-Driven Development (TDD) inverts the typical workflow — you write the test before the implementation. The red-green-refactor cycle keeps the implementation focused: red (write a failing test), green (write the minimum code to pass), refactor (improve the code without changing behaviour). TDD is most valuable for complex business logic where the correct behaviour is well-defined but the implementation requires thought — like the BlogApp’s post publishing workflow.
TDD: Implementing PublishAsync
// ── STEP 1: RED — write failing tests before implementation ────────────────
public class PostsService_PublishAsync_Tests
{
private readonly Mock<IPostsRepository> _repo = new();
private readonly Mock<ICurrentUserService> _user = new();
private readonly PostsService _sut;
public PostsService_PublishAsync_Tests()
=> _sut = new PostsService(_repo.Object, _user.Object);
[Fact]
public async Task PublishAsync_WhenPostIsInReview_PublishesSuccessfully()
{
// Arrange
var post = new Post { Id = 1, AuthorId = "u1", Status = "review",
IsPublished = false };
_repo.Setup(r => r.GetByIdAsync(1, default)).ReturnsAsync(post);
_user.Setup(u => u.UserId).Returns("u1");
// Act — this will fail (NotImplementedException) until we implement
var result = await _sut.PublishAsync(1, default);
// Assert
result.Status.Should().Be("published");
result.IsPublished.Should().BeTrue();
result.PublishedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public async Task PublishAsync_WhenPostIsInDraft_ThrowsDomainException()
{
var post = new Post { Id = 1, AuthorId = "u1", Status = "draft" };
_repo.Setup(r => r.GetByIdAsync(1, default)).ReturnsAsync(post);
_user.Setup(u => u.UserId).Returns("u1");
Func<Task> act = () => _sut.PublishAsync(1, default);
await act.Should().ThrowAsync<DomainException>()
.WithMessage("*review*"); // error message should mention 'review'
}
[Fact]
public async Task PublishAsync_WhenNotOwner_ThrowsForbiddenException()
{
var post = new Post { Id = 1, AuthorId = "other-user", Status = "review" };
_repo.Setup(r => r.GetByIdAsync(1, default)).ReturnsAsync(post);
_user.Setup(u => u.UserId).Returns("u1");
Func<Task> act = () => _sut.PublishAsync(1, default);
await act.Should().ThrowAsync<ForbiddenException>();
}
[Fact]
public async Task PublishAsync_WhenPostNotFound_ThrowsNotFoundException()
{
_repo.Setup(r => r.GetByIdAsync(99, default)).ReturnsAsync((Post?)null);
_user.Setup(u => u.UserId).Returns("u1");
Func<Task> act = () => _sut.PublishAsync(99, default);
await act.Should().ThrowAsync<NotFoundException>();
}
}
// ── STEP 2: GREEN — implement minimum code to pass tests ───────────────────
// In PostsService:
public async Task<PostDto> PublishAsync(int postId, CancellationToken ct)
{
var post = await _repo.GetByIdAsync(postId, ct)
?? throw new NotFoundException($"Post {postId} not found.");
var userId = _currentUser.UserId;
if (post.AuthorId != userId)
throw new ForbiddenException("Only the post author can publish.");
if (post.Status != "review")
throw new DomainException("Post must be in review status to publish.");
post.Status = "published";
post.IsPublished = true;
post.PublishedAt = DateTime.UtcNow;
await _repo.UpdateAsync(post, ct);
return post.ToDto();
}
// Run: dotnet test — all 4 tests should now be GREEN ✅
// ── STEP 3: REFACTOR — improve without breaking tests ─────────────────────
// Extract the ownership check to a shared method
// Add logging
// Extract constants for status values
// Run dotnet test again — still GREEN ✅
dotnet test --watch command keeps the test runner running and automatically re-runs tests when source files change. This creates a tight feedback loop during TDD: save the file → tests run immediately → see red/green within 2 seconds. Set it up in a split terminal: one for coding, one showing the test output. The instant feedback loop is what makes TDD feel natural rather than burdensome.Common Mistakes
Mistake 1 — Writing implementation before all tests (no red phase)
❌ Wrong — implement PublishAsync, then write tests; tests may be written to match the (possibly wrong) implementation.
✅ Correct — write ALL tests first (all fail), then implement to make them pass; tests define the correct behaviour independently.
Mistake 2 — Skipping the refactor phase (accumulated technical debt)
❌ Wrong — get to green with hacky code, move to the next feature; code quality degrades over time.
✅ Correct — every green phase is followed by a refactor phase; maintain clean code continuously rather than in cleanup sprints.