The most common testing pattern I see looks like this:
| |
Looks fine. Tests pass. But this approach has a long-term problem: tests are coupled to implementation details. The day UserService switches from set to setItem, or wraps a namespace around the key, the test breaks. Every internal change requires a test change.
Fake + in-memory sidesteps these traps. This post walks through how three concepts fit together: DI (architecture) + Fake (pattern) + in-memory (implementation technique).
First: Why DI Matters
Dependency Injection’s core idea is “don’t new dependencies inside a class.” The connection to testing is straightforward: it lets you swap implementations.
Without DI:
| |
No way to swap out localStorage during tests β you end up spying on globals or using vi.stubGlobal. Ugly, and tests bleed state between each other.
With DI:
| |
Now localStorage can be replaced with anything during tests β including the Fake we’re about to build.
No framework required. Constructor injection is enough. Frontend frameworks (React, Vue) achieve the same thing via context or providers.
The Problem with Mocks
The biggest issue with mocks is that each test has to assemble its own. Adding a “get should return a value” scenario to the test above:
| |
Mocks don’t know that get should return what was just set β they have no state. Every test has to manually configure the interaction order and return values.
Worse, assertions hook into calls: expect(mock.set).toHaveBeenCalledWith(...). You’re verifying “which internal method got called” instead of “what observable outcome was produced.” Touch the implementation, break the test.
What a Fake Is
A Fake is a real implementation β just a simplified one. It implements the same interface as production, but stores data and runs logic in memory.
| |
Key differences from a mock:
- Has state: what you
setis what yougetback - Predictable: behaves like production, not made up per-test
- Reusable: one copy for the whole project, not hand-assembled per test
Rewriting the Test with a Fake
| |
Differences:
- No
expect(...).toHaveBeenCalledWith(...)β we assert the thing actually exists in storage - We test behavior (stored data can be retrieved), not implementation (the
setmethod was called) - If
UserServiceswitches tosetIteminternally or wraps keys in a namespace, the test survives as long as the interface doesn’t change
Building a Good In-Memory Fake
A few design points that matter:
1. Implement the production interface
Most important rule. A Fake must share the same interface as the production implementation:
| |
This guarantees test and runtime behavior align. A Fake without a shared interface is a pretend β you’re testing a system you imagined, not the one you built.
2. Provide test-only setup methods
Fakes can add methods production doesn’t need, for arranging state:
| |
These keep the arrange step clean:
| |
3. Have a reset method
Tests must be isolated. Provide reset() so each test starts clean:
| |
Or create a fresh instance per test. The latter is cleaner β you can’t forget to reset something that doesn’t exist.
The Fake Itself Needs Tests
Something most people never consider: the Fake’s behavior must be tested.
The Fake stands in for production across your entire test suite. If the Fake misbehaves, every test built on top of it rests on a false foundation.
| |
The value of these tests: they describe the Fake’s invariants. With those written down, reusing the Fake elsewhere is safe β its behavior is known.
When to Use Fake vs Stub
Fake isn’t always the right tool. Some cases are a better fit for Stub (pure input/output).
Use a Fake when
- The dependency has state (storage, cache, database, session)
- The dependency’s behavior needs to stay consistent across tests
- Multiple tests share this dependency
- You need to verify “what went in can come out” style interactions
Use a Stub when
- The dependency is purely “input β output”
- Only a few tests need it
- Different tests need very different return values
- A full Fake isn’t worth building (e.g. complex external API responses)
Most projects mix both: shared services get a Fake, one-off external dependencies get a stub.
Summary
The full flow:
- Write production code with DI β inject dependencies via constructor, never
newthem inside - Define interfaces β both production and test doubles implement the same one
- Write the Fake β in-memory implementation with seed/reset helpers
- Test the Fake β verify its own invariants
- Test production code β use the Fake as a stand-in, assert behavior not method calls
This combo gives tests three long-term wins: not coupled to implementation details, clean cross-test state, and a trustworthy Fake validated on its own.
The next post covers how a single Fake can power tests from the frontend down through the backend in a monorepo β where this pattern really shines.
