Test Doubles

Last updated Sep 7th, 2021

About this...

A test double is a generic term to refer to any object that stands in for a production one during testing




Originating from the idea of a stunt double (like in action movies), there are two general categories of testing objects that we use in testing: mocks and stubs.

Mocks

  • Mocks: We use a mock to stand in and assert against command-like operations (outgoing interactions or state changes against dependencies). We pass mocks in to the system under test (SUT) and later check during the assert phase of a test that the correct calls to change the dependencies' state were made.
  • Spies: These are exactly the same thing as traditional mocks except that we hand-roll spies manually, whereas with traditional mocks, mocking libraries like ts-auto-mock help you create mocks in a single line or two (I highly recommend this package over basic mocking with Jest). You can see an example of a hand-rolled spy here.

Stubs

  • Dummies: These don't do anything. Nor are they used during a test. These are objects that are just used to fill up parameter lists so that a constructor, function, or method will execute. They can often be null or empty-string.
  • Stubs: A stub is a dependency that we can configure to return different values in query-like scenarios. This generally takes some effort to do.
  • Fakes: A fake is practically the same thing as a stub. They only differ in the sense that we create a fake to sub in for a dependency that doesn't exist yet. This can be done very quickly.

When to use each?

The general rule of thumb is that you should:

Use mocks for commands, use stubs for queries

However, there are more specific rules as well depending on the type of test you're writing.

  • Unit tests (domain layer objects): Don't use mocks. Use stubs when necessary.
  • Use case tests (unit test): Only use mocks to assert that commands were invoked against dependencies. Use stubs when necessary.
  • Contract tests (integration): You're testing the contract between a managed dependency like your application database. Don't mock anything. Ensure that the repository (the adapter to your database) can do everything that the interface (the contract) says it needs to be able to do.
  • Integration tests (incoming adapter tests): You're testing that the incoming communication mechanism (such as an HTTP request from a web server, GraphQL API, webhook or some other incoming port) calls the correct application layer use case. Don't mock the HTTP calls. Use an HTTP testing framework like Supertest to make real requests to your application. You can stub the response for the use case because the behavior is covered in use case tests.
  • Integration tests (outgoing adapter tests): You're testing that you can connect to an unmanaged dependency like Stripe, PayPal, an email-sending service, or some piece of infrastructure shared between several applications in your enterprise (like AWS SES). While writing a test against the real thing is better, choose one of these options.
  • End-to-end tests: Treat the entire application like a black box. Don't mock anything. If you're using 3rd party APIs like Stripe or PayPal for example, if provided, use their Sandbox Environments (see Stripe sandbox docs, see Paypal sandbox docs). Use these specifically for your E2E tests instead of mocking them out entirely. Why? Because we want to test against the most production-like environment as possible. You can, however, stubs (see the Builder pattern) to clean up the creation of request data if necessary.

Additional testing principles

Do not perform assertions on queries; assert the result instead

For example, in a use case test, the following type of assertion is a violation of the rule and can lead to brittle tests. This is due to the fact that a query is an implementation detail.

// While testing that a `createOffer` use case in a vinyl-trading application
// can successfully save and email the vinyl owner.

expect(mockTraderRepo.getTraderReputation).toHaveBeenCalledWith('123')
expect(mockVinylRepo.getVinylOwner).toHaveBeenCalledWith('123')

You should be able to create a mock with merely an interface (the contract)

If you have to manually define the implementation for your mock every single time you want to use one, you'll run into the situation where everytime you add or change a method on your interface, you'll have to update all your mock implementations in your test files.

Take a look at "How to Mock without Providing an Implementation in TypeScript". We explore this problem and provide a solution using ts-auto-mock.