Real collaborators like databases and third-party APIs can make tests slow and hard to control. Test Doubles replace them with simpler alternatives when needed, but knowing when to use them and when to use real objects is just as important.
Nomenclatures and concept definitions follow Gerard Meszaros and Martin Fowler. References are at the end of the article.
First: General Test Nomenclatures
SUT: System Under Test. It is the main (class, object, function, etc.) being tested.
Collaborators: "Secondary objects", they are not the main object like the SUT, but are necessary to test the SUT.
Observation Point: Provides the ability to analyze the interaction between the SUT and other parts of the system after exercising the SUT.
Indirect Input: When the behavior of the SUT depends on values returned by another component whose services it uses.
Indirect Output: Calls the SUT makes to its collaborators (e.g. saving to a repository) that are not visible in the SUT's return value and can only be observed by inspecting the collaborator.
What are Test Doubles?
Any kind of pretend object used in place of a real object for testing purposes.
They need to make the SUT believe it’s talking to its real collaborators
Important:
When talking about Test Doubles we are mainly talking about unit tests and sometimes integration tests, but rarely for e2e tests. Unit tests alone will never guarantee reliability for future software changes during maintenance or development of new features, so e2e tests are highly recommended.
The quote below argues in favor of this position:
"It's at this point that I should stress that whichever style of test you use, you must combine it with coarser grained acceptance tests that operate across the system as a whole. I've often come across projects which were late in using acceptance tests and regretted it." - Martin Fowler
One exception to using Test Doubles in E2E tests
Mainly applies to third-party services.
Testing third-party services in E2E tests can be tricky and non-deterministic, failing due to network latency, timeouts, or other factors. Flaky tests are undesirable.
While some argue it helps detect failures, real downtime is usually noticed by customers first. Monitoring tools are a better way to track service issues.
For deterministic E2E tests, it is often preferable to use Test Doubles for third-party services. As Martin Fowler explains:
"The other area where these tests don't cover the full breadth of the stack lies in connection to remote systems. Many people, including myself, think that tests that call remote systems are unnecessarily slow and brittle. It is usually better to use TestDoubles for these remote systems and check the doubles with ContractTests." - Martin Fowler
For the upcoming examples we will consider the code below:
functionvalidateUser(user:User):ValidationResult{if (!user.name){return{errorMessage:"required name",valid:false};}if (user.name.length<1||user.name.length>30){return{errorMessage:"name length between 1 and 30",valid:false};}return{valid:true};}// using dependency injection to facilitate tests (dbRepository)exportasyncfunctioncreateUserUsecase({user,dbRepository,}:CreateUsersUsecasePayload):Promise<User>{try{constvalidationResult=validateUser(user);if (!validationResult.valid){thrownewError(validationResult.errorMessage);}constexisting=awaitdbRepository.findById(user.id);if (existing)thrownewError("user already exists");constsavedUser=awaitdbRepository.save(user);returnsavedUser;}catch (error:unknown){if (errorinstanceofError)console.error(error.message);throwerror;}}exportasyncfunctionlistUsersUsecase({dbRepository,}:ListUsersUsecasePayload):Promise<User[]>{try{constusers=awaitdbRepository.list();returnusers;}catch (error:unknown){if (errorinstanceofError)console.error(error.message);throwerror;}}
Dummy
Object passed only to fill parameter lists but never really used.
describe("createUserUsecase with Dummy",()=>{constdummyDbRepository={}asDbRepository<User>;test("Given empty username should throw error",async ()=>{awaitassert.rejects(async ()=>createUserUsecase({user:{id:1}asany,dbRepository:dummyDbRepository,}),/required name/);});test("Given username too long should throw error",async ()=>{awaitassert.rejects(async ()=>createUserUsecase({dbRepository:dummyDbRepository,user:{id:3,name:"a".repeat(31)},}),/name length between 1 and 30/);});// we can't make this one with dummy...// test("Given valid username should save user", async () => {});});
Fake
Object with a real working implementation, but with a shortcut that make it not a good option for production, but perfect for tests.
classUsersInMemoryRepositoryFakeimplementsDbRepository<User>{users:User[]=[];asyncsave(input:User):Promise<User>{this.users.push(input);returninput;}asynclist():Promise<User[]>{returnthis.users;}asyncfindById(id:number):Promise<User|null>{returnthis.users.find(u=>u.id===id)??null;}clean(){this.users=[];}}describe("createUserUsecase with Fake",()=>{constusersInMemoryRepositoryFake=newUsersInMemoryRepositoryFake();beforeEach(()=>{usersInMemoryRepositoryFake.clean();})test("Given empty username should throw error",async ()=>{awaitassert.rejects(async ()=>createUserUsecase({dbRepository:usersInMemoryRepositoryFake,user:{id:1}asany,}),/required name/);});test("Given username too long should throw error",async ()=>{awaitassert.rejects(async ()=>createUserUsecase({dbRepository:usersInMemoryRepositoryFake,user:{id:3,name:"a".repeat(31)},}),/name length between 1 and 30/);});test("Given valid username should save user",async ()=>{constuser={id:2,name:"John"};constsavedUser=awaitcreateUserUsecase({dbRepository:usersInMemoryRepositoryFake,user,});assert.deepStrictEqual(savedUser,user);assert.deepStrictEqual(awaitusersInMemoryRepositoryFake.list(),[user]);});test("Given duplicate id should throw",async ()=>{// Because fake has real logic (unlike Stub)constuser={id:1,name:"John"};awaitcreateUserUsecase({dbRepository:usersInMemoryRepositoryFake,user});awaitassert.rejects(async ()=>createUserUsecase({dbRepository:usersInMemoryRepositoryFake,user}),/user already exists/);});});// Could test the list use case here too, but to exemplify this is enough.
Stub
Provide fixed responses for what is required during the test, but can't respond anything outside the context of the test.
We never verify Stub's state or behavior, we are only using the stub with a fixed response to test what we need.
There are Stub variations; I'll mention the ones I consider most common:
Responder: A stub that injects valid indirect inputs into the SUT. Generally used in "happy path" tests.
Saboteur: A stub that injects invalid indirect inputs into the SUT. Used to test how the SUT behaves with incorrect indirect inputs.
Hard-Coded: Responses baked into the implementation. Not all stubs need to be hard-coded — a Configurable Stub gets its response injected at setup (e.g., via constructor).
If you always hard-coded stubs and wondered if anything else was a Fake: the difference is that Fakes mirror real production logic but take shortcuts, while stubs just return canned responses (even if they store some state to do so).
Example of SUT:
classUsersStubRepositoryimplementsDbRepository<User>{asyncsave(input:User):Promise<User>{returninput;}asynclist():Promise<User[]>{return[{id:1,name:"joao"},{id:2,name:"john"}];}asyncfindById(_id:number):Promise<User|null>{returnnull;}}describe("listUsersUsecase with Stub",()=>{constusersStubRepository=newUsersStubRepository();test("Given existing users should return users list",async ()=>{constusers=awaitlistUsersUsecase({dbRepository:usersStubRepository});assert.deepStrictEqual(users,[{id:1,name:"joao"},{id:2,name:"john"}]);});});describe("createUserUsecase with Stub",()=>{constusersStubRepository=newUsersStubRepository();test("Given valid user should save user",async ()=>{constuser={id:1,name:"John"};constresult=awaitcreateUserUsecase({dbRepository:usersStubRepository,user});assert.deepStrictEqual(result,user);});test("Given same id called twice should save both times without throwing",async ()=>{constuser={id:1,name:"John"};awaitcreateUserUsecase({dbRepository:usersStubRepository,user});constresult=awaitcreateUserUsecase({dbRepository:usersStubRepository,user});assert.deepStrictEqual(result,user);});});
Spy
Stubs that also record some information based on how they were called. For example, an email service that records how many messages it was sent.
It can save any information on how they were called: number of calls, arguments passed, order of calls, timestamp, return values...
classUsersSpyRepositoryimplementsDbRepository<User>{private_saveCallCount:number=0;private_lastSavedUser:User|null=null;private_findByIdCallCount:number=0;private_lastFindByIdArg:number|null=null;asyncsave(input:User):Promise<User>{this._saveCallCount++;this._lastSavedUser=input;returninput;}asynclist():Promise<User[]>{return[{id:1,name:"joao"},{id:2,name:"john"}];}asyncfindById(id:number):Promise<User|null>{this._findByIdCallCount++;this._lastFindByIdArg=id;returnnull;}getSaveCallCount(){returnthis._saveCallCount;}getLastSavedUser(){returnthis._lastSavedUser;}getFindByIdCallCount(){returnthis._findByIdCallCount;}getLastFindByIdArg(){returnthis._lastFindByIdArg;}clean(){this._saveCallCount=0;this._lastSavedUser=null;this._findByIdCallCount=0;this._lastFindByIdArg=null;}}describe("createUserUsecase with Spy",()=>{constusersSpyRepository=newUsersSpyRepository();beforeEach(()=>{usersSpyRepository.clean();});test("Given valid user should call findById with user id then save the user",async ()=>{constuser={id:42,name:"John"};awaitcreateUserUsecase({dbRepository:usersSpyRepository,user});assert.strictEqual(usersSpyRepository.getFindByIdCallCount(),1);assert.strictEqual(usersSpyRepository.getLastFindByIdArg(),42);assert.strictEqual(usersSpyRepository.getSaveCallCount(),1);assert.deepStrictEqual(usersSpyRepository.getLastSavedUser(),user);});test("Given invalid user should not call findById or save",async ()=>{awaitassert.rejects(async ()=>createUserUsecase({dbRepository:usersSpyRepository,user:{id:1}asany}),/required name/);assert.strictEqual(usersSpyRepository.getFindByIdCallCount(),0);assert.strictEqual(usersSpyRepository.getSaveCallCount(),0);});});
Mock
A pre-programmed object/function that has expectations about how it should be used or called, and which will verify that the expected actions occurred.
In general tests we do state verification using real instances of our classes and checking how their states were impacted after the SUT exercise, this state verification is made using asserts in the collaborators.
Mock objects allows us to do behavior verification checking what calls were made to the mock. Unlike Spy, there are no external state asserts on the mock in the test body; the mock encapsulates its own verification internally (assertions run inside the mock methods and in verify()).
classUsersRepositoryMockimplementsDbRepository<User>{private_expectedFindByIdArg:number|null=null;private_expectedSaveArg:User|null=null;private_expectedFindByIdCalls=0;private_expectedSaveCalls=0;private_actualFindByIdCalls=0;private_actualSaveCalls=0;expectFindById(id:number,times=1){this._expectedFindByIdArg=id;this._expectedFindByIdCalls=times;returnthis;}expectSave(user:User,times=1){this._expectedSaveArg=user;this._expectedSaveCalls=times;returnthis;}asyncfindById(id:number):Promise<User|null>{this._actualFindByIdCalls++;assert.strictEqual(id,this._expectedFindByIdArg);returnnull;}asyncsave(input:User):Promise<User>{this._actualSaveCalls++;assert.deepStrictEqual(input,this._expectedSaveArg);returninput;}asynclist():Promise<User[]>{return[];}verify(){assert.strictEqual(this._actualFindByIdCalls,this._expectedFindByIdCalls);assert.strictEqual(this._actualSaveCalls,this._expectedSaveCalls);}}describe("createUserUsecase with Mock",()=>{test("Given valid user should call findById and save with correct arguments",async ()=>{constuser={id:1,name:"John"};// assertions configured on arrange partconstrepositoryMock=newUsersRepositoryMock().expectFindById(user.id).expectSave(user);// asserts on the mock are executed during the exercise of the SUT -> testing behavior// mock is checking the passed argsawaitcreateUserUsecase({dbRepository:repositoryMock,user});// verify if it was called enough timesrepositoryMock.verify();});test("Given invalid user should not call findById or save",async ()=>{constrepositoryMock=newUsersRepositoryMock();awaitassert.rejects(async ()=>createUserUsecase({dbRepository:repositoryMock,user:{id:1}asany}),/required name/);repositoryMock.verify();});});
Use Cases Quick Summary:
Dummy:
Use when: the collaborator is required but never called in that test path
Avoid when: the SUT will actually invoke the collaborator
Fake:
Use when: you need a real working collaborator with simplified internals (e.g. in-memory DB)
Avoid when: a fixed response is enough -> a Stub is simpler
Stub:
Use when: you need to control what the collaborator returns (indirect input)
Avoid when: you also need to verify how many times or how the collaborator was called
Spy:
Use when: you need to verify indirect output by asserting on the collaborator's recorded state after the SUT runs (e.g. callsToSave === 1)
Avoid when: you only care about the SUT's return value -> a Stub is enough
Mock:
Use when: you need to verify the interaction itself (what was called, with what args) without caring about the collaborator's internal state (behavior verification)
Avoid when: the collaborator needs real stateful behaviour, use a Fake instead
When and How to Use Test Doubles
The quote below shows that it depends on the testing philosophy that you and your team prefer:
"The classical TDD style is to use real objects if possible and a double if it's awkward to use the real thing. So a classical TDDer would use a real warehouse and a double for the mail service. The kind of double doesn't really matter that much." - Martin Fowler
Another important quote from Martin Fowler is about cases we find during software development, such as a cache, where state verification proves to be unfeasible in some situations, and for that mock objects are a good fit for behavior verification.
"Occasionally you do run into things that are really hard to use state verification on, even if they aren’t awkward collaborations. A great example of this is a cache. The whole point of a cache is that you can’t tell from its state whether the cache hit or missed - this is a case where behavior verification would be the wise choice for even a hard core classical TDDer. I’m sure there are other exceptions in both directions." - Martin Fowler
Risks of Overusing Test Doubles:
You should not overuse Test Doubles, since your SUTs will be using real implementations it's important to test using them too.
If we overuse Test Doubles we will have what is called Fragile Test
"We must be careful when using Test Stubs because we are testing the SUT in a different configuration from that which will be used in production. We really should have at least one test that verifies it works without a Test Stub. A common mistake made by test automaters new to stubs is to replace a part of the SUT that they are trying to test. It is therefore important to be really clear about what is playing the role of SUT and what is playing the role of test fixture. Also, note that excessive use of Test Stubs can result in Overspecified Software." - Gerard Meszaros
Conclusion
Although we've discussed nomenclature and the usage of Test Doubles throughout the article, developers normally don't follow this naming strictly. It's important to understand the role of each one, but in practice you just need to know these tools to write better tests.
In the majority of testing libraries, people tend to call everything a mock. Is it a problem when we use the same name for multiple different things? Absolutely.
But if you understand the concepts and know when you need each one, you are able to simply use a dummy, stub, or fake for the majority of easier test cases, and spy on what is needed to test indirect output. Mocks help to better follow object-oriented design, in which you want to "tell, don't ask". Example:
// BADclassOrderService{register(order){constuser=db.getUser(order.userId);if (user.isActive){emailService.send(user.email);// this logic is outside the domain object (user)}}}// GOODclassOrderService{register(user){user.notify();}}test("should notify user",()=>{constuser={notify:jest.fn(),};constservice=newOrderService();service.register(user);expect(user.notify).toHaveBeenCalled();});
Top comments (0)