What to Test at Which Level: Behavior, Boundaries and Which Dependencies to Mock
Domain logic gets heavy unit testing, orchestration code a few integration tests; trivial code goes untested and overcomplicated code is refactored first. In tests your own database (managed) is real, while unmanaged dependencies such as payment and email are mocked. The real output of a mock-heavy test breaking on a behavior-preserving refactoring, risk-based prioritization and a one-page test strategy document.
Version note: The code in this post was built and run with .NET 10 (SDK 10.0.402), Entity Framework Core 10.0.14, xUnit.net v3 4.0.2 and NSubstitute 6.2.0.
Guess First
The Bookstore's OrderService checks stock, takes payment, lowers the stock and sends a confirmation email. There are two tests for it. The first mocks every dependency: the stock repository, the payment service, the email service. The second keeps the stock in a real SQLite database and mocks only payment and email. To stop two orders from selling the same last book, we turn the stock decrease into a single atomic UPDATE; from the point of view of the customer, the payment provider and the database, nothing changes. Which test breaks? Did the broken test catch a bug, or was it a false alarm?
Khorikov's Code Quadrant
Vladimir Khorikov (author of Unit Testing Principles, Practices, and Patterns, Manning, 2020) answers "what should I test at which level?" by placing code on two axes. The vertical axis is the code's complexity or domain significance: branching, computation, business rules. The horizontal axis is the number of collaborators: the other objects and systems the code talks to.

