Skip to content

Latest commit

 

History

History
370 lines (260 loc) · 16 KB

faking-dependencies.md

File metadata and controls

370 lines (260 loc) · 16 KB
layout title description
chapter
Faking dependencies (Mocking)
How to mock dependencies to test Components and Services in isolation

Faking dependencies

Learning objectives
  • Testing a code unit in isolation
  • Replacing dependencies with fakes
  • Rules for creating fakes to avoid pitfalls
  • Using Jasmine spies to fake functions and methods

When testing a piece of code, you need to decide between an integration test and a unit test. To recap, the integration test includes (“integrates”) the dependencies. In contrast, the unit test replaces the dependencies with fakes in order to isolate the code under test.

Also known as mocking

These replacements are also called test doubles, stubs or mocks. Replacing a dependency is called stubbing or mocking.

Since these terms are used inconsistently and their difference is subtle, this guide uses the term “fake” and “faking” for any dependency substitution.

Faking safely

Creating and injecting fake dependencies is essential for unit tests. This technique is double-edged – powerful and dangerous at the same time. Since we will create many fakes throughout this guide, we need to set up rules for faking dependencies to apply the technique safely.

Equivalence of fake and original

A fake implementation must have the same shape as the original. If the dependency is a function, the fake must have the same signature, meaning the same parameters and the same return value. If the dependency is an object, the fake must have the same public API, meaning the same public methods and properties.

Replaceability

The fake does not need to be complete, but sufficient enough to act as a replacement. The fake needs to be equivalent to the original as far as the code under test is concerned, not fully equal to the original.

Imagine a fake building on a movie set. The outer shape needs to be indistinguishable from an original building. But behind the authentic facade, there is only a wooden scaffold. The building is an empty shell.

The biggest danger of creating a fake is that it does not properly mimic the original. Even if the fake resembles the original at the time of writing the code, it might easily get out of sync later when the original is changed.

When the original dependency changes its public API, dependent code needs to be adapted. Also, the fake needs to be aligned. When the fake is outdated, the unit test becomes a fantasy world where everything magically works. The test passes but in fact the code under test is broken.

Keep fake in sync

How can we ensure that the fake is up-to-date with the original? How can we ensure the equivalence of original and fake in the long run and prevent any possible divergence?

We can use TypeScript to enforce that the fake has a matching type. The fake needs to be strictly typed. The fake’s type needs to be a subset of the original’s type.

Type equivalence

Then, TypeScript assures the equivalence. The compiler reminds us to update the implementation and the fake. The TypeScript code simply does not compile if we forget that. We will learn how to declare matching types in the upcoming examples.

Effective faking

The original dependency code has side effects that need to be suppressed during testing. The fake needs to effectively prevent the original code from being executed. Strange errors may happen if a mix of fake and original code is executed.

Do not mix fake and original

In some faking approaches, the fake inherits from the original. Only those properties and methods are overwritten that are currently used by the code under test.

This is dangerous since we may forget to overwrite methods. When the code under test changes, the test may accidentally call original methods of the dependency.

This guide will present thorough faking techniques that do not allow a slip. They imitate the original code while shielding the original from calls.

Faking functions with Jasmine spies

Jasmine provides simple yet powerful patterns to create fake implementations. The most basic pattern is the Jasmine spy for replacing a function dependency.

Call record

In its simplest form, a spy is a function that records its calls. For each call, it records the function parameters. Using this record, we later assert that the spy has been called with particular input values.

For example, we declare in a spec: “Expect that the spy has been called two times with the values mickey and minnie, respectively.”

Like every other function, a spy can have a meaningful return value. In the simple case, this is a fixed value. The spy will always return the same value, regardless of the input parameters. In a more complex case, the return value originates from an underlying fake function.

`createSpy`

A standalone spy is created by calling jasmine.createSpy:

const spy = jasmine.createSpy('name');

createSpy expects one parameter, an optional name. It is recommended to pass a name that describes the original. The name will be used in error messages when you make expectations against the spy.

Assume we have class TodoService responsible for fetching a to-do list from the server. The class uses the Fetch API to make an HTTP request. (This is a plain TypeScript example. It is uncommon to use fetch directly in an Angular app.)

