DEV Community

Thomas Heniart
Thomas Heniart

Posted on

Test Doubles: A Quick Guide to Boost Your Testing Skills

Welcome to the fast-paced realm of software development, where mastering Test Doubles is your key to crafting rock-solid
code and easing your way to TDD mastery. In just a few seconds, we'll unravel the secrets of spies, stubs, and fakes,
exploring their power to supercharge your testing game. Whether you're a TDD veteran or a newcomer, join us on this
quick journey to transform your approach to testing and elevate our code quality.


Doubles come into play when you prefer not to employ a specific implementation of an interface in your tests,
streamlining the test-writing process and speeding up feedback on actual features. They prove valuable in ensuring test
independence from infrastructure, such as crafting Doubles for a Repository and contributing to more effective and
efficient testing.

Dummy

Dummies are services that our SUT (System Under Test) depends on but are irrelevant to the test scope.

Example with a banking account creation feature relying on a BankingAccountRepository as well as an EventPublisher and
our current tests do not have any concern for the event publisher. We can just replace it with a Dummy implementation
that will not affect our testing system.

class CreateBankingAccount {
    constructor(
        private readonly _bankingAccountRepository: BankingAccountRepository,
        private readonly _eventPublisher: EventPublisher
    ) {
    }

    execute() {
        //Some business logic and references to _eventPublisher
    }
}

interface EventPublisher {
    publish(event: Event): void
}

class DummyEventPublisher implements EventPublisher {
    publish(event: Event): void {
        // Do nothing, we call it a dummy implementation
    }
}
Enter fullscreen mode Exit fullscreen mode

Spy

A test spy is a tool that captures indirect output and provides necessary indirect input, dealing with output that is
not directly observable.

In the given code snippet, we obtain detailed information about "published" events. This is especially valuable in an
event-driven system where you need or must publish events on a bus without yet knowing the subsequent actions they may
trigger.

The key motivation behind employing a spy is to gain a more in-depth insight into the internal state of the system, even
though this comes at the expense of heightened coupling.

it("publishes a banking account event", () => {
    const spy = new SpyEventPublisher();
    const useCase = new CreateBankingAccount(bankingAccountRepository, spy);

    useCase.execute();

    expect(spy.events.length).toEqual(1);
    expect(spy.events[0]).toEqual({id: "newBankingAccountId"});
})

class SpyEventPublisher implements EventPublisher {
    private readonly _events: Array<Event> = []

    publish(event: Event): void {
        this._events.push(event)
    }

    get events() {
        return this._events
    }
}

class CreateBankingAccount {
    constructor(
        private readonly _bankingAccountRepository: BankingAccountRepository,
        private readonly _eventPublisher: EventPublisher
    ) {
    }

    execute() {
        //Some business logic
        this._eventPublisher.publish({id: "newBankingAccountId"})
    }
}
Enter fullscreen mode Exit fullscreen mode

Stub

A stub is an object that returns fake data injected into it.

Consider a scenario where our feature relies on an external API to fetch user data, such as a credit score needed for
creating an account. To ensure the service functions as expected, we can build a stub object with fake values.

The following snippet represents a potential implementation of a stub for the external API.

interface UserDataGateway {
    creditScore(email: string): number
}

class StubUserDataGateway implements UserDataGateway {
    private _creditScoreValue: number

    creditScore(email: string): number {
        return this._creditScoreValue;
    }

    set creditScoreValue(value: number) {
        this._creditScoreValue = value;
    }
}
Enter fullscreen mode Exit fullscreen mode

Then our test would look like:

it("requires a positive credit score to create a bank account", async () => {
    const stub = new StubUserDataGateway();
    const useCase = new CreateBankingAccount(bankingAccountRepository, new DummyEventPublisher());
    {
        stub.creditScoreValue = 0
        useCase.execute({email: "john@doe.com"});
        expect(bankingAccountRepository.accounts).toEqual([]);
    }
    {
        stub.creditScoreValue = 20
        useCase.execute({email: "jane@doe.com"});
        expect(bankingAccountRepository.accounts).toEqual([/*An account object*/]);
    }
})

class CreateBankingAccount {
    constructor(
        private readonly _bankingAccountRepository: BankingAccountRepository,
        private readonly _userDataGateway: UserDataGateway
    ) {
    }

    execute({email}: { email: string }) {
        if (!this._userDataGateway.creditScore(email)) return
        // Bank account creation logic
    }
}
Enter fullscreen mode Exit fullscreen mode

I'm confident you've already noticed a flaw in this design. The issue lies in the fact that we could replace the email
with any string in the statement below, because of our Stub implementation.

this._userDataGateway.creditScore(email)
Enter fullscreen mode Exit fullscreen mode

This is why, most of the time, we prefer to add a touch of simple logic rather than keeping it entirely straightforward,
as you can see in the following code snippet.

class StubUserDataGateway implements UserDataGateway {
    private _creditScores: { [email: string]: number } = {}

    creditScore(email: string): number {
        return this._creditScores[email] || 0;
    }

    feedWith(email, creditScore) {
        this._creditScores[email] = creditScore;
    }
}
Enter fullscreen mode Exit fullscreen mode

Leading our test implementation to

it("requires a positive credit score to create a bank account", async () => {
    const stub = new StubUserDataGateway();
    const useCase = new CreateBankingAccount(bankingAccountRepository, new DummyEventPublisher());
    {
        stub.feedWith("john@doe.com", 0)
        useCase.execute({email: "john@doe.com"});
        expect(bankingAccountRepository.accounts).toEqual([]);
    }
    {
        stub.feedWith("jane@doe.com", 1)
        useCase.execute({email: "jane@doe.com"});
        expect(bankingAccountRepository.accounts).toEqual([/*An account object*/]);
    }
})
Enter fullscreen mode Exit fullscreen mode

Fake

Last but not least, Fakes! They are primarily used when we want to implement an architectural interface with some
in-memory logic, so we are not bound to an external infrastructure service like a Database for example. Depending on its
implementation, but most of the time, a fake implementation can be used in a production-like environment for demo
purposes.

The following snippet represents a simple but common Fake implementation of a repository in a trivial system:

class InMemoryBankingAccountRepository implements BankingAccountRepository {
    private readonly _accounts: Array<BankingAccount> = []

    create(bankingAccount: BankingAccount) {
        this._accounts.push(bankingAccount)
    }


    get accounts(): Array<BankingAccount> {
        return this._accounts;
    }
}


it("creates an account", async () => {
    const repository = new InMemoryBankingAccountRepository()
    const useCase = new CreateBankingAccount(bankingAccountRepository)

    useCase.execute({accountId: "someAccountId"})

    expect(repository.accounts).toEqual([{accountId: "someAccountId"}])
})
Enter fullscreen mode Exit fullscreen mode

In future articles, we will dive deeper into these concepts and apply them in real-world applications.

Stay tuned, and feel free to follow me on this platform and
on LinkedIn, where I share insights every week about software
design, OOP practices, discoveries, and my projects! πŸ’»πŸ„

Top comments (0)