| name | test-driven-development |
| description | Drives .NET/C# development with tests — RED/GREEN/REFACTOR with xUnit (v2 or v3) or MSTest, the Prove-It Pattern for bug fixes, the test pyramid with `WebApplicationFactory` for integration and `Playwright`/`Avalonia.Headless` for E2E. Supports both VSTest and Microsoft.Testing.Platform (MTP) runners. Use when implementing any logic, fixing any bug, or changing any behavior. |
| version | 1.0.1 |
| source | vendor/agent-skills/skills/test-driven-development/SKILL.md@44dac80 |
Test-Driven Development
Overview
Write a failing test before writing the code that makes it pass. For bug fixes, reproduce the bug with a test before attempting a fix. Tests are proof — "seems right" is not done. A codebase with good tests is an AI agent's superpower; a codebase without tests is a liability.
When to Use
- Implementing any new logic or behavior
- Fixing any bug (the Prove-It Pattern)
- Modifying existing functionality
- Adding edge case handling
- Any change that could break existing behavior
When NOT to use: Pure configuration changes, documentation updates, or static content changes that have no behavioral impact.
Related: For HTTP and UI integration, combine TDD with integration-testing-dotnet — that skill covers WebApplicationFactory<T>, Testcontainers, Microsoft.Playwright, and Avalonia.Headless for Blazor / ASP.NET Core / Avalonia tests respectively.
Version Awareness: xUnit v2 vs v3, and Microsoft.Testing.Platform
The guidance below works with both current xUnit major versions and both current runners. Detect which one the project uses (the .csproj package references are the tell) and apply the matching packaging.
| Aspect | xUnit v2 (mature) | xUnit v3 (current) |
|---|
| Core package | xunit | xunit.v3 |
| IDE / VSTest runner | xunit.runner.visualstudio | xunit.v3.runner.visualstudio |
Output of dotnet build on the test project | DLL loaded by the runner | Executable (can be run directly: ./MyApp.Tests.exe) |
| Native runner | VSTest (dotnet test) | Microsoft.Testing.Platform (MTP) — faster startup, self-contained; still invokable via dotnet test |
| Minimum target framework | netcoreapp3.1+ / net472 | net8.0 or later |
| Source-level API compatibility | [Fact], [Theory], [InlineData], Assert.*, IClassFixture<T>, ICollectionFixture<T>, [Collection] — all identical between v2 and v3 for normal test code | same |
Practical upshot for the TDD workflow:
- The RED / GREEN / REFACTOR examples in this skill compile unchanged against either version.
dotnet test works in both worlds. On v3 the command invokes MTP; on v2 it invokes VSTest. Same user-facing command for agents driving tests from CI or a Husky.Net hook.
- On v3,
dotnet run --project tests/MyApp.Tests is a legitimate alternative — the test project is an executable — useful when the VSTest runner setup is causing trouble or when you want zero-overhead test startup locally.
- MTP filtering syntax differs slightly from VSTest.
dotnet test --filter "FullyQualifiedName~X" still works on both; for v3-native MTP flags see the xUnit v3 docs (link in "See Also").
- xUnit v3 is the current recommendation for new projects on .NET 8+. Existing v2 projects can stay as-is — the test-author API is source-compatible — and migrate when the project has capacity. Samples below are shown in v3 form; swap the package references above to target v2.
MSTest parallel: MSTest has shipped MTP-native support since 3.6. The MSTest.TestFramework + MSTest.Sdk packages give you an MTP-first experience with the same [TestClass]/[TestMethod]/Assert.* API.
The TDD Cycle
RED GREEN REFACTOR
Write a test Write minimal code Clean up the
that fails ──→ to make it pass ──→ implementation ──→ (repeat)
│ │ │
▼ ▼ ▼
Test FAILS Test PASSES Tests still PASS
Step 1: RED — Write a Failing Test
Write the test first. It must fail. A test that passes immediately proves nothing.
Both xUnit and MSTest are first-class in the .NET world; pick one per project and stick to it. Examples below are shown in both using native assertions — Xunit.Assert.X for xUnit, Microsoft.VisualStudio.TestTools.UnitTesting.Assert.X for MSTest. No third-party assertion library is introduced or recommended.
public sealed class TaskServiceTests
{
private readonly TaskService _service = new();
[Fact]
public async Task CreateTaskAsync_WithTitle_ReturnsTaskWithDefaultStatus()
{
var task = await _service.CreateTaskAsync(new CreateTaskInput("Buy groceries"));
Assert.NotEqual(default, task.Id);
Assert.Equal("Buy groceries", task.Title);
Assert.Equal(TaskStatus.Pending, task.Status);
Assert.True(task.CreatedAt <= DateTimeOffset.UtcNow);
}
}
[TestClass]
public sealed class TaskServiceTests
{
private readonly TaskService _service = new();
[TestMethod]
public async Task CreateTaskAsync_WithTitle_ReturnsTaskWithDefaultStatus()
{
var task = await _service.CreateTaskAsync(new CreateTaskInput("Buy groceries"));
Assert.AreNotEqual(default, task.Id);
Assert.AreEqual("Buy groceries", task.Title);
Assert.AreEqual(TaskStatus.Pending, task.Status);
Assert.IsTrue(task.CreatedAt <= DateTimeOffset.UtcNow);
}
}
Step 2: GREEN — Make It Pass
Write the minimum code to make the test pass. Don't over-engineer:
public sealed class TaskService
{
public Task<TaskDto> CreateTaskAsync(CreateTaskInput input, CancellationToken cancellationToken = default)
{
var task = new TaskDto(
Id: TaskId.New(),
Title: input.Title,
Status: TaskStatus.Pending,
CreatedAt: DateTimeOffset.UtcNow);
return Task.FromResult(task);
}
}
public readonly record struct TaskId(Guid Value)
{
public static TaskId New() => new(Guid.NewGuid());
}
public sealed record CreateTaskInput(string Title);
public sealed record TaskDto(TaskId Id, string Title, TaskStatus Status, DateTimeOffset CreatedAt);
public enum TaskStatus { Pending, InProgress, Completed, Cancelled }
Step 3: REFACTOR — Clean Up
With tests green, improve the code without changing behavior:
- Extract shared logic into private methods or helpers
- Improve naming
- Remove duplication
- Swap
DateTimeOffset.UtcNow for an injected TimeProvider so time-dependent tests aren't flaky
- Optimize if
BenchmarkDotNet measurements justify it
Run dotnet test after every refactor step to confirm nothing broke.
The Prove-It Pattern (Bug Fixes)
When a bug is reported, do not start by trying to fix it. Start by writing a test that reproduces it.
Bug report arrives
│
▼
Write a test that demonstrates the bug
│
▼
Test FAILS (confirming the bug exists)
│
▼
Implement the fix
│
▼
Test PASSES (proving the fix works)
│
▼
Run full test suite (no regressions): `dotnet test`
Example:
[Fact]
public async Task CompleteTaskAsync_SetsCompletedAtTimestamp()
{
var created = await _service.CreateTaskAsync(new CreateTaskInput("Test"));
var completed = await _service.CompleteTaskAsync(created.Id);
Assert.Equal(TaskStatus.Completed, completed.Status);
Assert.NotNull(completed.CompletedAt);
}
public async Task<TaskDto> CompleteTaskAsync(TaskId id, CancellationToken cancellationToken = default)
{
var task = await _repository.GetByIdAsync(id, cancellationToken);
var completed = task with
{
Status = TaskStatus.Completed,
CompletedAt = _timeProvider.GetUtcNow(),
};
await _repository.UpdateAsync(completed, cancellationToken);
return completed;
}
The Test Pyramid
Invest testing effort according to the pyramid — most tests should be small and fast, with progressively fewer tests at higher levels:
╱╲
╱ ╲ E2E Tests (~5%)
╱ ╲ Full user flows:
╱──────╲ Blazor/Razor → Microsoft.Playwright
╱ ╲ Avalonia → Avalonia.Headless.XUnit
╱ ╲ Integration Tests (~15%)
╱ ╲ API boundary → WebApplicationFactory<TEntryPoint>
╱──────────────╲ DB → Testcontainers (Postgres/MSSQL/Redis)
╱ ╲ Unit Tests (~80%)
╱ ╲ Pure logic, isolated, milliseconds each
╱────────────────────╲ xUnit v3 / MSTest + native Assert
The Beyonce Rule: If you liked it, you should have put a test on it. Infrastructure changes, refactoring, and migrations are not responsible for catching your bugs — your tests are. If a change breaks your code and you didn't have a test for it, that's on you.
Test Sizes (Resource Model)
Beyond the pyramid levels, classify tests by what resources they consume:
| Size | Constraints | Speed | Example in .NET |
|---|
| Small | Single process, no I/O, no network, no database | Milliseconds | Pure domain tests in MyApp.Core.Tests, value-type tests |
| Medium | Multi-process OK, localhost only, no external services | Seconds | WebApplicationFactory HTTP tests with SQLite or Testcontainers; Avalonia.Headless UI tests |
| Large | Multi-machine OK, external services allowed | Minutes | Full Microsoft.Playwright E2E runs against a staged deployment; BenchmarkDotNet perf suites |
Small tests should make up the vast majority of your suite. They're fast, reliable, and easy to debug when they fail.
Decision Guide
Is it pure logic with no side effects?
→ Unit test (xUnit/MSTest in MyApp.Core.Tests)
Does it cross a boundary (HTTP, EF Core, file system, message bus)?
→ Integration test (WebApplicationFactory + Testcontainers in MyApp.Integration.Tests)
Is it a critical user flow that must work end-to-end in a real browser or UI?
→ E2E test (Playwright.NET or Avalonia.Headless) — limit these to critical paths
Writing Good Tests
Test State, Not Interactions
Assert on the outcome of an operation, not on which methods were called internally. Tests that verify method-call sequences break when you refactor, even if the behavior is unchanged.
[Fact]
public async Task ListTasks_SortedByCreationDate_NewestFirst()
{
var tasks = await _service.ListAsync(new ListTasksParams(SortBy: "createdAt", SortOrder: "desc"));
Assert.Equal(tasks.Data.OrderByDescending(t => t.CreatedAt), tasks.Data);
}
[Fact]
public async Task ListTasks_CallsDbContextWithOrderByDesc()
{
await _service.ListAsync(new ListTasksParams(SortBy: "createdAt", SortOrder: "desc"));
_dbContextMock.Verify(db => db.Tasks.OrderByDescending(It.IsAny<Expression<Func<Task, DateTimeOffset>>>()),
Times.Once);
}
DAMP Over DRY in Tests
In production code, DRY (Don't Repeat Yourself) is usually right. In tests, DAMP (Descriptive And Meaningful Phrases) is better. A test should read like a specification — each test should tell a complete story without requiring the reader to trace through shared helpers.
[Fact]
public void CreateTask_WithEmptyTitle_Throws()
{
var input = new CreateTaskInput("");
var ex = Assert.Throws<ValidationException>(() => _service.Create(input));
Assert.Contains("Title is required", ex.Message);
}
[Fact]
public void CreateTask_TrimsWhitespaceFromTitle()
{
var input = new CreateTaskInput(" Buy groceries ");
var task = _service.Create(input);
Assert.Equal("Buy groceries", task.Title);
}
Some duplication in tests is acceptable when it makes each test independently understandable. Reach for [Theory] / [InlineData] (xUnit) or [DataRow] (MSTest) when the variation itself is the point of the test, not to collapse conceptually different tests into one.
Prefer Real Implementations Over Mocks
Use the simplest test double that gets the job done. The more your tests use real code, the more confidence they provide.
Preference order (most to least preferred):
1. Real implementation → Highest confidence, catches real bugs
2. Fake → In-memory version (e.g., EF Core InMemory provider, in-memory IDistributedCache)
3. Stub → Returns canned data, no behavior (hand-written Test class that implements the interface)
4. Mock (interaction) → Moq / NSubstitute verifying method calls — use sparingly
For EF Core, prefer SQLite in-memory or Testcontainers over the Microsoft.EntityFrameworkCore.InMemory provider — the in-memory provider doesn't enforce relational constraints, so tests pass against it that would fail in production.
Use mocks only when: the real implementation is too slow (external APIs), non-deterministic (random, wall-clock time), or has side effects you can't control (email sending, payment processing). Over-mocking creates tests that pass while production breaks.
Use TimeProvider for Time-Dependent Logic
.NET 8+ ships TimeProvider — inject it instead of calling DateTimeOffset.UtcNow directly, then use FakeTimeProvider (from Microsoft.Extensions.TimeProvider.Testing) in tests:
public sealed class TaskService(ITaskRepository repository, TimeProvider timeProvider)
{
public TaskDto MarkOverdue(TaskDto task)
{
var now = timeProvider.GetUtcNow();
return task with { IsOverdue = task.Deadline < now };
}
}
[Fact]
public void MarkOverdue_DeadlinePassed_SetsOverdueFlag()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 1, 2, 0, 0, 0, TimeSpan.Zero));
var service = new TaskService(_repository, time);
var task = new TaskDto(, Deadline: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
var result = service.MarkOverdue(task);
Assert.True(result.IsOverdue);
}
Use the Arrange-Act-Assert Pattern
[Fact]
public void MarkOverdue_DeadlinePassed_SetsOverdueFlag()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 1, 2, 0, 0, 0, TimeSpan.Zero));
var service = new TaskService(_repository, time);
var task = new TaskDto(, Deadline: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
var result = service.MarkOverdue(task);
Assert.True(result.IsOverdue);
}
One Assertion Per Concept
[Fact] public void Create_RejectsEmptyTitle() { }
[Fact] public void Create_TrimsWhitespaceFromTitle() { }
[Fact] public void Create_EnforcesMaxTitleLength() { }
[Fact]
public void Create_ValidatesTitlesCorrectly()
{
Assert.Throws<ValidationException>(() => _service.Create(new("")));
Assert.Equal("hello", _service.Create(new(" hello ")).Title);
Assert.Throws<ValidationException>(() => _service.Create(new(new string('a', 256))));
}
Name Tests Descriptively
A common .NET convention is MethodUnderTest_Scenario_ExpectedResult:
public class TaskService_CompleteTaskAsync_Tests
{
[Fact] public Task SetsStatusToCompletedAndRecordsTimestamp() { return Task.CompletedTask; }
[Fact] public Task ThrowsNotFoundException_WhenTaskDoesNotExist() { return Task.CompletedTask; }
[Fact] public Task IsIdempotent_CompletingAlreadyCompletedTaskIsNoOp() { return Task.CompletedTask; }
[Fact] public Task SendsNotificationToAssignee() { return Task.CompletedTask; }
}
public class TaskServiceTests
{
[Fact] public void Works() { }
[Fact] public void HandlesErrors() { }
[Fact] public void Test3() { }
}
Test Anti-Patterns to Avoid
| Anti-Pattern | Problem | Fix |
|---|
| Testing implementation details | Tests break when refactoring even if behavior is unchanged | Test inputs and outputs, not internal structure |
| Flaky tests (timing, order-dependent) | Erode trust in the test suite; [Fact(Skip = "…")] accumulates | Use deterministic assertions, FakeTimeProvider, per-test DbContext scoping |
| Testing framework code | Wastes time testing BCL/EF Core behavior | Only test YOUR code |
| Snapshot abuse (e.g. Verify.Xunit on sprawling objects) | Large snapshots nobody reviews, break on any change | Use sparingly and review every change |
| No test isolation | Tests pass individually but fail together (shared static state, shared DbContext) | Each test sets up and tears down its own fixture; use IClassFixture / ICollectionFixture intentionally |
| Mocking everything | Tests pass but production breaks | Prefer real > fakes > stubs > mocks. Mock only at non-deterministic or external boundaries |
Microsoft.EntityFrameworkCore.InMemory for anything serious | Doesn't enforce relational constraints; false confidence | Use SQLite in-memory or Testcontainers with the real provider |
.Result / .Wait() in test bodies | Deadlocks; muddies stack traces | async Task test methods and await everywhere |
When to Use Subagents for Testing
For complex bug fixes, spawn a subagent to write the reproduction test:
Main agent: "Spawn a subagent to write a test that reproduces this bug:
[bug description]. The test should fail with the current code."
Subagent: Writes the reproduction test (xUnit or MSTest, matching
the project convention).
Main agent: Verifies the test fails (`dotnet test --filter ...`),
then implements the fix, then verifies the test passes.
This separation ensures the test is written without knowledge of the fix, making it more robust.
See Also
Common Rationalizations
| Rationalization | Reality |
|---|
| "I'll write tests after the code works" | You won't. And tests written after the fact test implementation, not behavior. |
| "This is too simple to test" | Simple code gets complicated. The test documents the expected behavior. |
| "Tests slow me down" | Tests slow you down now. They speed you up every time you change the code later. |
| "I tested it manually" | Manual testing doesn't persist. Tomorrow's change might break it with no way to know. |
| "The code is self-explanatory" | Tests ARE the specification. They document what the code should do, not what it does. |
| "It's just a prototype" | Prototypes become production code. Tests from day one prevent the "test debt" crisis. |
| "EF Core InMemory is good enough for tests" | It skips relational constraints. You'll discover that the day you deploy to Postgres. Use SQLite in-memory or Testcontainers. |
| "I'll use DateTime.UtcNow — it's fine" | Time-dependent tests go flaky the minute someone runs them on a slow CI machine. Inject TimeProvider from day one. |
"Let me run dotnet test again just to be extra sure" | After a clean test run, repeating the same command adds nothing unless the code has changed since. Run again after subsequent edits, not as reassurance. |
Red Flags
- Writing code without any corresponding tests
- Tests that pass on the first run (they may not be testing what you think)
- "All tests pass" but
dotnet test output shows Skipped or Passed: 0
- Bug fixes without reproduction tests
- Tests that test BCL / EF Core behavior instead of application behavior
- Test names that don't describe the expected behavior
[Fact(Skip = "…")] / [Ignore] added to make the suite pass
.Result or .Wait() in test bodies
- Mocking
DbContext directly instead of using a real provider (SQLite or Testcontainers)
- A solution with no
tests/ directory
- Running the same
dotnet test command twice in a row without any intervening code change
Verification
After completing any implementation:
Note: Run each test command after a change that could affect the result. After a clean dotnet test run, don't repeat the same command unless the code has changed since — re-running on unchanged code adds no confidence.
Source & Modifications
- Upstream: https://github.com/addyosmani/agent-skills/blob/44dac80216da709913fb410f632a65547866346f/skills/test-driven-development/SKILL.md
- Pinned commit:
44dac80216da709913fb410f632a65547866346f (synced 2026-04-19)
- Status:
modified
- Changes:
- All TypeScript/Jest examples rewritten as dual xUnit + MSTest (with FluentAssertions note) for both the RED step and the Prove-It Pattern; GREEN step uses C# records +
TaskId strongly-typed ID + TimeProvider
- REFACTOR step mentions
TimeProvider injection and BenchmarkDotNet-justified optimization
- Test Pyramid labels call out the concrete .NET tooling at each tier (xUnit/MSTest at the base,
WebApplicationFactory + Testcontainers at the middle, Playwright.NET + Avalonia.Headless at the top)
- Test Sizes table retargeted:
MyApp.Core.Tests, WebApplicationFactory, SQLite/Testcontainers, Playwright E2E, BenchmarkDotNet
- Decision Guide uses .NET-specific project names (
MyApp.Integration.Tests, Playwright.NET, Avalonia.Headless)
- State-vs-interaction example rewritten with xUnit + FluentAssertions and Moq counter-example
- DAMP example rewritten in C# with
[Theory]/[InlineData] (xUnit) and [DataRow] (MSTest) pointer for parameterized variation
- "Prefer Real Implementations" table mentions EF Core SQLite in-memory, Testcontainers, Moq/NSubstitute; added explicit warning against
Microsoft.EntityFrameworkCore.InMemory (no relational constraint enforcement)
- Added new "Use
TimeProvider for Time-Dependent Logic" section (.NET 8+ TimeProvider + FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing) — not in upstream
- Arrange-Act-Assert + One-Assertion-Per-Concept + Name-Tests-Descriptively examples rewritten with xUnit +
MethodUnderTest_Scenario_Expected naming convention
- Anti-patterns table adds:
EntityFrameworkCore.InMemory false confidence, .Result/.Wait() deadlocks, IClassFixture/ICollectionFixture scoping, Verify.Xunit snapshot abuse
- Removed upstream's "Browser Testing with DevTools" section entirely and replaced with a pointer to the new
integration-testing-dotnet skill (which covers the .NET equivalent)
- Subagent section uses
dotnet test --filter
- Added "See Also" links to xUnit docs, MSTest docs,
TimeProvider API reference
- Rationalizations table adds rows on EF Core InMemory and
DateTime.UtcNow vs TimeProvider
- Red-flag list adds
.Result/.Wait(), mocking DbContext, missing tests/ directory, [Fact(Skip = "…")]
- Verification checklist retargeted to
dotnet test, TimeProvider + FakeTimeProvider, real EF Core provider, --collect:"XPlat Code Coverage"
- Preserved verbatim: TDD cycle diagram, Step 1/2/3 structure, Prove-It Pattern flowchart, Test Pyramid diagram, Beyonce Rule, Arrange-Act-Assert frame, Common Rationalizations and Red Flags structure
- Downstream patches (applied after the initial sync; not tracked against upstream):
- 2026-04-19 (skill v1.0.1) — Added "Version Awareness: xUnit v2 vs v3, and Microsoft.Testing.Platform" section covering package-reference differences (
xunit vs xunit.v3), test-project-as-executable output in v3, VSTest vs MTP runner differences, dotnet run --project tests/… as a v3-only alternative to dotnet test, source-level API compatibility for [Fact] / Assert.* / IClassFixture<T>, MSTest's parallel MTP-native story via MSTest.Sdk. Added MTP docs to "See Also". Description updated to mention "(v2 or v3)" and "VSTest and Microsoft.Testing.Platform (MTP) runners".
- 2026-04-19 (skill v1.0.2, plugin v2.3.0) — FluentAssertions removed and xUnit v3 made canonical. Rationale: FluentAssertions v8 (January 2025) moved to the XCEED source-available license; only v7.x remains Apache 2.0, and recommending "FluentAssertions" without pinning nudges agents toward v8. Every sample now uses native
Xunit.Assert.X or MSTest Assert.X. The optional "FluentAssertions alternative" block after the RED step is deleted. Prove-It, State-vs-Interaction, DAMP, TimeProvider, and Arrange-Act-Assert samples rewritten — completed.CompletedAt.Should().NotBeNull() → Assert.NotNull(completed.CompletedAt), tasks.Data.Should().BeInDescendingOrder(...) → Assert.Equal(tasks.Data.OrderByDescending(...), tasks.Data), act.Should().Throw<E>().WithMessage("*X*") → var ex = Assert.Throws<E>(act); Assert.Contains("X", ex.Message), result.IsOverdue.Should().BeTrue() → Assert.True(result.IsOverdue). Test-pyramid diagram label changed xUnit / MSTest + FluentAssertions → xUnit v3 / MSTest + native Assert. Version Awareness "practical upshot" reframed — v3 is the current recommendation for new projects; v2 stays source-compatible for test authors.
- Upstream sync 2026-06-14 (plugin v2.6.0) — re-pinned to
3a6fc63. Ported the upstream "no redundant re-runs" guidance (a new rationalization row, a new red-flag, and a **Note:** after the Verification checklist): after a clean dotnet test run, re-running the same command on unchanged code adds no confidence. Retargeted from upstream's generic test wording to the dotnet CLI.
- License: MIT © 2025 Addy Osmani — see
../../LICENSES/agent-skills-MIT.txt