class TodoService {
  constructor(
    // Bind `fetch` to `window` to ensure that `window` is the `this` context
    private fetch = window.fetch.bind(window)
  ) {}

  public async getTodos(): Promise<string[]> {
    const response = await this.fetch('/todos');
    if (!response.ok) {
      throw new Error(
        `HTTP error: ${response.status} ${response.statusText}`
      );
    }
    return await response.json();
  }
}
Inject fake

The TodoService uses the constructor injection pattern. The fetch dependency can be injected via an optional constructor parameter. In production code, this parameter is empty and defaults to the original window.fetch. In the test, a fake dependency is passed to the constructor.

The fetch parameter, whether original or fake, is saved as an instance property this.fetch. Eventually, the public method getTodos uses it to make an HTTP request.

In our unit test, we do not want the Service to make any HTTP requests. We pass in a Jasmine spy as replacement for window.fetch.

// Fake todos and response object
const todos = [
  'shop groceries',
  'mow the lawn',
  'take the cat to the vet'
];
const okResponse = new Response(JSON.stringify(todos), {
  status: 200,
  statusText: 'OK',
});

describe('TodoService', () => {
  it('gets the to-dos', async () => {
    // Arrange
    const fetchSpy = jasmine.createSpy('fetch')
      .and.returnValue(okResponse);
    const todoService = new TodoService(fetchSpy);

    // Act
    const actualTodos = await todoService.getTodos();

    // Assert
    expect(actualTodos).toEqual(todos);
    expect(fetchSpy).toHaveBeenCalledWith('/todos');
  });
});

There is a lot to unpack in this example. Let us start with the fake data before the describe block:

const todos = [
  'shop groceries',
  'mow the lawn',
  'take the cat to the vet'
];
const okResponse = new Response(JSON.stringify(todos), {
  status: 200,
  statusText: 'OK',
});

First, we define the fake data we want the fetch spy to return. Essentially, this is an array of strings.

Fake response

The original fetch function returns a Response object. We create one using the built-in Response constructor. The original server response is a string before it is parsed as JSON. So we need to serialize the array into a string before passing it to the Response constructor. (These fetch details are not relevant to grasp the spy example.)

Then, we declare a test suite using describe:

describe('TodoService', () => {
  /* … */
});

The suite contains one spec that tests the getTodos method:

it('gets the to-dos', async () => {
  /* … */
});

The spec starts with Arrange code:

// Arrange
const fetchSpy = jasmine.createSpy('fetch')
  .and.returnValue(okResponse);
const todoService = new TodoService(fetchSpy);

Here, we create a spy. With .and.returnValue(…), we set a fixed return value: the successful response.

Inject spy

We also create an instance of TodoService, the class under test. We pass the spy into the constructor. This is a form of manual dependency injection.

In the Act phase, we call the method under test:

const actualTodos = await todoService.getTodos();

getTodos returns a Promise. We use an async function together with await to access the return value easily. Jasmine deals with async functions just fine and waits for them to complete.

In the Assert phase, we create two expectations:

expect(actualTodos).toEqual(todos);
expect(fetchSpy).toHaveBeenCalledWith('/todos');
Data processing

First, we verify the return value. We compare the actual data (actualTodos) with the fake data the spy returns (todos). If they are equal, we have proven that getTodos parsed the response as JSON and returned the result. (Since there is no other way getTodos could access the fake data, we can deduce that the spy has been called.)

Verify call record

Second, we verify that the fetch spy has been called with the correct parameter, the API endpoint URL. Jasmine offers several matchers for making expectations on spies. The example uses toHaveBeenCalledWith to assert that the spy has been called with the parameter '/todos'.

Both expectations are necessary to guarantee that getTodos works correctly.

Happy and unhappy paths

After having written the first spec for getTodos, we need to ask ourselves: Does the test fully cover its behavior? We have tested the success case, also called happy path, but the error case, also called unhappy path, is yet to be tested. In particular, this error handling code:

if (!response.ok) {
  throw new Error(
    `HTTP error: ${response.status} ${response.statusText}`
  );
}

