Choosing Sociable or Solitary Unit Tests
A unit test fails after a harmless refactor. The production behaviour is still correct, but the test expected an internal collaborator to receive exactly three calls. Elsewhere, a different unit test passes even though two real classes no longer work together, because both sides were replaced with mocks.
Both tests are isolated in some sense, yet they give poor feedback for different reasons. The useful question isn’t simply whether unit tests should use mocks. It is where the test boundary should be.
This article develops a practical way to choose between sociable and solitary unit tests. You’ll learn when to keep real in-process collaborators inside a test, when to replace a dependency with a test double, and how that choice changes the failures your test suite can detect.
The test boundary determines what evidence you get
A unit test checks a small unit of behaviour quickly and with controlled inputs. The disputed part is what counts as the unit.
A solitary unit test treats one object or function as the unit and usually replaces its collaborators with test doubles. A sociable unit test allows the subject to collaborate with real, lightweight objects in the same process while still excluding expensive or uncontrolled boundaries such as networks, clocks, or external services.
Consider an order-pricing service:
PricingService
-> DiscountPolicy
-> TaxPolicyA solitary test might construct PricingService with fake or mock versions of both policies. It can then verify how PricingService behaves for precisely controlled collaborator responses.
A sociable test might use the real DiscountPolicy and TaxPolicy, treating the three objects together as the tested unit. It verifies the result produced by their collaboration.
Neither choice is inherently more rigorous. They answer different questions.
The solitary test asks, “Does this component behave correctly given these collaborator responses?” The sociable test asks, “Do these components produce the expected behaviour when they work together?”
That distinction is the core mental model: every replacement of a real collaborator removes some integration from the test and gives you more control in exchange.
Start with the smallest useful example
Suppose checkout calculates a subtotal and then applies a discount policy.
function checkoutTotal(items, discountPolicy):
subtotal = sumPrices(items)
discount = discountPolicy.discountFor(subtotal)
return subtotal - discountA solitary test can provide a stub policy:
discountPolicy returns 10
total = checkoutTotal([40, 60], discountPolicy)
assert total == 90This test isolates the arithmetic performed by checkoutTotal. If the real discount rules are complicated, the stub lets the test focus on one decision at a time.
Now imagine the real policy says orders of 100 or more receive a discount of 10. A sociable test can use that policy directly:
total = checkoutTotal([40, 60], realDiscountPolicy)
assert total == 90The assertion looks almost identical, but the evidence is different. The second test can catch a mismatch between checkout and the real policy: perhaps checkout starts passing prices in cents while the policy still interprets them as currency units. The first test cannot detect that mismatch because its stub accepts whatever contract the test author imagined.
The sociable version also has a larger failure surface. If the discount rule intentionally changes, this checkout test may fail even though checkout itself hasn’t changed. That failure may be useful evidence about externally visible behaviour, or it may be noise if the test was meant to focus only on checkout orchestration.
The right boundary depends on which failure would help you make a decision.
Prefer real collaborators when they are cheap and stable
Real collaborators are often the simplest choice when they are deterministic, fast, and easy to construct.
Value objects, domain policies, parsers, formatters, calculators, and small in-memory collections commonly fit this description. Replacing every such object with a mock can make tests describe implementation wiring instead of useful behaviour.
Suppose a shipping quote uses a Weight value object and a ShippingBand policy. If both are ordinary in-memory code, using the real objects gives the test several advantages.
First, the test exercises the real contracts between the components. A renamed method will normally be caught by the compiler or interpreter, but a semantic disagreement can only be caught when compatible-looking components actually interact.
Second, the test can assert on an observable result rather than internal conversations. A test such as quote.total == 18 usually tolerates harmless changes to the number or order of internal calls. A test that expects band.rateFor(weight) to be called once may fail when an implementation caches the result, even though the behaviour hasn’t changed.
Third, real lightweight collaborators reduce test setup. If creating a real policy takes one line while configuring a mock requires five expectations, isolation is increasing ceremony rather than reducing complexity.
This doesn’t mean a sociable test should expand until it constructs the whole application. Keep the boundary small enough that a failure still points to a comprehensible area of behaviour.
Use test doubles when isolation buys something concrete
A test double is useful when replacing a collaborator gives the test control, speed, determinism, or a safe observation point that would otherwise be difficult to obtain.
Isolate slow or external boundaries
Network services, file systems, message brokers, payment gateways, and other process boundaries can make a unit test slow or dependent on infrastructure. A unit test normally replaces these with an in-process substitute and leaves real integration to a smaller set of boundary or integration tests.
For example, an application service that decides whether to charge a card can use a fake payment port in a unit test. The purpose isn’t to prove that the real payment provider works. It is to verify the application’s decision and its handling of defined outcomes such as accepted, declined, or temporarily unavailable.
Control hard-to-produce conditions
Some states are valid but awkward to reproduce with a real collaborator. A retry policy needs to see transient failures. Timeout handling needs an operation that doesn’t finish before a deadline. Error translation needs specific lower-level failures.
A controllable fake or stub can make those cases ordinary test inputs instead of unreliable environmental accidents.
The key is to model outcomes that the collaborator’s contract genuinely allows. If a test double invents impossible responses, the test proves behaviour for a world production can never enter.
Observe an effect that has no returned value
Commands sometimes communicate only through a boundary: enqueue a message, record an audit entry, or request that an email be sent. A recording fake can provide an observation point.
mailer = RecordingMailer()
service = PasswordResetService(mailer)
service.requestReset(user)
assert mailer.sentMessages == [expectedResetMessage]This is stronger than checking arbitrary method-call choreography. The fake records the meaningful effect at the boundary, and the assertion describes the outcome the application promised to produce.
Don’t confuse a mock with a boundary
A common mistake is to mock every object that happens to be referenced by the subject. That makes the current class diagram define the test boundary.
Class structure and behavioural boundaries are not the same thing.
Imagine InvoiceService uses Money, TaxCalculator, InvoiceNumber, and InvoiceRepository. Mocking all four dependencies would isolate one class, but three of those collaborators may be ordinary domain code whose behaviour is part of calculating a valid invoice. The repository is different: it represents persistence outside the domain calculation.
A more useful test might keep Money, TaxCalculator, and InvoiceNumber real while substituting an in-memory repository. The boundary now follows the engineering concern: domain behaviour stays together; external persistence is controlled.
This approach also makes refactoring less expensive. Splitting TaxCalculator into two internal objects doesn’t force the test to learn about the new structure as long as the observable invoice behaviour stays the same.
Watch for two opposite failure modes
Choosing test boundaries poorly tends to create one of two problems.
Overspecified solitary tests
A solitary test becomes fragile when it verifies incidental collaboration details rather than meaningful effects.
Suppose a test requires these interactions:
expect repository.find(id) once
expect policy.evaluate(order) once
expect repository.save(order) onceSome ordering constraints may matter. For example, saving before validation could be a real defect. But exact call counts often don’t matter unless the contract makes them significant.
If a refactor combines two reads into one cached read, an exact interaction test can fail despite unchanged behaviour. Repeated failures of this kind train developers to treat tests as obstacles rather than evidence.
When using mocks, ask what production rule each expectation protects. Remove expectations that merely mirror the current implementation.
Sociable tests with an accidental blast radius
The opposite problem occurs when a supposedly small test quietly constructs a large object graph. A failure in a low-level helper then breaks dozens of tests whose names point elsewhere.
This can happen even when everything runs in memory. Speed alone doesn’t make a boundary useful.
If a test needs a large fixture, many unrelated collaborators, or extensive setup before reaching the behaviour under test, shrink the boundary. Introduce a stable interface around the part that is making tests difficult, or test that lower-level behaviour separately.
A good sociable unit test includes collaborators because their real behaviour adds useful evidence, not because constructing the production graph is convenient.
Choose boundaries by asking what can vary independently
A practical decision starts with the behaviour you want confidence in, then considers which collaborators can change independently from it.
Suppose an application service performs these steps:
request
-> parse domain command
-> apply pricing rules
-> save order
-> publish confirmationThe parser and pricing rules may belong to the same application behaviour. Keeping them real can catch disagreements in data shape and domain assumptions.
Persistence and message publication cross different boundaries. They can fail for reasons unrelated to pricing and may be implemented by infrastructure that changes independently. Replacing them with fakes keeps the unit test deterministic and lets separate integration tests verify the adapters.
This is not a fixed formula. A parser backed by a third-party library with complicated configuration may deserve its own boundary. An in-memory repository may be simple enough to keep real. The decision follows the costs and guarantees of the specific components.
Three questions are especially useful:
- Does the collaborator’s real behaviour contribute to what this test is trying to prove? If yes, keeping it real may increase confidence.
- Does using the real collaborator introduce slowness, nondeterminism, destructive effects, or difficult setup? If yes, a test double may make the test more useful.
- Can the collaborator change independently behind a stable contract? If yes, isolating that contract can reduce unrelated test failures, provided another test verifies the real integration.
These questions produce better boundaries than a rule such as “mock all dependencies” or “never mock code you own.”
Isolation creates an integration obligation
A test double proves behaviour against the double, not against the real implementation.
That sounds obvious, but it has an important consequence. The more a unit test relies on substitutes at a boundary, the more you need another form of verification that the substitute still represents reality.
Suppose a unit test uses a fake repository with this contract:
save(order) -> successThe fake can verify what the application does after a successful save. It cannot prove that the production adapter serializes every required field, handles concurrency correctly, or talks to the storage system using valid configuration.
Those questions belong in tests at the adapter or integration boundary.
You don’t need to duplicate every unit test at a higher level. Verify the contract where the substitution occurs. If the fake models save, test that the real adapter satisfies the important save semantics. Then unit tests can use the fake for application decisions without pretending the fake validates persistence.
This is one reason excessive mocking can create false confidence. Each mock creates a small assumption about how another component behaves. If none of those assumptions are checked against reality, a large green unit suite can coexist with broken integrations.
Let failures guide boundary adjustments
Test boundaries don’t need to be perfect when first written. Failure patterns provide useful feedback.
If tests repeatedly fail after internal refactors while user-visible behaviour remains correct, the boundary may be too solitary or the interaction assertions too specific. Try keeping stable in-process collaborators real and asserting on outcomes.
If one low-level change causes a wide collection of unrelated tests to fail, the boundary may be too sociable. Isolate the independently changing component behind a meaningful interface.
If unit tests pass but integration failures appear frequently, inspect the doubles. They may be too permissive, may model outdated contracts, or may replace too much real collaboration.
If tests are slow or flaky, identify the source rather than assuming all sociable testing is the problem. One uncontrolled clock, filesystem dependency, or network call can damage an otherwise useful test boundary.
The goal isn’t maximum isolation or maximum realism. It is high-quality feedback at a reasonable cost.
When a simpler approach is enough
Small pure functions rarely need this debate. If a function takes ordinary values and returns a value without external effects, call it directly and assert on the result.
Likewise, don’t introduce interfaces and mocks solely to make every class independently testable. An abstraction has a maintenance cost. If two small objects form one cohesive behaviour and always change together, testing them together may be clearer than creating an artificial seam between them.
At the other extreme, don’t stretch the term unit test to cover a full application stack simply because it runs quickly on one machine. Once a test crosses multiple infrastructure boundaries, its setup, failure modes, and purpose are closer to integration testing. Naming the test honestly helps the team decide how often to run it and how to diagnose failures.
Build tests around useful evidence
When writing the next unit test, begin with the behaviour you want evidence about. Keep real collaborators that are cheap, deterministic, and meaningfully participate in that behaviour. Replace collaborators when isolation gives you a concrete benefit such as control over failures, protection from external effects, or a narrower independently changing boundary.
Then check the other side of the trade-off. Every substitute removes real collaboration from the test, so make sure an appropriate boundary test verifies the contract you chose to fake.
A good unit test boundary isn’t defined by the number of classes inside it. It is defined by the quality of the feedback it provides when the software changes.