What exactly is a “Test Double”? The term was coined by Gerard Meszaros in xUnit Test Patterns (2007) and later popularized by Martin Fowler. Check out Fowler’s post on it here: Test Double. Meszaros defines 5 types of test double: dummies, fakes, stubs, spies, and mocks. In my experience, teams blur stubs, spies, and mocks into a single “Mockito-style mock”, and the one test double that is usually neglected but most powerful is the fake.
In this article we will explore building “test fakes”. Before we do that I propose an enhancement to this nomenclature. Instead of test “fake” I will call it a “testable implementation”. At a conceptual level that is all a fake is supposed to be - an implementation of code that makes a component testable. I will also propose some quality-of-life improvements to testable implementation design.
This is a two-part series. In this part, we use a single service as an example, the class AuthenticationService. We will then walk through the multiple tiers of test double as part of a test for a protected endpoint. These tiers aren’t Meszaros’s five types. It’s really a maturity ladder I see teams climb as their development process matures: no tests, tests that use dummy test doubles, then heavy reliance on mocking (which in practice blends stubs and mock verification), and finally the testable implementation. In part 2 (coming soon), we take the in-memory fake built here and use it to test a different feature that happens to need an authenticated user. Hopefully, in part 2, you will see the payoff of the investment. It’s not that the fake only helps test this one endpoint; it enables you and your team to test every downstream feature that depends on a third party component, authentication in this example, without needing to have a full-blown service running.
Why Authentication? Practically all software has some form of authentication. For web software, every request needs a notion of “who is this?”, which means almost every test that exercises real application code needs to substitute it. Many times teams try to or feel forced to also test authentication for all components. This is both an application and test design “smell”. Is authentication hardcoded into each app component? Is authentication treated as its own composed or composable layer? Do teams skip lower-level tests because it’s a burden to test without authentication, or, worse, because it’s not possible to test without connecting to a deployed environment?
More generally speaking, when it comes to systems design which either enables or prevents testing, we must ask what is the design? Is it highly coupled? Highly cohesive? Consider how your design influence testability and, more importantly, a product’s quality, and ultimately your business’s value proposition? If we design authentication to be easy to test, we enhance our ROI and improve our ability to maintain and enhance our value proposition.
The service
Our AuthenticationService has one responsibility: given a bearer token, say who presented it. It returns the Principal the token was issued to, or it fails and says why. A token we don’t accept is an invalid token failure that carries the reason: unknown, expired, revoked, or issued for another API. An identity provider we can’t reach is a different failure: we couldn’t decide. There’s no silent third outcome, no empty result that leaves the caller guessing which check failed. How a failure is expressed follows the language: an unchecked InvalidTokenException (with a reason()) or AuthenticationUnavailableException a raised InvalidToken (with a .reason) or AuthenticationUnavailable a returned error: an *InvalidTokenError (with a Reason) or one wrapping ErrAuthenticationUnavailable the Err of a Result: AuthenticationError::InvalidToken(reason) or AuthenticationError::Unavailable the Left of an Either: AuthenticationError.InvalidToken(reason) or AuthenticationError.Unavailable a raised AuthenticationService::InvalidToken (with a #reason) or AuthenticationService::Unavailable an {:error, {:invalid_token, reason}} or {:error, %AuthenticationUnavailableError{}} tuple a rejected promise: InvalidTokenError (with a reason) or AuthenticationUnavailableError a thrown ex-info: (invalid-token reason) or (unavailable message) the Left of an Either: InvalidToken reason or AuthenticationUnavailable a member of an error set: TokenUnknown, TokenExpired, TokenRevoked, TokenWrongAudience, or AuthenticationUnavailable. In OAuth terms, this is the API’s side of the exchange: the caller got the token from the identity provider (a user signing in, or another service using its client credentials), and our API only has to verify it. Here’s what it returns:
In production this is backed by Okta, Auth0, your in-house IdP, or your identity provider of choice. Here is a rough example of an OktaAuthenticationService:
In production code, this is the implementation. It’s dependent on access to Okta, it can be slow, and it costs money to use. This implementation under test adds overhead that is simply not needed. Why must we test this all the time? During development, why do we need to test if Okta is working? Isn’t that what we pay them for?
The function under test
To read their profile, the caller sends GET /me with an Authorization: Bearer <token> header. This endpoint is the boundary where your customers communicate with your system. Stated more abstractly, it’s how external domain actors interface with this aspect of your domain, the authentication subdomain. See Component Integration Tests for the case for testing at the HTTP layer.
This is a standard protected-endpoint workflow at the HTTP boundary:
- Read the bearer token (
401if theAuthorizationheader is missing or isn’tBearer) — a request without credentials never reaches the auth service. - Hand the token to the auth service (
401if it’s not one we accept, with the reason in the challenge’serror_description;503if the identity provider is unreachable) — at this boundary we enforce authentication. - Check the scope (
403withoutprofile:read) — the token proves who the caller is; our own rule decides what they may do. - Return the profile as JSON on success — acknowledge the caller.
Tier 1: the dummy
If we needed to test a component that uses AuthenticationService, teams might start with a dummy.
A test for the “no token” path:
auth is a dummy. The test never asks anything of it, never asserts on it, and the controller never reaches the line that would have called it (because the missing token is rejected first). It exists only to satisfy the constructor’s parameter list. The one extra assertion, the plain WWW-Authenticate: Bearer challenge, is what proves that: only the no-token branch answers with a bare challenge; every other 401 says invalid_token.
Is this really that bad? Well, the dummy doesn’t enforce expected behavior. It doesn’t validate the behavior, and it doesn’t help us validate it either. Are we sure it returns an unauthorized or a forbidden response? What is the expected behavior? Does the dummy help us here or provide false confidence?
Depending on the maturity of the team’s test practices, when this approach is no longer sufficient, the dummy mock model is refactored into a naive mock. In this instance, that might happen once we try covering a positive case, “user can authenticate”. This is usually referred to as a happy-path case or testcase.
Tier 2: the naive mock
The happy-path test:
Notice what this test doesn’t know. Alice’s principal says it expired on May 19, 2026, and the stub hands it over anyway: a stub returns whatever we script, even an answer the real service would never give. And there’s nothing to verify; the test checks our response, not the conversation with the double.
You may also be familiar with test code that looks like the example below.
What does this sort of test setup do? It makes every token authenticate as alice, even a token the test never meant to accept. Doesn’t this defeat the purpose of the testing? This is a major problem with traditional mocking approaches. Technically, there is a fix. Keep writing the specific stub based on the scenario, and keep doing it across 10s or 100s (or more) of tests. But in practice production codebases are usually littered with the example above. Many times as engineering teams mature they realize there are opportunities to improve. That leads us to our next approach: centralizing the mock.
Tier 3: the reusable mock
Let’s extract the setup.
Now our test becomes:
Per-test setup dropped from ten lines to two. However, it’s not about lines of code! it’s about readability. Less contextual overhead allows us to focus. Now we are designing a test that more clearly describes intent. Another perspective: now we are designing an automated specification to make sure our system works as a part of our build process. Given a token for alice, we should return her profile as expected.
The remaining cost is that the helpers become their own abstraction layer. These abstraction layers contain implicit assumptions (“a principal never has claims, and always expires in an hour”).
In practice, once the team needs to write a test that deviates from the helper, the well-intentioned colleague simply bypasses these pre-built components or refactors the helpers, bolting on extra parameters. This is the important trade-off that most production code bases have to make. It’s better than the naive mock, but it’s still a simplified design. It works for most simple use cases, but it constrains the team, adds friction to development, and silently creates technical debt.
Tier 4: the testable implementation
I would consider this the most mature form of a test double design. Instead of duplicating setup in every test to redefine what the auth service is expected to do, we write a working implementation. The difference is that it’s a real implementation of the interface, not a script of predefined expected calls. This is the testable implementation, introduced in the component integration tests article and the whole point of this article. Testable implementations:
- Must: It always runs in memory. No I/O, no external connections, no network (writing to a local file is fine, e.g. for log output).
- Must: It adheres to a production interface, and its behavior reflects the behavior of the production implementation without overhead of external integrations. Think of it as an implementation of a real interface (literally) In our example above, this becomes
authenticatewith a token it issued produces a realPrincipalthat the system under test (SUT) can inspect; calling it with an unknown, expired, revoked, or wrong-audience token fails with that reason (reflecting what actually happens in production). - Must not: test concurrent execution. The tests that use it evaluate serially, one step after another, even when a real server handles each request on its own thread. It may depend on asynchronous behavior evaluating a future and blocking on its completion for example. The caller must evaluate any asynchronous structure explicitly. The unit-testing rule of not testing concurrency also applies here: concurrency is an effect of your runtime, tested separately at the runtime level or with system tests. We test around asynchronous behavior (the side-effect of the runtime), not the asynchrony itself or the runtime itself. Our focus is the implementation: does our domain work as expected within its runtime, async or not.
- Should: It simulates the external boundaries of your domain.
- Nice to have: It adds test-friendly helpers (
issueToken,revoke,withClock,withTokenLifetime) that the production component doesn’t expose, so a test can seed or configure it directly while the system under test still calls it through the production interface. Just because production code is sometimes “complex” doesn’t mean test code has to share these same characteristics. - Nice to have: It should be configurable to replicate error conditions (raising or returning the failures the interface declares). This allows us to not rely on mocking while having fine-grained control over the implementation’s behavior.
Please see the six characteristics in action below:
The happy-path test:
And the negative paths: an expired token, a token for another API, a revoked token, a missing scope, and an identity provider that’s down. Now easy to do:
Let’s consider our current state with this approach:
No mock-framework dependency. The tests allow for clear intent on behavior and expectations.
issueToken("alice-1", "profile:read")says what it means without forcing the reader to translate from mock-setup syntax.The fake models the rules, so the tests don’t have to. Expiry, audience, and revocation live in one place. A test that wants an expired token moves the clock; it doesn’t hand-build a
Principalthat pretends to be expired (compare Tier 2, whose stub cheerfully returned alice’s principal months after it expired). And the403test exercises our rule, the scope check, against a principal the fake really issued.You get negative case support gratis. No additional setup. This would be just like setting up the authentication service the first time. You issued alice a token; any other token, or the same token once it expires or is revoked, simply isn’t accepted. And because the fake says why it refused, each negative test pins the reason, not just the
401: the expired-token test can’t pass because the token was revoked, or never issued. Compare this with mocks, which liberally useany()args, meaning that we test against the auth always working even for the wrong token.The fake costs more upfront.
TestAuthenticationServiceis real code, and there’s a cost to designing and implementing testable implementations. What are those trade-offs?
Trade-offs
- It’s real code.
TestAuthenticationServicehas to keep up with the interface as production evolves. For a dependency you touch in one or two tests, a mock is cheaper. - It can drift. A fake is only as good as its fidelity to production. If Okta starts rejecting tokens your fake accepts (a new required claim, a shorter lifetime), your tests stay green. Contract tests are how you keep the two honest.
- It won’t model everything. Latency, rate limits, partial outages, eventual consistency — a fake reproduces the behavior you give it, and nothing else. Error injection (
failingWith) covers the cases you think of; it won’t surprise you the way production will. - Its helpers become an API.
issueToken,withClock,failingWithare now test infrastructure other engineers depend on. Treat them with the same care as production code. - It holds state. A fake shared across tests has to be reset between them, or tests start depending on each other’s leftovers. But this can be easily mitigated with effective test design.
Testable implementations aren’t free. However, as professionals, we have to think about ROI. Does the return (shorter feedback loops, improved quality, however your team defines it) justify the investment of time and maintenance? That is a question we, as software professionals, must be able to answer.
An equally important question: when do we use a testable implementation at all? That takes judgment about the platform we’re supporting. Testable implementations don’t solve every problem. Sometimes mocks just work; other times it makes more sense to rely on other testing approaches (e.g. contract testing, exploratory testing). We must use the right tool for the job.
Up next
In Part 2 (coming soon), we take TestAuthenticationService into a bigger slice of the domain: a GDPR data export with auditing, a database, and an outbox. We then try to answer a narrower question: what do we simulate with a testable implementation, and what do we keep as the real thing?