What Is the "Unit" in Unit Testing? Classical vs London School and the Four Pillars of a Good Test
A unit is a behavior, not a method or a class; the classical school, which uses test doubles only at the system boundary, produces tests that resist refactoring better than the London school, which mocks every collaborator. Solitary vs sociable tests, Khorikov's four pillars of a good test, and real output showing how a behavior-preserving change breaks a mocked test.
Version note: The code in this post was built and run with .NET 10, xUnit.net v3 4.0.2 and NSubstitute 6.2.0.
Predict First
In the Bookstore order flow, OrderService does two things: it checks stock for every line and, if everything is available, charges the subtotal through the payment system. Two different teams wrote two different tests for this class. First, the code under test:
namespace Bookstore.Ordering;
public interface IInventory
{
bool IsInStock(string isbn, int quantity);
int Available(string isbn);
}
public sealed class Inventory : IInventory
{
private readonly Dictionary<string, int> stock = [];
public void Receive(string isbn, int quantity) => stock[isbn] = Available(isbn) + quantity;
public int Available(string isbn) => stock.GetValueOrDefault(isbn);
public bool IsInStock(string isbn, int quantity) => Available(isbn) >= quantity;
}
public interface IPaymentGateway
{
Task<bool> ChargeAsync(Guid orderId, decimal amount, CancellationToken ct);
}
public enum PlaceOrderResult { Placed, OutOfStock, PaymentDeclined }
public sealed class OrderService(IInventory inventory, IPaymentGateway payments)
{
public async Task<PlaceOrderResult> PlaceAsync(Order order, CancellationToken ct = default)
{
foreach (OrderLine line in order.Lines)
{
if (!inventory.IsInStock(line.Book.Isbn, line.Quantity))
{
return PlaceOrderResult.OutOfStock;
}
}
bool charged = await payments.ChargeAsync(order.Id, order.Subtotal, ct);
return charged ? PlaceOrderResult.Placed : PlaceOrderResult.PaymentDeclined;
}
}
The first test replaces every collaborator with NSubstitute and verifies which method was called with which arguments:
public sealed class OrderServiceLondonTests
{
private static readonly Book Ddd = new("978-0321125217", "Domain-Driven Design", 55m);
[Fact]
public async Task PlaceAsync_ChecksStockAndChargesSubtotal()
{
IInventory inventory = Substitute.For<IInventory>();
inventory.IsInStock(Ddd.Isbn, 2).Returns(true);
IPaymentGateway payments = Substitute.For<IPaymentGateway>();
payments.ChargeAsync(Arg.Any<Guid>(), Arg.Any<decimal>(), Arg.Any<CancellationToken>()).Returns(true);
Order order = new(Guid.NewGuid(), "ada@example.com");
order.AddLine(Ddd, 2);
OrderService sut = new(inventory, payments);
PlaceOrderResult result = await sut.PlaceAsync(order, TestContext.Current.CancellationToken);
Assert.Equal(PlaceOrderResult.Placed, result);
inventory.Received(1).IsInStock(Ddd.Isbn, 2);
await payments.Received(1).ChargeAsync(order.Id, 110m, Arg.Any<CancellationToken>());
}
}
The second test uses the real Inventory class and only replaces the payment system with a small hand-written fake:
public sealed class OrderServiceClassicalTests
{
private static readonly Book Ddd = new("978-0321125217", "Domain-Driven Design", 55m);
[Fact]
public async Task Order_with_enough_stock_is_placed_and_charged_once()
{
Inventory inventory = new();
inventory.Receive(Ddd.Isbn, 5);
FakePaymentGateway payments = new(approve: true);
Order order = new(Guid.NewGuid(), "ada@example.com");
order.AddLine(Ddd, 2);
OrderService sut = new(inventory, payments);
PlaceOrderResult result = await sut.PlaceAsync(order, TestContext.Current.CancellationToken);
Assert.Equal(PlaceOrderResult.Placed, result);
Assert.Equal([(order.Id, 110m)], payments.Charges);
}
}
// Payment sits at the system boundary: a simple hand-written fake
public sealed class FakePaymentGateway(bool approve) : IPaymentGateway
{
public List<(Guid OrderId, decimal Amount)> Charges { get; } = [];
public Task<bool> ChargeAsync(Guid orderId, decimal amount, CancellationToken ct)
{
Charges.Add((orderId, amount));
return Task.FromResult(approve);
}
}
Both are green. Then a teammate notices that the same book can arrive on two separate lines of an order and changes the stock check: lines are grouped by ISBN (International Standard Book Number) and the total quantity is compared with Available. For a single-line order, the behavior stays exactly the same:
// Refactoring: the same book can appear on two lines, sum the quantities first
IEnumerable<(string Isbn, int Quantity)> required = order.Lines
.GroupBy(l => l.Book.Isbn)
.Select(g => (Isbn: g.Key, Quantity: g.Sum(l => l.Quantity)));
if (required.Any(r => inventory.Available(r.Isbn) < r.Quantity))
{
return PlaceOrderResult.OutOfStock;
}
Which test turns red? The first, the second, both or neither? Pick an answer before reading on.
The Answer: Same Behavior, Red Test
The dotnet test output (file paths shortened):
failed Bookstore.Ordering.Tests.OrderServiceLondonTests.PlaceAsync_ChecksStockAndChargesSubtotal (28ms)
from …/p01/Bookstore.Ordering.Tests/bin/Debug/net10.0/Bookstore.Ordering.Tests.dll (net10.0|arm64)
Assert.Equal() Failure: Values differ
Expected: Placed
Actual: OutOfStock
at Bookstore.Ordering.Tests.OrderServiceLondonTests.PlaceAsync_ChecksStockAndChargesSubtotal() in …/OrderServiceTests.cs:23
Test run summary: Failed!
total: 3
failed: 1
succeeded: 2
skipped: 0
duration: 323ms
The mocked test broke; the test that uses the real Inventory stayed green. The code still works: an order for 2 books against a stock of 5 is still placed. The red comes from this: the IInventory that NSubstitute generates returns default values for every call that was not configured. The test only configured IsInStock; the new code called Available, got 0 and rejected the order as out of stock.
The easy way to "fix" the test is obvious: add a Returns for Available as well. But then the Received(1).IsInStock(...) line in the test breaks, because that method is no longer called at all. The test locked in how OrderService does its job, not what it does. Every internal change will drag the test along. This is the first example of a problem we will come back to throughout the series: what a test checks decides how long it lives.
What Is a "Unit"? Method, Class, Behavior
There is no definition of unit test that everyone agrees on. Martin Fowler says so plainly in his 2014 UnitTest article: the term is loosely defined, and people tend to think it is more precise than it is. The common ground is this: unit tests focus on a low level, developers write them with their own tools, and they run significantly faster than other kinds of tests.
There are three common answers about how big a "unit" is. The first is "a method": one or a few tests per public method. The second is "a class": one test class per class, with all of that class's collaborators replaced by test doubles. The third is "a behavior": a test that verifies a business rule or a use case, running several classes together if necessary. Fowler writes that in object-oriented design the class is usually taken as the unit, but that he often treats a group of closely related classes as a single unit; he leaves the decision to the team.
Vladimir Khorikov's Unit Testing Principles, Practices, and Patterns (Manning, 2020) offers a more useful frame. A unit test does three things: it verifies a small piece of code (a unit of behavior), it runs quickly, and it runs in isolation from other tests. The real debate is about the word "isolation": should the code under test be isolated from its collaborators, or should the tests be isolated from each other?
The answer we will use in this series: a unit is a behavior. If one class carries that behavior on its own, the unit is a class; if the behavior is spread across Order, Inventory and OrderService, a test that runs all three together is still a unit test. The cost and benefit of this choice become clear in the two distinctions below.
Solitary and Sociable Tests
The distinction Fowler highlights in his article comes from Jay Fields (author of Working Effectively with Unit Tests). A solitary test replaces all collaborators of the unit under test with test doubles; the unit stands alone. A sociable test uses the real collaborators and assumes they work correctly.
In our example, OrderServiceLondonTests is solitary: both IInventory and IPaymentGateway are fakes. OrderServiceClassicalTests is sociable: Inventory is real, only the payment is fake. Fowler points out an interesting detail: most tests written in the early years of the xUnit family were sociable, and nobody called them "not unit tests". As long as a test still targets the behavior of one unit, using real collaborators does not make it an integration test.
The sociable test has a known cost: if Inventory has a bug, every test that uses it turns red. One bug can break hundreds of tests at once, and finding the source takes a while. In a solitary test, the red test points straight at the culprit. But the output at the top of this post shows the solitary test's price too: a change that did not break behavior broke the test.
The Classical (Detroit) and London (Mockist) Schools
The choice between solitary and sociable is known in the unit testing world as two "schools". The classical school (also called the Detroit or Chicago school) comes from Kent Beck's Extreme Programming (XP) circle; Beck's Test-Driven Development by Example (2002) reflects this style. The London school came out of the XP community in London and was established by Steve Freeman and Nat Pryce's Growing Object-Oriented Software, Guided by Tests (GOOS, 2009). Fowler's "Mocks Aren't Stubs" article calls the second group "mockist".
The difference between the two schools fits in a few rows:
| Question | Classical (Detroit) | London (mockist) |
|---|---|---|
| What is a unit? | A behavior; one or several classes | A class |
| What gets a test double? | Only shared or slow dependencies (database, payment, email) | Every collaborator that carries mutable state |
| What is verified? | The result and observable state | The conversation with collaborators |
| When there is a bug | Several tests may break | Usually a single test breaks |
| When refactoring | Tests stay green if behavior is unchanged | Tests break if internal calls changed |
The London school's strength deserves to be stated fairly: in GOOS, mocks are a design tool. While writing the test, you discover the collaborator's interface and derive role definitions starting from the test. In large, nested object graphs, seeing what broke from a single red test is also a real advantage. The trouble starts when this style is applied to every collaborator. When you mock a collaborator like Inventory, which runs in-process and is your own code, you turn the conversation between two classes into a contract, and that conversation changes with every refactoring.
In this series we will stay close to the classical school: test doubles only at the system boundary, for dependencies that are truly slow or out of our control. The details of which dependency to mock are the topic of post 08.
The Four Pillars of a Good Test
Khorikov defines a good unit test through four attributes. They take the question "is this test good?" out of the realm of taste:
- Protection against regressions: the likelihood that the test turns red when the code breaks. The more code, and the more important code, it exercises, the higher it is.
- Resistance to refactoring: the test stays green when internal changes do not break behavior. A test that scores low here produces false positives, that is, false alarms.
- Fast feedback: how quickly the test runs. A test that finishes in milliseconds runs on every save; a test that takes minutes gets pushed aside.
- Maintainability: the cost of reading, understanding and running the test. Long setup chains and tests that need external processes lose points here.
All four cannot be maximized at the same time. An end-to-end (E2E) test gives very good protection against regressions and is resistant to refactoring, but it is slow. A trivial test that checks a getter is fast and resistant, but protects almost nothing. Khorikov's key observation is that resistance to refactoring is non-negotiable: a test either has it or it does not, and when it does not, the team eventually stops taking red tests seriously. The balance is struck consciously among the remaining three.
Let us look at the two tests from the beginning through this lens. Their protection against regressions is the same: they run the same code and check the same result. Both are fast. But the London test has no resistance to refactoring; it raised a false alarm at the first internal change. On maintainability, too, a seven-line setup loses to a five-line one.
Microsoft's "Best practices for writing unit tests" document gives the same idea in different words: a good test should be fast, isolated, repeatable, self-checking and timely. The strategic questions about which test to write at which level, that is, the test pyramid and what coverage really means, are covered by the Test Stratejisi (Test Strategy) series; this series works through the unit test side of those decisions.
Bookstore Throughout the Series
All examples in this series take place on the ordering side of the site's Bookstore application. These are the pieces we will use:
Order, OrderLine and Book: the order itself; subtotal and the rules for adding lines. Pure domain code that needs no test doubles. Discount rules: threshold amount, loyalty discount, coupon. We will test them with boundary values in post 04 and as pure functions in post 09. Inventory: stock levels and reservations. Our own code, running in-process. OrderService: the orchestration layer that drives the order flow. External services: IPaymentGateway (payment), IEmailSender (email), IShippingService (shipping). These sit at the system boundary, where test doubles are really needed.
The series does not repeat the xUnit and Moq basics from post 29 of the ASP.NET Core Web API (Application Programming Interface) series or the [Fact]/[Theory] introduction from post 14 of the C# İleri (Advanced C#) series; it deepens them with xUnit.net v3 and 4.0. The next post sets up the test project from scratch.
In Production
The best signal of a test suite's health is what happens when the team refactors. If the code change is small but the number of changed test files is large, the tests have locked in implementation, not behavior. When pull requests start showing "12 lines changed in production code, 140 in tests", the topic of this post has reached you too.
The second signal is how people react to red. If the first reflex on seeing a red test is "the test is broken, let's update it", the tests are producing false alarms. As the false alarm rate grows, the team starts ignoring real alarms too. A test suite's value is measured by whether red is taken seriously.
The third is duration. A unit test suite should finish in a few seconds for a few thousand tests. The measure Fowler cites as Kent Beck's rule is that the commit suite should not exceed ten minutes; unit tests should use a small part of that budget. When unit tests slow down, the cause is usually hidden I/O: a real file system, network or database call.
Common Pitfalls
The one-test-class-per-class reflex. OrderServiceTests for OrderService, InventoryTests for Inventory, OrderLineTests for OrderLine... This structure ties test files to the class structure; when you split one class in two or merge two classes, the tests have to be reorganized too. Grouping test classes by behavior (placing an order, insufficient stock, payment declined) lasts longer. Post 03 covers this together with naming.
The one-mock-per-collaborator reflex. Typing Substitute.For the moment you see an interface in a constructor. This reflex is exactly what produced the red test above. First ask: "can this collaborator be used as-is in the test?" Does it run in-process, is it fast, is it deterministic? If the answer is yes to all three, use the real one.
The "this is not a unit test because two classes run" debate. The unit being a class is not a rule; it is one school's preference. A sociable test that is fast, isolated and deterministic is a unit test. Spend the time you would spend on naming debates on discussing which of the four pillars a test is weak in.
Treating test count as a quality metric. 400 solitary tests may not give more confidence than 80 sociable ones; on the contrary, every refactoring forces you to update some of those 400. What matters is not the count but each test's balance across the four pillars.
A Small Experiment
★☆☆ Make OrderServiceLondonTests green against the refactored code without using any Received lines. What did you lose in protection against regressions, and what did you gain in resistance to refactoring?
★★☆ Go back to the pre-refactoring code and add this scenario to the classical test: there is 1 copy of "Domain-Driven Design" in stock, and the order asks for the same book on two separate lines, one copy each. Does the old code accept this order? Run the same test against the refactored code.
★★★ Pick a service test class from your own project. Score each test from 1 to 3 on the four pillars and note the lowest-scoring pillar. For the tests with low resistance to refactoring, which collaborator's mock is causing it?
In the next post we build the ground these tests run on: the xUnit.net v3 template, the executable test project, dotnet test with Microsoft Testing Platform, and the changes that came with 4.0.