Testing with Chai Spies

Goals

By the end of this lesson, you will be able to:

  • Determine when it is appropriate to use a test spy
  • Run a test using a spy

Vocab

  • Spy/Spies - helpers that we use in tests which can assert functions were called

Why Spy with Our Little Eyes?

One of the biggest hurdles with front-end testing, and why it can be so complex, is that your tests are running in a different environment than your app. Your app runs in the browser, and your tests run in the terminal.

Brainstorm

  • What are some web APIs and technologies that our apps rely on in the browser?
  • Are these defined/available in the terminal environment? How could you find out?

This environmental difference means that we can’t test functionality that’s dependent on the browser. The terminal does not have access to all of the objects and web APIs that we have in the browser, and will therefore not understand things like:

document.getElementById()

because it doesn’t know what a document is. If we look at our window object in the console, pretty much anything that exists here that we want to use in our code, the terminal will not know about or understand. So we can run into some problems testing our code when we want to do things like:

  • manipulate the DOM
  • perform network requests
  • manage localStorage data

We’ll be working with the our firsts tests repo.

  • If you still have the repo on your computer, open it and commit your changes, then run:
    git fetch --all
    git checkout spies-begin
    
  • If you don’t have the repo on your computer, clone it down, then run:
    git checkout spies-begin
    

Practice

Let’s look at what would happen if we tried to test a method that leverages localStorage.

Let’s test for a method saveDetails, which we will expect persists information to localStorage

describe('saveDetails', function() {
  it('should save details to localStorage', function() {
    // setup
    var box = new Box(100, 100);

    // execution
    box.saveDetails();

    // expectation
    expect(localStorage.getItem('box')).to.deep.equal({
      width: 100,
      height: 100
    });
  });
});

When we first run this test, we’re told that the function saveDetails is not defined. That’s no problem, we know how to get past that error. However, the next error we encounter is more challenging.

We’ll see in our terminal ReferenceError: localStorage is not defined. This would be the case even if we changed our expectation to expect(true).to.equal(true), because the test is actually failing during the execution phase when our application code is trying to do localStorage.setItem().

What are our Options?

Mocking LocalStorage

One option is to recreate our own version of localStorage. This might sound daunting at first, but it’s actually not all that much code:

global.localStorage = {
  store: {},
  setItem(keyName, value) {
    this.store[keyName] = value;
  },

  getItem(keyName) {
    return this.store[keyName]
  }
}

This is common in front-end testing. Just like we mock out data to work with, we also sometimes mock out web APIs to bring some of that functionality to the terminal. The problem with this is that if anything is wrong with our implementation of localStorage, our tests might fail even if our application code isn’t actually broken. Alternatively, our tests could pass, even though our real code is broken, because our mocked implementation doesn’t match the real implementation.

The other concern is that it shouldn’t be our responsibility to test code that we didn’t write. Someone else implemented localStorage, it’s their responsibility to test that it does what it is supposed to do. Our responsibility is to test that all of our code uses localStorage correctly.

The better option is to leverage spies.

Spies

Spies are useful for when you want to check that something happened - but you don’t necessarily care exactly what it did. Spies will help you verify calls to methods without actually calling them.

So in our example, we would want to verify that localStorage.setItem() was called, but we don’t actually care to test the result of that method running. (We can assume the browser has already tested their implementation of localStorage, which means that we don’t have to!) We are trusting that as long as we’re invoking localStorage.setItem(), our browser is going to do it.

So all we really want to test is that something was called. We want to spy on localStorage, and make sure that its setItem method was called.

A spy will listen for a specific function, localStorage.setItem, to be called in a test. When it is called, the spy takes over control of localStorage.setItem. The spy runs a “fake” function instead, as if localStorage.setItem had actually run.

To do this, we’re going to add another devDependency to our package.json file:

npm install chai-spies --save-dev

To our test file, we’ll require in our new dependency and configure chai to use it, by adding the following near the top of the file:

const spies = require('chai-spies');
chai.use(spies);

Now instead of mocking out all the functionality of localStorage, we can mock it to an empty object that we’ll spy on. Remember, localStorage doesn’t exist in the terminal, so we’re going to have to put something in place for it, otherwise we’ll just keep encountering that reference error.

global.localStorage = {};

Checkout the following documentation on chai.spy.on for more details about how to call it.

chai.spy.on(localStorage, ['setItem', 'getItem'], () => {});
  1. chai.spy.on() is a method that let’s us define what we want to spy on
  2. the first argument is the object we want to spy on
  3. the second argument is an array of any methods we want to override with a spy (or a single string if we’re only spying on one method)
  4. the third argument is an optional replacement for how those methods should behave/what they should do

So what we’re doing with this code is saying: “I know that localStorage works as it should, because the browser engineers have already tested it. All I want to verify is that I’m actually invoking localStorage.setItem(). I am going to replace the default behavior of localStorage.setItem() with a spy so that I can assert it was called without having to worry about what’s happening under the hood.”

You might be wondering where exactly we should put that mocking/spying code in our test suite. This is a perfect place for a beforeEach. We can set this up inside our saveDetails describe block.

Let’s see how this changes the assertion logic of our test:

describe('saveDetails', function() {
  before(function() {
    global.localStorage = {}
    chai.spy.on(localStorage, ['setItem', 'getItem'], () => {})
  })

  it('should save details to localStorage', function() {
    // setup
    var box = new Box(100, 100);

    // execution
    box.saveDetails();

    // expectation
    expect(localStorage.setItem).to.have.been.called(1);
    expect(localStorage.setItem).to.have.been.called.with('box', JSON.stringify({ width: 100, height: 100 }));
});

We have two assertions here:

  1. verifies that localStorage.setItem was called one time
  2. verifies that it was called with accurate arguments

Now that our test isn’t erroring out on us, we can actually go ahead and implement our method. Go ahead and add this implementation, and see if the test passes now.

saveDetails() {
  localStorage.setItem('box', JSON.stringify({
    height: this.height,
    width: this.width
  }));
}

Huzzah! The test passes! This might make you uncomfortable. You might be saying “But how do I know that my data was properly set in localStorage?” It boils down to trust. If you’re verifying that localStorage gets called with the correct arguments, we can trust that someone else already tested that localStorage will store that data correctly.

Check for Understanding

In your journal, write about the other web APIs and libraries you’ll be using in your projects. Where might spies help you? What will be your strategy?

Wrap Up

In your journals, let’s reflect on what we just learned. Look back at the code we used to create our spies, and write down:

  • Why are each of the 3 arguments passed into chai.spy.on necessary?
  • What does each one “do”?

Finally, spend some time thinking about your current project.

  • What pieces of functionality is code you DID NOT CREATE (like localStorage and its setter/getter methods)?
  • How would you write the spies for those pieces?
  • What questions do you still have?
  • What might you do to find answers to your questions? (Who would you talk with, what documentation would you research?)

Lesson Search Results

Showing top 10 results