Skip to content
End To End Tester

Unit Testing without Mocks

The sociable approach — real collaborators, state-based assertions, and tests that survive refactoring. What it buys, where it breaks down, and how to keep it fast.

3 min read · updated 19 September 2026

The classicist position: a unit test should assert on the observable outcome of a behaviour, and whatever real collaborators are needed to produce that outcome should simply participate. Also called the Detroit school, or sociable unit testing.

The claim is not that doubles are bad. It is that a test coupled to how the code works is worth less than one coupled to what it produces, and that most doubles inside your own codebase buy precision you did not need at a price — refactoring resistance — that you did not want.

#What it looks like

typescript
// TypeScript. Money, DiscountPolicy and LineItem are all real;
// nothing is stubbed, and the assertion is about the result.
import { Cart } from './cart';
import { DiscountPolicy } from './discount-policy';

it('applies the best single discount, never two', () => {
  const cart = new Cart([
    { sku: 'book', unitCents: 2_000, quantity: 3 },
    { sku: 'pen', unitCents: 500, quantity: 1 }
  ]);
  const policies = [
    DiscountPolicy.percentOver('SUMMER', 10, { thresholdCents: 5_000 }),
    DiscountPolicy.fixed('WELCOME', 1_500)
  ];

  const total = cart.totalWith(policies);

  expect(total.discountCents).toBe(1_500);   // the fixed one wins here
  expect(total.appliedCode).toBe('WELCOME');
  expect(total.payableCents).toBe(5_000);
});

Four classes took part. The test mentions none of the collaborations. Inline DiscountPolicy into Cart tomorrow and the test still passes — which is the entire argument.

#What it buys

Refactoring freedom. The test describes behaviour at the boundary you actually care about, so it survives internal restructuring. In a codebase that is being actively designed, this is worth more than precise failure attribution.

Real integration between your own parts. A mockist suite can have every unit green while the units do not fit together, because each one was tested against your assumption about its neighbours. A sociable suite catches that on the spot.

Less test code. No setup of doubles, no expectation configuration. The test above is nine lines; the mockist equivalent is twenty and half of it is scaffolding.

Tests that read like specifications. Because there is no double ceremony, the test is almost entirely domain language. That matters for the same reason given-when-then matters.

#Where it breaks down

Failure attribution. A bug in DiscountPolicy fails every Cart test as well as its own. In a suite of two thousand tests, one small change can turn forty of them red and you have to read to find out why. Mitigate it by keeping the collaborator graph shallow and by having direct tests for each class as well.

Slowness creeps in. "Just use the real one" is fine until the real one opens a connection. The discipline is that the process boundary is still doubled — always. The moment a "sociable" unit test does I/O it has become an integration test and needs to move to that suite.

Shared mutable state. Real collaborators sometimes have real state, and a static cache inside one of them will couple tests to each other. This is the same hazard as parallel execution, arriving early.

#Fakes instead of mocks

The technique that makes this style practical is the fake: a real, working, in-memory implementation of an interface, written once and used everywhere.

csharp
// C#. One fake, no framework, usable in hundreds of tests.
public sealed class InMemoryOrderRepository : IOrderRepository
{
    private readonly Dictionary<Guid, Order> _orders = new();

    // Lets a test exercise the failure path without a mock's call-count coupling
    public Exception? NextFailure { get; set; }

    public Task<Order?> FindAsync(Guid id) =>
        Task.FromResult(_orders.GetValueOrDefault(id));

    public Task SaveAsync(Order order)
    {
        if (NextFailure is { } failure) { NextFailure = null; throw failure; }
        _orders[order.Id] = order;
        return Task.CompletedTask;
    }

    // The assertion surface, in domain terms rather than call verification
    public IReadOnlyCollection<Order> All => _orders.Values.ToList();
}

The test then asserts repository.All contains what it should, rather than that SaveAsync was called. The first survives a refactor that batches saves; the second does not.

A good fake is a small investment with a large return, and it is the strongest form of the classicist argument: you are not avoiding test doubles, you are choosing the kind that does not encode call sequences. See test doubles for the taxonomy.

#The modern complication

Fakes have a failure mode: they drift from the real implementation. Your in-memory repository does case-sensitive lookups; Postgres, with the collation you actually deployed, does not.

Two mitigations, both worth having:

  1. Contract tests for the fake. One test suite, run twice — once against the fake, once against the real implementation. Anything true of one must be true of the other.
  2. Skip the fake. Testcontainers starts a real Postgres in about a second and keeps it warm across the suite. For repository code specifically, this is now often cheaper than maintaining a fake, and it does not lie.

That second option is why this debate looks different in 2026 than it did in 2012. A great deal of what used to be mocked is now simply run.

Common questions

Are sociable tests still unit tests?
Yes, under the operational definition — in memory, fast, deterministic, independent. They involve more than one class, but "one class" was never a useful boundary. What matters is that the test runs in microseconds and fails for one reason.
If I do not mock, how do I test error paths from the database?
With a fake rather than a mock. An in-memory implementation of the repository interface can be told to throw on the next call, which tests the error path without coupling the test to how many times a method was called or in what order.
Does this mean I never use mocking frameworks?
No. It means you use them at the process boundary and not inside it. Almost every sociable suite still doubles the payment gateway and the clock; it just does not double its own domain objects.

Runnable samples for this page

last test results ↗
  • TypeScripttypescript/src/testing-levels/unit-testing-without-mocks

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?