Understanding Test Doubles: Spies and Mocks

In the previous installment, we walked through a basic unit test for a calculator. What we didn't discuss is what the term unit actually means in the context of unit testing.

There are a few distinct strategies for testing an application:

Unit Test: This approach verifies a single piece of code in complete isolation. That isolation means no real dependencies are involved. For instance, a component is tested without its actual services or any templates. A service is tested without other services, and so on.

Integration Test: Here, we confirm that multiple pieces function correctly together. Many in the community consider testing a component alongside its template to be an integration test. We'll dig deeper into this in future parts.

End to End: An end-to-end (e2e) test validates that our user stories work from beginning to end. This typically includes server calls, authentication, and similar infrastructure. That topic might be covered in a separate series.

In the Angular ecosystem, we aim to write as many Unit Tests as we can. They are more cost-efficient, both in terms of initial effort and long-term maintenance.

testing pyramid

Let's shift to a fresh example, and this time, we'll focus our attention squarely on the test code.

(If you'd like to code along, I've prepared a codesandbox you can use.)

This example is deliberately simplified, but it serves our learning purposes well.

We start with a recipe service:

File: src/recipe.service.ts

export interface Recipe {
  name: string;
  ingredients: string[];
  cookTemperature: number;
  temperatureUnit: string;
  steps: string;
}

export class RecipeService {
  getRecipes() {
    // In a real world, this is calling some backend
    // through an API call
    return [
      {
        name: "Pizza",
        ingredients: ["Tomato", "Mozarella", "Basil"],
        cookTemperature: 500,
        temperatureUnit: 'F',
        steps: "Put in oven until it gets your desired doneness"
      }
    ];
  }
}
Enter fullscreen mode Exit fullscreen mode

This service exposes a method named getRecipes which provides a collection of recipes. In a production scenario, this would likely be a genuine HTTP call. We don't need to worry about that detail here.

Next, there's a service responsible for converting temperatures from Fahrenheit to Celsius:

File: src/temperature.service.ts

export class TemperatureService {
  fahrenheitToCelsius(temperature: number): number {
    return ((temperature - 32) * 5) / 9;
  }
}
Enter fullscreen mode Exit fullscreen mode

Nothing particularly exciting there.

Finally, we have a component (again, a simplified one with no template) that relies on both of these services:

File: src/recipe.component.ts

import { Recipe, RecipeService } from "./recipe.service";
import { TemperatureService } from "./temperature.service";

export class RecipeComponent {
  recipes: Recipe[];

  constructor(
    private recipeService: RecipeService,
    private temperatureService: TemperatureService
  ) {}

  fetchRecipes() {
    this.recipes = this.recipeService.getRecipes();
  }

