TDD Workflow — Red-Green-Refactor for the BlogApp

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 ✅
Note: The 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.
Tip: In the Green phase, write the minimum code to make the tests pass — not the production-quality implementation. It can be ugly, repetitive, or hardcoded. The goal is just to get to green. Then refactor with confidence: the tests verify you haven’t broken anything. This discipline prevents over-engineering in the Green phase and keeps refactoring safe. A common mistake: writing too much code in the Green phase and skipping the Refactor phase entirely.
Warning: TDD does not suit all situations. For exploratory code (figuring out how an API works), UI layout experiments, or infrastructure glue code, writing tests first is unproductive because you are not sure what the final shape will be. TDD works best when the behaviour is well-defined in advance — business rules, validation logic, state machines, and algorithms. For everything else, write tests after the implementation stabilises, or use integration tests rather than unit tests.

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.

🧠 Test Yourself

In the TDD Green phase for PublishAsync, the simplest implementation is: if (postId == 1) return hardcodedDto; throw new Exception(). Is this acceptable?