The top-left corner is the domain model and algorithms: complex code with few collaborators. In the Bookstore, DiscountPolicy.RateFor sits right here; it has a threshold, customer tiers and their combinations, but no database and no network. This code deserves heavy unit testing; in the previous post we verified eight boundary values in 51 milliseconds.
The bottom-right corner is controllers, meaning orchestration code: simple in itself but talking to many collaborators. OrderService.PlaceAsync reads the stock, calls payment, writes to the database and sends an email; the computation it leaves to DiscountPolicy. Unit testing this code means setting up a mock for every collaborator; a few integration tests are both cheaper and more trustworthy.
The bottom-left corner is trivial code: Book.Title { get; set; }, DTO (Data Transfer Object) records, one-line mappings. Don't test it. The top-right corner is overcomplicated code: both complex and full of collaborators. Picture an OrderManager.Process() that computes the discount, checks the stock, calls payment and sends the email in the same 200-line method. Every way of testing it is expensive. First you separate the computation into the domain and the coordination into a thin orchestration layer; then each part is tested in its own corner. The design side of that split is covered in the upcoming Unit Test series post "Testable Design: Dependency Injection, Seams, Functional Core and Humble Object".
Managed and Unmanaged Dependencies
You have decided to test the orchestration code with integration tests. Now the second question: which dependencies are real in the test and which are mocked? Khorikov splits out-of-process dependencies in two. A managed dependency is a system only your application accesses and that is fully under your control: the Bookstore's own database. Calls to it are invisible from the outside; what is visible from the outside is the database's final state. An unmanaged dependency is one that other systems also observe: the payment provider, the email server, a message bus that other services listen to. Every call to it is part of the system's externally observable behavior.
The rule follows: managed dependencies are real in tests, unmanaged dependencies are mocked. The Bookstore's integration test applies it line by line:
[Fact]
public async Task Placing_an_order_lowers_stock_charges_and_sends_email()
{
OrderService service = new(new EfStockRepository(_db), _payments, _email);
OrderResult result = await service.PlaceAsync("ada@example.com", 1, 2, TestContext.Current.CancellationToken);
Assert.Equal(OrderResult.Placed, result);
// managed dependency: read the outcome from the database's final state
Assert.Equal(3, await _db.Books.Where(b => b.Id == 1).Select(b => b.Stock).SingleAsync(TestContext.Current.CancellationToken));
// unmanaged dependencies: verify the calls visible from the outside
await _payments.Received(1).ChargeAsync("ada@example.com", 640m, Arg.Any<CancellationToken>());
await _email.Received(1).SendAsync("ada@example.com", "Your order for Clean Architecture is confirmed", Arg.Any<CancellationToken>());
}
_db is a real DbContext on an SQLite connection kept open in memory; _payments and _email are NSubstitute mocks. The two mocked calls are things the payment provider and the customer will actually see. The 640 TL charge and the email with the subject "Your order for Clean Architecture is confirmed" are a contract; verifying them is not coupling to the implementation. The details of test double types and mocking libraries are in the upcoming Unit Test series.
Implementation Detail or Observable Behavior?
Now the other test. In post 29 of the ASP.NET Core Web API (Application Programming Interface) series we mocked the repository with Moq while testing the service; a good start for learning to test a class in isolation. A test written with the same reflex looks like this:
[Fact]
public async Task Placing_an_order_saves_the_lowered_stock()
{
IStockRepository stock = Substitute.For<IStockRepository>();
IPaymentGateway payments = Substitute.For<IPaymentGateway>();
IEmailSender email = Substitute.For<IEmailSender>();
Book book = new() { Id = 1, Title = "Clean Architecture", Price = 320m, Stock = 5 };
stock.GetAsync(1, Arg.Any<CancellationToken>()).Returns(book);
payments.ChargeAsync("ada@example.com", 640m, Arg.Any<CancellationToken>()).Returns(new PaymentResult(true, "tx-1"));
OrderResult result = await new OrderService(stock, payments, email)
.PlaceAsync("ada@example.com", 1, 2, TestContext.Current.CancellationToken);
Assert.Equal(OrderResult.Placed, result);
await stock.Received(1).GetAsync(1, Arg.Any<CancellationToken>());
await stock.Received(1).SaveAsync(Arg.Is<Book>(b => b.Stock == 3), Arg.Any<CancellationToken>());
}
Both tests are green. Then we notice that two concurrent orders can sell the same last book: reading the stock, lowering it in memory and saving it is open to a second request slipping in between. We turn the stock decrease into a single conditional UPDATE:
public async Task<bool> TryDecreaseAsync(int bookId, int quantity, CancellationToken ct) =>
await db.Books
.Where(b => b.Id == bookId && b.Stock >= quantity)
.ExecuteUpdateAsync(s => s.SetProperty(b => b.Stock, b => b.Stock - quantity), ct) == 1;
In OrderService, the lines book.Stock -= quantity; await stock.SaveAsync(book, ct); are replaced with a call to TryDecreaseAsync. The customer gets the same result, the payment provider sees the same call, the stock in the database is still 3. The tests:
Bookstore.Ordering.Tests.OrderServiceMockTests.Placing_an_order_saves_the_lowered_stock [FAIL]
Bookstore.Ordering.Tests.OrderServiceMockTests.Placing_an_order_saves_the_lowered_stock [FINISHED] Time: 0.034s
Bookstore.Ordering.Tests.OrderServiceTests.Placing_an_order_lowers_stock_charges_and_sends_email [FINISHED] Time: 0.331s
Bookstore.Ordering.Tests Total: 2, Errors: 0, Failed: 1, Skipped: 0, Not Run: 0, Time: 0.372s
The integration test passed, the mocked test broke:
Assert.Equal() Failure: Values differ
Expected: Placed
Actual: OutOfStock
The mock has no answer it knows for the new method; NSubstitute returned the default false for TryDecreaseAsync and the service concluded the stock had run out. When we "fixed" the test by stubbing that method too, it broke somewhere else:
Xunit.MicrosoftTestingPlatform.XunitException: NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching:
SaveAsync(b => (b.Stock == 3), any CancellationToken)
Actually received no matching calls.
That is the answer to Guess First: the broken test caught no bug. What it verified was not the behavior but how the behavior happened: first GetAsync, then SaveAsync. Khorikov calls this a loss of resistance to refactoring and counts that resistance as one of the four pillars of a good test. Over time, a test like this makes a team say "tests break when we refactor, so let's not refactor," the exact opposite of why the test exists. The stock repository is an abstraction placed in front of a managed dependency; calls to it are invisible to everyone, so they don't need to be verified either.
Risk-Based Prioritization
The quadrant tells you the kind of code, but time is limited. Risk decides which behavior gets tested first and most deeply. For the Bookstore we ask four questions.
Is it money? The discount calculation, the payment amount, refunds. A one-cent rounding error is multiplied across thousands of orders. Is it security? Being able to see another customer's order, skipping an authorization check, not verifying the signature of a payment callback. Is it data loss? Stock going negative, an order saved halfway, a migration dropping a column. Does it change often? To see how many commits a file received in the last six months:
git log --since=6.months --name-only --format= | sort | uniq -c | sort -rn | head
Code that is both risky and frequently changed is where tests pay off the most. There are also things you should never test: the framework itself (Microsoft tests ASP.NET Core's routing and the SaveChanges of Entity Framework Core (EF Core)), auto properties, the behavior of a third-party library. You already use them indirectly while testing your own behavior.
Level Decisions for the Bookstore
Combining the quadrant, the managed/unmanaged distinction and risk, the decision table for four features comes out like this:
| Behavior | Kind of code | Level | Real | Mocked | Reason |
|---|---|---|---|---|---|
| Order discount | Domain | Unit (many, boundary values) | Everything | Nothing | Money, frequently changing campaign rules |
| Stock decrease | Orchestration | Integration (real database) | Database, repository | Payment, email | Data loss, concurrency |
| Payment callback | Orchestration + security | Integration + contract | Endpoint, signature verification, database | The payment provider itself | Money, security, the same callback arriving twice |
| Angular cart component | UI (User Interface) + small computation | Component test, one E2E (End-to-End) journey | Component, store, template | HTTP (Hypertext Transfer Protocol) | The total the user sees, the stock limit |
The payment callback is interesting in its own way: this time the unmanaged dependency calls us. Instead of connecting the provider to a test environment, we generate the payload and signature the provider documents and send them to our own endpoint in the test; a request with a broken signature must get 401, and when the same callback arrives a second time, the order must not be counted as paid twice. A contract test guarantees that the payload stays compatible with what the provider actually sends.
A One-Page Test Strategy Document
If these decisions are not written down, they are argued again on every pull request. A document at the root of the repo, no longer than a page, is enough:
# Bookstore test strategy (v1, 2026-11)
## Levels
| Level | What | Size | Tool | Runs | Owner |
|-------------|------------------------------------------|--------|--------------------------|----------------|--------------|
| Static | all code | - | Nullable, analyzers, tsc | every build | everyone |
| Unit | domain rules (pricing, stock rules) | small | xUnit.net v3 | every commit | feature team |
| Component | Angular components | small | Vitest + TestBed | every commit | web team |
| Integration | API + real database, fake payment/email | medium | WebApplicationFactory | every PR | feature team |
| Contract | web <-> API, payment callback payloads | medium | Pact | every PR | API + web |
| E2E | 5 critical journeys | medium | Playwright | before deploy | QA + teams |
| Smoke | /health, /api/books | large | smoke.sh | after deploy | platform |
## Rules
- Mock only unmanaged dependencies (payment, email, message bus). The database is real.
- Every production bug gets a regression test at the lowest level that reproduces it.
- No tests for trivial code (DTOs, auto properties, framework behavior).
- A flaky test is quarantined within 24 hours; its owner fixes or deletes it within a week.
## Covered in production, not by tests
- Payment provider outages: alert on payment error rate above 2% for 5 minutes.
- New checkout flow ships behind the "checkout-v2" feature flag.
The document's value is in the rules more than in the table. The "the database is real" rule stops the InMemory provider trap from the previous post and the brittle mock test above with a single sentence in code review. The owner column matters too: a flaky test with no owner is nobody's problem.
In Production
A test is a prediction made before production; however good it is, it cannot see real traffic, real data or the real behavior of third-party services. The shift-right approach deliberately moves part of the confidence into production. Production monitoring watches business metrics such as the payment error rate and raises an alert when a threshold is crossed; that is the "2 percent, 5 minutes" line in the strategy document. Synthetic monitoring runs the smoke test every few minutes instead of only at deploy time. A feature flag lets you open the new payment flow to a 5 percent group of users first and switch it off without touching the code if the metrics get worse; the mechanics are in the Deployment Strategies series post "Feature Flags: Decoupling Deploy from Release with Microsoft.FeatureManagement and OpenFeature".
Shift-right is not an excuse to test less. Monitoring sees a bug after the customer has lived through it; a test sees it before. Both answer the same question at different times: the strategy document should state clearly which risk is left to which.
Review the decisions together with the code. Code that moves to another corner of the quadrant changes the level of its test too: if OrderService one day pulls the discount calculation into itself, you need to refactor until that calculation can be unit tested again.
Common Pitfalls
A test class for every class, a mock for every collaborator. This reflex couples orchestration code to implementation details; as we saw above, a refactoring that changes no behavior turns things red in two different places.
Mocking your own database. Mocking the repository leaves the one place where a thin API can break outside the test. The database is a managed dependency; keep it real.
Calling an unmanaged dependency for real in tests. A test that hits the payment provider's sandbox on every pull request slows your pipeline whenever the provider slows down, and it can pollute another team's test data.
Testing trivial code. A test that assigns a value to Book's Title property and reads it back raises coverage, not confidence. What coverage really measures is the topic of the next post.
Trying to test overcomplicated code as it is. Instead of wrapping a 200-line method in forty mocks, first split it into the right corners of the quadrant.
A Small Experiment
★☆☆ Place five classes from your own project on the quadrant. How many of them landed in the top-right corner?
★★☆ List the external dependencies in your project and mark each one as managed or unmanaged. Find the tests that mock a managed dependency and rewrite one of them with a real database.
★★★ Write a one-page test strategy document for your team: a levels table, at least four rules and a "risks covered in production, not by tests" section. Review it with the team in a pull request.
We know what to test at which level. But how do we know the tests we wrote actually verify anything? In the next post we look at what line and branch coverage tell you and what they don't, and how mutation testing closes that gap.