  printRecipesInCelsius() {
    return this.recipes.map((recipe) => {
      const cookTemperature = this.temperatureService.fahrenheitToCelsius(
        recipe.cookTemperature
      );
      return {
        ...recipe,
        temperatureUnit: 'C',
        cookTemperature
      };
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

This recipe component holds references to both services. It includes one method that retrieves recipes from the service and stores them locally, and another that produces a new list where the temperatures have been converted to Celsius.

Our task is to write a unit test for this component class. Let's begin by opening the spec file and laying down the basic test structure:

File: src/recipe.component.spec.ts

import { RecipeComponent } from "./recipe.component";

describe("RecipeComponent", () => {
  let component: RecipeComponent;

  beforeEach(() => {
    component = /* what goes here? */
  });
});
Enter fullscreen mode Exit fullscreen mode

Before we rush off and think "Clearly, we need to hand over an instance of both services," let's pause and reason about this.

What is this component's actual responsibility? It maintains a list of recipes, a method for fetching recipes, and a method that returns recipes with temperatures in Celsius.

That's the whole story. The component doesn't concern itself with how the recipes are retrieved by the service. Its only concern is that recipeService.getRecipes() yields a recipe list. We need to trust that the service itself has its own tests. The component's responsibility ends at "I invoke this method to get the recipes I need."

Given that, passing a genuine RecipeService instance into the component means our test is now tied to the real service's behavior. If that service makes a call to a slow backend, our test suite becomes sluggish and brittle.

Put simply, using the real RecipeService here would only introduce extraneous complexity to our test. As noted at the outset, the goal of a unit test is to exercise our code in complete isolation.

So, how do we get this working without the actual service?

Mocks

A mock is essentially a stand-in object designed to imitate another object for testing purposes. It exposes the same shape and methods as the original, but its functionality is drastically simplified—sometimes even a no-op.

That might seem abstract, so let's see a concrete example:

File: src/recipe.component.spec.ts

import { RecipeComponent } from "./recipe.component";
import { RecipeService } from "./recipe.service";

const recipeServiceMock: RecipeService = {
  getRecipes: () => []
}

describe("RecipeComponent", () => {
  let component: RecipeComponent;

  beforeEach(() => {
    // ommited for now
  });
});
Enter fullscreen mode Exit fullscreen mode

Our recipeServiceMock is a mock of the RecipeService. It mirrors the interface, namely the getRecipes method, but simply returns an empty array. That's perfectly acceptable. We're only concerned with verifying that its methods are invoked by our SUT (subject under test, meaning the code we're validating).

With this mock in hand, we can now instantiate our component for testing:

File: src/recipe.component.spec.ts

describe("RecipeComponent", () => {
  let component: RecipeComponent;

  beforeEach(() => {
    component = new RecipeComponent(recipeServiceMock, ...)
  });
});
Enter fullscreen mode Exit fullscreen mode

That's a start. We'll apply the same approach to the TemperatureService.

File: src/recipe.component.spec.ts

import { RecipeComponent } from "./recipe.component";
import { RecipeService } from "./recipe.service";
import { TemperatureService } from "./temperature.service";

const recipeServiceMock: RecipeService = {
  getRecipes: () => []
}

const temperatureServiceMock: TemperatureService = {
  fahrenheitToCelsius: () => 0
}

describe("RecipeComponent", () => {
  let component: RecipeComponent;

  beforeEach(() => {
    component = new RecipeComponent(recipeServiceMock, temperatureServiceMock);
  });
});
Enter fullscreen mode Exit fullscreen mode

Now that our foundational structure is in place, let's write our first test. We want to verify that the component actually triggers the service call to fetch recipes:

File: src/recipe.component.spec.ts

it("calls a service to fetch the recipes", () => {
  component.fetchRecipes();
});
Enter fullscreen mode Exit fullscreen mode

Hold on. In this test, we're just invoking the fetchRecipes method. We are assuming it's supposed to call the service, but we haven't verified that yet. How can we actually confirm this behavior?

Spies

Spies give us the ability to capture detailed call information about functions. We can track invocation counts, inspect the arguments passed, and more.

This is exactly what we need for our tests, right? Jest provides a simple method to create a spy:

File: src/recipe.component.spec.ts

import { RecipeComponent } from "./recipe.component";
import { RecipeService } from "./recipe.service";
import { TemperatureService } from "./temperature.service";

const recipeServiceMock: RecipeService = {
  getRecipes: jest.fn()
}

const temperatureServiceMock: TemperatureService = {
  fahrenheitToCelsius: jest.fn()
}
Enter fullscreen mode Exit fullscreen mode

At this point, getRecipes and fahrenheitToCelsius behave like empty functions, but they are now equipped with spying capabilities.

This allows us to enhance our test with new assertions:

File: src/recipe.component.spec.ts

it("calls a service to fetch the recipes", () => {
  component.fetchRecipes();

  expect(recipeServiceMock.getRecipes).toHaveBeenCalled();
});
Enter fullscreen mode Exit fullscreen mode

In this scenario, we invoke fetchRecipes and then verify that getRecipes from our RecipeService was indeed called.

Does our test pass?

1 test pass

It absolutely does. We don't care how the service obtains the recipes. Our only concern is verifying that the component invokes the correct method at the appropriate time. No actual service code was executed in the process.

While that works for many straightforward tests, the actual implementation returns a list of recipes that our component stores. We need to test that behavior as well, because even if the service was called, we might have failed to assign the result to a variable.

Let's extend our mock to both spy and return recipes.

File: src/recipe.component.spec.ts

import { RecipeComponent } from "./recipe.component";
import { Recipe, RecipeService } from "./recipe.service";
import { TemperatureService } from "./temperature.service";

const recipes: Recipe[] = [
  {
    name: "Chicken with cream",
    ingredients: ["chicken", "whipping cream", "olives"],
    cookTemperature: 400,
    temperatureUnit: 'F',
    steps: "Cook the chicken and put in the oven for 25 minutes"
  }
];

const recipeServiceMock: RecipeService = {
  getRecipes: jest.fn().mockReturnValue(recipes)
};
Enter fullscreen mode Exit fullscreen mode

We start by creating a mock recipe, then we attach .mockReturnValue to our spy so it also provides a value.

Now we can introduce a new expectation to our test.

File: src/recipe.component.spec.ts

it("calls a service to fetch the recipes", () => {
  component.fetchRecipes();

  expect(component.recipes).toBe(recipes);
  expect(recipeServiceMock.getRecipes).toHaveBeenCalled();
});
Enter fullscreen mode Exit fullscreen mode

1 test pass

The tests continue to pass. We now verify both that the service was called and that the recipes are correctly assigned locally.

NOTE: A single test can contain multiple expectations; there's no restriction to just one.

For our second test, we want to confirm that the recipes can be retrieved with temperatures in celsius.

File: src/recipe.component.spec.ts

it('can print the recipes with celsius using a service', () => {
  component.fetchRecipes();

  expect(component.recipes[0].cookTemperature).toBe(400);
  expect(component.recipes[0].temperatureUnit).toBe('F');

  const recipesInCelsius = component.printRecipesInCelsius();

  const recipe = recipesInCelsius.pop();

  expect(recipe.cookTemperature).not.toBe(400);
  expect(recipe.temperatureUnit).toBe('C');

  expect(temperatureServiceMock.fahrenheitToCelsius).toHaveBeenCalledWith(400);
});
Enter fullscreen mode Exit fullscreen mode

Let's walk through this step by step. First, we invoke fetchRecipes to populate the component's recipes. Then, before making any changes, we verify that the current temperature and unit are at their default values.

Next, we call printRecipesInCelsius and assert that cookTemperature is no longer 400 (we don't need to verify the exact value here; that's covered in the service's tests) and that the unit has changed to 'C'.

Finally, we confirm that the service was called with the correct parameter.

2 test pass

This test passes as well.

At this stage, we're essentially done. We've verified that our component interacts with the services correctly without interfering with their internal implementation.

Do we always need to mock?

Ah, that's a great question. The answers vary depending on who you ask. In my view, if a service is extremely simple, mocking it might be unnecessary overhead. The real RecipeService would likely make HTTP requests to fetch recipes, but a service like TemperatureService is so straightforward that it won't impact our tests at all.

In essence, when a service is small, has no external dependencies, and executes quickly, we can safely skip mocking it.

Let's modify our code to use the real temperature service instead of a mock:

File: src/recipe.component.spec.ts

const recipeServiceMock: RecipeService = {
  getRecipes: jest.fn().mockReturnValue(recipes)
};

const temperatureService = new TemperatureService();

describe("RecipeComponent", () => {
  let component: RecipeComponent;

  beforeEach(() => {
    component = new RecipeComponent(recipeServiceMock, temperatureService);
  });
Enter fullscreen mode Exit fullscreen mode

Here, we simply instantiate our original TemperatureService. For this setup to work, we need to comment out a line from our test.

File: src/recipe.component.spec.ts

it('can print the recipes with celsius using a service', () => {
  component.fetchRecipes();

  expect(component.recipes[0].cookTemperature).toBe(400);
  expect(component.recipes[0].temperatureUnit).toBe('F');

  const recipesInCelsius = component.printRecipesInCelsius();

  const recipe = recipesInCelsius.pop();

  expect(recipe.cookTemperature).not.toBe(400);
  expect(recipe.temperatureUnit).toBe('C');

  // expect(temperatureServiceMock.fahrenheitToCelsius).toHaveBeenCalledWith(400);
});
Enter fullscreen mode Exit fullscreen mode

Since it's no longer a mock, that particular assertion won't work.

2 test pass

But isn't this approach actually worse? Previously, we could verify that the service was called; now we lose that ability. That's true. However, we can apply a spy to the real service, just as we did earlier.

File: src/recipe.component.spec.ts

it('can print the recipes with celsius using a service', () => {
  jest.spyOn(temperatureService, 'fahrenheitToCelsius');
  component.fetchRecipes();

  expect(component.recipes[0].cookTemperature).toBe(400);
  expect(component.recipes[0].temperatureUnit).toBe('F');

  const recipesInCelsius = component.printRecipesInCelsius();

  const recipe = recipesInCelsius.pop();

  expect(recipe.cookTemperature).not.toBe(400);
  expect(recipe.temperatureUnit).toBe('C');

  expect(temperatureService.fahrenheitToCelsius).toHaveBeenCalledWith(400);
});
Enter fullscreen mode Exit fullscreen mode

2 test pass

jest.spyOn functions similarly to jest.fn but targets an existing method. In this case, it will also execute the real service code, but as we established, since it's small and straightforward, that's perfectly acceptable.

Conclusions

When writing unit tests, it's often necessary to mock certain dependencies so that our focus remains solely on the code under test, not on its collaborators.

Through these tests, we ensure our code performs its intended actions and also uses its dependencies correctly and at the right moments.

If a dependency is minimal, has no other dependencies, and runs quickly, we might choose to use the actual implementation instead of a mock.

In the upcoming section, we'll start bootstrapping our Angular component.