Test-driven development is a design loop: write a failing test for a behavior you want, write the minimum code to pass, then refactor under the green bar. The tests are a by-product; the point is a design that is callable without the whole world standing up.
Red–green–refactor
- Red: a test that names a behavior and fails for the right reason (not a compile typo you ignore).
- Green: ugly is allowed. Fake it, hardcode, duplicate.
- Refactor: now you have coverage, remove duplication, name things. Do not add behavior here.
Skip the loop and you are just “writing tests after,” which is still useful but is not TDD.
What to drive
Domain rules, parsers, state machines, pricing. Not SaveChanges to a real cloud database on every save (that is an integration test; write it, just not in the inner loop). Not GUI pixel tests.
Outside-in vs inside-out
Inside-out: start at a function. Outside-in: start at an acceptance test (often BDD-shaped) and fake inward until the unit tests take over. Pick one per slice so you do not duplicate the same rule at three layers.
Example
Red came first: RejectsWhenAmountExceedsBalance failed because TryTransfer still returned true. Green is the check below. Refactor does not add a second rule.
static bool TryTransfer(decimal balance, decimal amount, out decimal next)
{
if (amount > balance)
{
next = balance;
return false;
}
next = balance - amount;
return true;
}
static void RejectsWhenAmountExceedsBalance()
{
var ok = TryTransfer(40m, 50m, out var next);
if (ok || next != 40m)
throw new Exception("amount above balance must be rejected");
}
What breaks it
The only assertion is that a spy Save was called. Transferring 50 from a balance of 40 still passes. Assert the rejected transfer, as the test above does.
sealed class SpyOrders
{
public int Saves;
public void Save() => Saves++;
}
sealed class TransferService
{
private readonly SpyOrders orders;
public TransferService(SpyOrders orders) => this.orders = orders;
public void Transfer(decimal balance, decimal amount) => orders.Save();
}
static void OnlyChecksTheMock()
{
var orders = new SpyOrders();
new TransferService(orders).Transfer(40m, 50m);
if (orders.Saves != 1)
throw new Exception("mock was not called");
}
Pitfalls
- Testing implementations (
verify mock.calledon every collaborator) so refactors break tests that should have stayed green. - A test per getter.
- No refactor step — TDD without refactor is a mess with coverage.
- TDD as a mandate on throwaway scripts.
BDD is the same family with a shared language for the outer tests: BDD.