When the server response is not “ok”, we throw an error. “Ok” means the HTTP response status code is 200-299. Examples of “not ok” are “403 Forbidden”, “404 Not Found” and “500 Internal Server Error”. Throwing an error rejects the Promise so the caller of getTodos knows that fetching the to-dos failed.

The fake okResponse mimics the success case. For the error case, we need to define another fake Response. Let us call it errorResponse with the notorious HTTP status 404 Not Found:

const errorResponse = new Response('Not Found', {
  status: 404,
  statusText: 'Not Found',
});

Assuming the server does not return JSON in the error case, the response body is simply the string 'Not Found'.

Now we add a second spec for the error case:

describe('TodoService', () => {
  /* … */
  it('handles an HTTP error when getting the to-dos', async () => {
    // Arrange
    const fetchSpy = jasmine.createSpy('fetch')
      .and.returnValue(errorResponse);
    const todoService = new TodoService(fetchSpy);

    // Act
    let error;
    try {
      await todoService.getTodos();
    } catch (e) {
      error = e;
    }

    // Assert
    expect(error).toEqual(new Error('HTTP error: 404 Not Found'));
    expect(fetchSpy).toHaveBeenCalledWith('/todos');
  });
});

In the Arrange phase, we inject a spy that returns the error response.

Catching errors

In the Act phase, we call the method under test but anticipate that it throws an error. In Jasmine, there are several ways to test whether a Promise has been rejected with an error. The example above wraps the getTodos call in a try/catch statement and saves the error. Most likely, this is how implementation code would handle the error.

In the Assert phase, we make two expectations again. Instead of verifying the return value, we make sure the caught error is an Error instance with a useful error message. Finally, we verify that the spy has been called with the right value, just like in the spec for the success case.

Again, this is a plain TypeScript example to illustrate the usage of spies. Usually, an Angular Service does not use fetch directly but uses HttpClient instead. We will get to know testing this later (see Testing a Service that sends HTTP requests).

Spying on existing methods

We have used jasmine.createSpy('name') to create a standalone spy and have injected it into the constructor. Explicit constructor injection is straight-forward and used extensively in Angular code.

Spy on object methods

Sometimes, there is already an object whose method we need to spy on. This is especially helpful if the code uses global methods from the browser environment, like window.fetch in the example above.

For this purpose, we can use the spyOn method:

spyOn(window, 'fetch');
Overwrite and restore

This installs a spy on the global fetch method. Under the hood, Jasmine saves the original window.fetch function for later and overwrites window.fetch with a spy. Once the spec is completed, Jasmine automatically restores the original function.

spyOn returns the created spy, enabling us to set a return value, like we have learned above.

spyOn(window, 'fetch')
  .and.returnValue(okResponse);

We can create a version of TodoService that does not rely on construction injection, but uses fetch directly:

class TodoService {
  public async getTodos(): Promise<string[]> {
    const response = await fetch('/todos');
    if (!response.ok) {
      throw new Error(
        `HTTP error: ${response.status} ${response.statusText}`
      );
    }
    return await response.json();
  }
}

The test suite then uses spyOn to catch all calls to window.fetch:

// Fake todos and response object
const todos = [
  'shop groceries',
  'mow the lawn',
  'take the cat to the vet'
];
const okResponse = new Response(JSON.stringify(todos), {
  status: 200,
  statusText: 'OK',
});

describe('TodoService', () => {
  it('gets the to-dos', async () => {
    // Arrange
    spyOn(window, 'fetch')
      .and.returnValue(okResponse);
    const todoService = new TodoService();

    // Act
    const actualTodos = await todoService.getTodos();

    // Assert
    expect(actualTodos).toEqual(todos);
    expect(window.fetch).toHaveBeenCalledWith('/todos');
  });
});

Not much has changed here. We spy on fetch and make it return okResponse. Since window.fetch is overwritten with a spy, we make the expectation against it to verify that it has been called.

Creating standalone spies and spying on existing methods are not mutually exclusive. Both will be used frequently when testing Angular applications, and both work well with dependencies injected into the constructor.