When Not to Mock: Over-Mocking, Brittle Tests and "Don't Mock What You Don't Own"
Mocks belong only at the system boundary, for unmanaged dependencies observable from outside; domain objects and the application's own database are not mocked. A mocked exchange rate adapter kept tests green while production showed 100 EUR as 0 TRY; an HttpMessageHandler stub caught the bug on the first run. Output, state and communication test styles and the managed vs unmanaged distinction.
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
Bookstore starts showing prices in TRY (Turkish lira) as well. The exchange rate comes from an external provider. The team has put the provider behind an interface, written the adapter and tested the price converter:
public interface IExchangeRates
{
Task<decimal> GetRateAsync(string from, string to, CancellationToken ct);
}
// Adapter: the single point of contact with the exchange rate API we do not own
public sealed class ExchangeRateClient(HttpClient http) : IExchangeRates
{
public async Task<decimal> GetRateAsync(string from, string to, CancellationToken ct)
{
RateResponse? body = await http.GetFromJsonAsync<RateResponse>($"latest?base={from}&symbols={to}", ct);
return body?.Rate ?? throw new InvalidOperationException("Empty rate response.");
}
private sealed record RateResponse(decimal Rate);
}
public sealed class PriceConverter(IExchangeRates rates)
{
public async Task<decimal> ToLocalAsync(decimal priceInEur, string currency, CancellationToken ct)
{
decimal rate = await rates.GetRateAsync("EUR", currency, ct);
return Math.Round(priceInEur * rate, 2, MidpointRounding.AwayFromZero);
}
}
[Fact]
public async Task Price_is_converted_with_the_current_rate()
{
IExchangeRates rates = Substitute.For<IExchangeRates>();
rates.GetRateAsync("EUR", "TRY", Arg.Any<CancellationToken>()).Returns(48.73m);
PriceConverter sut = new(rates);
decimal price = await sut.ToLocalAsync(100m, "TRY", TestContext.Current.CancellationToken);
Assert.Equal(4873.00m, price);
}
The test is green. The provider's response to the latest?base=EUR&symbols=TRY call has this shape:
{ "base": "EUR", "date": "2026-11-06", "rates": { "TRY": 48.73 } }
In production, how many TRY does a 100 EUR book show on the site?
The Answer: 0 TRY
When I ran the adapter without mocking it, with a test that fakes only the HTTP (HyperText Transfer Protocol) call underneath:
failed Bookstore.Pricing.Tests.ExchangeRateClientTests.Reads_the_rate_from_the_provider_response (1ms)
Assert.Equal() Failure: Values differ
Expected: 48.73
Actual: 0
RateResponse expects a rate field at the top level of the response; in the real response the rate sits inside the rates dictionary. System.Text.Json does not treat the missing property as an error and fills Rate with its default value, 0; and ?? throw never runs because body is not null. The result: 100 EUR × 0 = 0 TRY. The mock in PriceConverterTests imitated the team's assumption about what the adapter would return, and that assumption was wrong. Tests green, production red. This post separates where a mock adds value from where it produces this kind of illusion.
Three Test Styles: Output, State and Communication
Khorikov splits unit tests into three styles by the way they verify:
public sealed class TestStyleTests
{
private const string Ddd = "978-0321125217";
// Output-based: give input, check the return value
[Fact]
public void Orders_below_50_pay_standard_shipping() =>
Assert.Equal(4.99m, ShippingFee.For(49.99m));
// State-based: run the operation, check the object's state
[Fact]
public void Adding_the_same_book_twice_sums_quantities()
{
Cart cart = new();
cart.Add(Ddd, 1);
cart.Add(Ddd, 2);
Assert.Equal(3, cart.Items[Ddd]);
}
// Communication-based: verify the call that crosses the system boundary
[Fact]
public async Task Taking_stock_below_three_alerts_purchasing()
{
IStockAlerts alerts = Substitute.For<IStockAlerts>();
StockKeeper keeper = new(alerts);
keeper.Receive(Ddd, 5);
await keeper.TakeAsync(Ddd, 3, TestContext.Current.CancellationToken);
await alerts.Received(1).LowStockAsync(Ddd, 2, Arg.Any<CancellationToken>());
}
}
An output-based test gives input to a function without side effects and checks the return value. This is the style most resistant to refactoring: however the inside of the function changes, if the same input gives the same output the test stays green. A state-based test runs an operation and checks the subsequent state of the object (or its collaborator). If the state is read through the public API (Application Programming Interface), it is resistant as well. A communication-based test verifies, with a mock, the calls the code under test makes to its collaborators. It is the most brittle of the three: when the name, arguments or count of a call change, the test breaks even if the behavior is the same.
This is an ordering: output if possible, state if not, communication only when needed. But communication-based testing is not a wrong style. IStockAlerts is a notification to the purchasing team; "purchasing is notified when stock drops below 3" is a business rule, and that call is its only externally observable result. The question is which calls are observable behavior and which are implementation details.
Mocks Only at the System Boundary: Managed and Unmanaged Dependencies
Khorikov's distinction answers this question. A dependency is unmanaged if it is observable from outside the application and shared with other systems: the payment provider, email, SMS (Short Message Service), the message bus, other teams' APIs. The conversation with them is the application's contract with the outside world and should be verified with mocks. A dependency is managed if only your application accesses it; the typical example is the application's own database. Calls to it are implementation details: what matters is not in which order you wrote to the tables but what ends up in the database. Managed dependencies are not mocked; they are tested with the real thing in integration tests.
| Dependency | Kind | In a unit test |
|---|---|---|
Order, Cart, PriceCalculator | Domain code, in-process | The real one is used, never mocked |
The application's own database (DbContext) | Managed, out-of-process | Not mocked; tested for real in integration tests |
| Payment, email, purchasing notification | Unmanaged | Verified with a mock or a spy |
| Exchange rate provider (read only) | Unmanaged, query | Supplies data through a stub, its call is not verified |
TimeProvider, randomness | In-process but non-deterministic | Controlled with a fake |
The table is the unit test side of the "what to test at which level" question in the Test Stratejisi (Test Strategy) series. Domain objects are never mocked: a mock of Order cuts the test off from Order's real behavior, and a bug in Order shows up in no test.
HttpClient: An HttpMessageHandler Stub and Your Own Adapter
The "don't mock what you don't own" rule comes from Steve Freeman and Nat Pryce's Growing Object-Oriented Software, Guided by Tests (GOOS, 2009). The reason is what we saw in Predict First: when you mock someone else's API, you test your own assumption about how that API behaves. If the assumption is wrong, the test will never tell you.
In practice the rule has two layers. First, put your own interface in front of what you do not own: IExchangeRates speaks Bookstore's language ("what is the EUR/TRY rate?") and is stubbed when testing code like PriceConverter. Second, test the adapter itself against the real shape of the foreign API. HttpClient's methods are not virtual, but every request goes through an HttpMessageHandler, and that can be faked:
// Fake the handler underneath, not HttpClient itself
public sealed class StubHttpMessageHandler(HttpStatusCode status, string json) : HttpMessageHandler
{
public List<Uri?> RequestedUris { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
RequestedUris.Add(request.RequestUri);
return Task.FromResult(new HttpResponseMessage(status)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
});
}
}
public sealed class ExchangeRateClientTests
{
// The provider's response shape: copied from its docs or from a recorded real response
private const string LatestEurTry = """
{ "base": "EUR", "date": "2026-11-06", "rates": { "TRY": 48.73 } }
""";
private static ExchangeRateClient CreateClient(StubHttpMessageHandler handler) =>
new(new HttpClient(handler) { BaseAddress = new Uri("https://rates.example.com/") });
[Fact]
public async Task Reads_the_rate_from_the_provider_response()
{
ExchangeRateClient client = CreateClient(new StubHttpMessageHandler(HttpStatusCode.OK, LatestEurTry));
decimal rate = await client.GetRateAsync("EUR", "TRY", TestContext.Current.CancellationToken);
Assert.Equal(48.73m, rate);
}
[Fact]
public async Task Requests_latest_rate_for_the_currency_pair()
{
StubHttpMessageHandler handler = new(HttpStatusCode.OK, LatestEurTry);
await CreateClient(handler).GetRateAsync("EUR", "TRY", TestContext.Current.CancellationToken);
Uri? uri = Assert.Single(handler.RequestedUris);
Assert.Equal("https://rates.example.com/latest?base=EUR&symbols=TRY", uri?.ToString());
}
[Fact]
public async Task Provider_outage_surfaces_as_http_request_exception()
{
ExchangeRateClient client = CreateClient(new StubHttpMessageHandler(HttpStatusCode.ServiceUnavailable, "{}"));
HttpRequestException ex = await Assert.ThrowsAsync<HttpRequestException>(
() => client.GetRateAsync("EUR", "TRY", TestContext.Current.CancellationToken));
Assert.Equal(HttpStatusCode.ServiceUnavailable, ex.StatusCode);
}
}
The first test caught the bug. The fixed adapter reads the real shape of the response and throws an exception if the rate is missing instead of silently returning 0:
public async Task<decimal> GetRateAsync(string from, string to, CancellationToken ct)
{
LatestRates? body = await http.GetFromJsonAsync<LatestRates>($"latest?base={from}&symbols={to}", ct);
if (body?.Rates is null || !body.Rates.TryGetValue(to, out decimal rate))
{
throw new InvalidOperationException($"No {from}/{to} rate in provider response.");
}
return rate;
}
private sealed record LatestRates(string Base, Dictionary<string, decimal>? Rates);
All four tests are green. These tests run in-process, in milliseconds; there is no network. But they are not enough on their own: if the provider changes its response shape tomorrow, the recorded JSON (JavaScript Object Notation) goes stale. Protecting the adapter with a few integration tests against the real provider (or its sandbox) and consumer-driven contract testing are the topic of the Integration ve E2E (end-to-end) Test series. A unit test guarantees that the adapter reads the shape we know correctly; it does not guarantee that the shape is still the same.
Writing a mockable IHttpClientWrapper for this test is a common path too, but it moves the same problem one level down: the wrapper's mock is still an assumption about the provider's response. The handler stub runs the real HttpClient, the real JSON deserialization and the real status code handling; the only thing faked is the network.
Do Not Mock DbContext or Domain Objects
Mocking DbContext and DbSet<T> is technically possible, but the result is a test in which LINQ (Language Integrated Query) queries run in memory, and the real SQL (Structured Query Language) translation, relationships, unique indexes and transactions are never exercised. Post 22 of the Entity Framework Core (EF Core) series describes the same pitfalls of the InMemory provider and the alternatives of SQLite in-memory and a real database. The application's own database is a managed dependency; test the code that uses it either behind a repository or with the real database in an integration test.
For domain objects the rule is simpler: never mock them. Even if Order had an interface, setting up Substitute.For<IOrder>() so that Total returns 96 removes from the test how Order calculates its total. If creating a real Order is tedious, the answer is not a mock but the test data builders in post 10.
In Production
Keep response samples together with their source. Instead of inventing the JSON in adapter tests, take it from the provider's documentation or a recorded real response, and note next to the file where it came from. When the provider changes versions, the place to update is obvious.
Keep the adapter thin. Business rules (rounding, currency choice, cache duration) belong not in the adapter but in the code that uses it. A few handler tests and a few integration tests are enough for a thin adapter; a thick adapter moves business rules that cannot be mocked to the system boundary.
For adapters used with IHttpClientFactory, the handler stub works the same way; the resilience handlers in production (retry, circuit breaker) are a separate topic. It makes more sense to test their behavior not in unit tests but against a real or emulated endpoint.
Common Pitfalls
"Everything mocked, tests green, production red". Predict First itself. A service test with all collaborators mocked says nothing about the real behavior of those collaborators. A unit test suite needs to touch reality at least once for every boundary: an adapter test, an integration test or a contract test.
Verifying queries. Writing rates.Received(1).GetRateAsync(...) is the mistake of asserting on a stub from the previous post. What matters is not how the rate was obtained but that the price was calculated correctly. The day a cache is added in front of the rate, this line raises a false alarm.
Mocking domain objects. Covered above; one of the most expensive mistakes, because the tests never run the domain itself.
Going to the real external service while running away from mocks. The message of this post is "mock less", but the answer is not calling the real exchange rate provider in a unit test. A test that goes to the network is a slow, non-deterministic integration test that eats into the provider's quota; it belongs in a separate project and a separate pipeline step.
A Small Experiment
★☆☆ Add two tests to ExchangeRateClientTests: what does the adapter do when the response is "rates": {}, and when the body is an empty JSON object? Do both give a meaningful exception?
★★☆ Rewrite PriceConverterTests with the real ExchangeRateClient and StubHttpMessageHandler instead of NSubstitute. Did the test get longer? Would this version have caught the bug from Predict First?
★★★ Pick the test class in your own project that uses the most mocks. Put each mock into one of the kinds in the table: domain, managed, unmanaged command, unmanaged query. Replace the domain and managed ones with the real thing or a fake, and turn queries into stubs. How many Received/Verify lines are left?
In the next post we move to the real way of reducing the need for mocks: testable design. Constructor injection, seams, TimeProvider, functional core / imperative shell and Humble Object.