In this post, I would like to talk about why I prefer my own test doubles over using those provided by a library. First of all, we need to define what a test double is.
A test double is an object that takes the place of a production object during a test. They are used to eliminate unwanted side effects, algo are used to isolate the unit of code being teste. We have different types.
With this clarified, we can discuss the advantages of having our own test doubles rather than using those provided by a test library like jest or phpunit, etc.
For me, the last point is the most important. Let’s look at an example to understand the difference.
Using the following use case:
@CommandHandler(CreateCountryCommand)
export default class CreateCountryCommandHandler implements ICommandHandler {
constructor(
@Inject(COUNTRY_REPOSITORY) private readonly countryRepository: CountryRepository,
@Inject(EVENT_BUS) private readonly eventBus: EventBus,
) {}
async execute(command: CreateCountryCommand): Promise {
const countryId = CountryId.of(command.id);
await this.guardCountryDoesNotExists(countryId);
const languages = LanguageCollection.of(command.languages);
const country = Country.create(countryId, command.name, command.iso, languages);
this.countryRepository.save(country);
void this.eventBus.publish(country.pullDomainEvents());
}
private async guardCountryDoesNotExists(countryId: CountryId): Promise {
const country = await this.countryRepository.findById(countryId);
if (country) {
throw new CountryAlreadyExistsException(countryId.toString());
}
}
}
First of all let's see and example using jest for the test doubles:
describe('Given a CreateCountryCommandHandler to handle', () => {
let eventBus: EventBus;
let countryRepository: CountryRepository;
let handler: CreateCountryCommandHandler;
const prepareDependencies = () => {
eventBus = {
publish: jest.fn(),
domainEvents: jest.fn().mockReturnValue([]),
} as unknown as EventBus;
countryRepository = {
findById: jest.fn(),
findAll: jest.fn().mockResolvedValue([]),
save: jest.fn(),
} as unknown as CountryRepository;
};
const initHandler = () => {
handler = new CreateCountryCommandHandler(countryRepository, eventBus);
};
const clean = () => {
eventBus.publish.mockClear();
countryRepository.save.mockClear();
};
beforeAll(() => {
prepareDependencies();
initHandler();
});
beforeEach(() => {
clean();
});
describe('When the country id is invalid', () => {
let command: CreateCountryCommand;
function startScenario() {
command = CreateCountryCommandMother.random({ id: '' });
}
beforeEach(startScenario);
it('should throw an exception', async () => {
await expect(handler.execute(command)).rejects.toThrowError(InvalidArgumentException);
});
it('should not create the country', async () => {
await expect(handler.execute(command)).rejects.toThrow();
expect(countryRepository.save).not.toHaveBeenCalled();
});
it('should not publish any event', async () => {
await expect(handler.execute(command)).rejects.toThrow();
expect(eventBus.domainEvents()).toHaveLength(0);
});
});
describe('When the country already exists', () => {
let command: CreateCountryCommand;
function startScenario() {
const country = CountryMother.random();
command = CreateCountryCommandMother.random({ id: country.getId().value });
countryRepository.findById.mockResolvedValue(country);
}
beforeEach(startScenario);
it('should throw an exception', async () => {
await expect(handler.execute(command)).rejects.toThrowError(CountryAlreadyExistsException);
});
it('should not create the country', async () => {
await expect(handler.execute(command)).rejects.toThrow();
expect(countryRepository.save).not.toHaveBeenCalled();
});
it('should not publish any event', async () => {
await expect(handler.execute(command)).rejects.toThrow();
expect(eventBus.domainEvents()).toHaveLength(0);
});
});
describe('When the parameters are valid and the country does not exist', () => {
let command: CreateCountryCommand;
let country: Country;
function startScenario() {
command = CreateCountryCommandMother.random();
country = CountryMother.createFromCreateCountryCommand(command);
countryRepository.findById.mockResolvedValue(null);
}
beforeEach(startScenario);
it('should create a country', async () => {
await handler.execute(command);
expect(countryRepository.save).toHaveBeenCalledWith(country);
});
it('should publish an event', async () => {
await handler.execute(command);
const countryCreatedEvent = CountryCreatedEventMother.createFromCreateCountryCommand(command);
expect(eventBus.domainEvents()).toHaveLength(1);
expect(eventBus.domainEvents()[0]).toEqual(countryCreatedEvent);
});
});
});
As you can see, the tests are tightly coupled to the repository and the event bus in this case. The test knows implementation details of these collaborators, which makes the tests more coupled and fragile. In this case, it’s a simple example, but it’s to help understand the concept.
Now let’s see how it would look implementing our own test doubles, for example:
NOTE: For simplicity, I’m calling all the test doubles with the suffix "Mock," but they could be named differently. For example, for the EventBus, we could call it EventBusSpy (since, in the end, we need to spy on whether the event was persisted correctly).
The test doubles would be:
EventBusMock:
export class EventBusMock implements EventBus { private storedEvents: DomainEvent[] = []; async publish(events: DomainEvent[]): Promise
{ this.storedEvents.push(...events); } domainEvents(): DomainEvent[] { return this.storedEvents; } clean(): void { this.storedEvents = []; } }
CountryRepositoryMock:
export class CountryRepositoryMock implements CountryRepository { private changed: boolean = false; private countriesStored: Country[] = []; private toReturn: Country[] = []; constructor() { this.changed = false; this.countriesStored = []; this.toReturn = []; } add(country: Country) { return this.toReturn.push(country); } storedChanged(): boolean { return this.changed; } stored(): Country[] { return this.countriesStored; } clean(): void { this.changed = false; this.countriesStored = []; this.toReturn = []; } async findAll(): Promise
{ return this.toReturn; } async findById(_id: CountryId): Promise { return this.toReturn.length > 0 ? this.toReturn[0] : null; } save(country: Country): void { this.changed = true; this.countriesStored.push(country); } }
This is how they would be used:
describe('Given a CreateCountryCommandHandler to handle', () => {
let eventBus: EventBusMock;
let countryRepository: CountryRepositoryMock;
let handler: CreateCountryCommandHandler;
const prepareDependencies = () => {
eventBus = new EventBusMock();
countryRepository = new CountryRepositoryMock();
};
const initHandler = () => {
handler = new CreateCountryCommandHandler(countryRepository, eventBus);
};
const clean = () => {
countryRepository.clean();
eventBus.clean();
};
beforeAll(() => {
prepareDependencies();
initHandler();
});
beforeEach(() => {
clean();
});
describe('When the country id is invalid', () => {
let command: CreateCountryCommand;
function startScenario() {
command = CreateCountryCommandMother.random({ id: '' });
}
beforeEach(startScenario);
it('should thrown an exception', async () => {
await expect(handler.execute(command)).rejects.toThrowError(InvalidArgumentException);
});
it('should not create the country', async () => {
await expect(handler.execute(command)).rejects.toThrow();
expect(countryRepository.storedChanged()).toBeFalsy();
expect(countryRepository.stored()).toHaveLength(0);
});
it('should not publish any event', async () => {
await expect(handler.execute(command)).rejects.toThrow();
expect(eventBus.domainEvents()).toHaveLength(0);
});
});
describe('When the country already exists', () => {
let command: CreateCountryCommand;
function startScenario() {
const country = CountryMother.random();
command = CreateCountryCommandMother.random({ id: country.getId().value });
countryRepository.add(country);
}
beforeEach(startScenario);
it('should thrown an exception', async () => {
await expect(handler.execute(command)).rejects.toThrowError(CountryAlreadyExistsException);
});
it('should not create the country', async () => {
await expect(handler.execute(command)).rejects.toThrow();
expect(countryRepository.storedChanged()).toBeFalsy();
expect(countryRepository.stored()).toHaveLength(0);
});
it('should not publish any event', async () => {
await expect(handler.execute(command)).rejects.toThrow();
expect(eventBus.domainEvents()).toHaveLength(0);
});
});
describe('When the parameters are valid and the country does not exists', () => {
let command: CreateCountryCommand;
let country: Country;
function startScenario() {
command = CreateCountryCommandMother.random();
country = CountryMother.createFromCreateCountryCommand(command);
}
beforeEach(startScenario);
it('should create a country', async () => {
await handler.execute(command);
const countryStored = countryRepository.stored();
expect(countryRepository.storedChanged()).toBeTruthy();
expect(countryStored).toHaveLength(1);
expect(countryStored[0].toPrimitives()).toEqual(country.toPrimitives());
});
it('should publish an event', async () => {
await handler.execute(command);
const countryCreatedEvent = CountryCreatedEventMother.createFromCreateCountryCommand(command);
expect(eventBus.domainEvents()).toHaveLength(1);
expect(eventBus.domainEvents()[0]).toEqual({
...countryCreatedEvent,
});
});
});
});
As seen in the example, they are simple objects, which makes our tests cleaner, more decoupled, and less fragile. This test suite doesn't know anything about how the repository or the event bus is implemented.
We could change the name of a method, and we would only need to change the test double, and that's it. The test suite would continue working without needing any modifications.