At some heirarchy in our application, we will have UI components that are stateful and perform outbound asyncronous requests, typically API calls.

A common example is:

  • API request for data is made
    • When successful and results exist, render data
    • When successful and no results exist, render empty view
    • When error, render error view

It’s common enough that there are UI abstractions for this like react-loadable and React Suspense

Question remains – regardless of implementation, how do test the results of these asyncronous behaviors?

This guide will give some general framework on testing such cases. We’ll be using jest and vue-test-utils helper, but the general concepts apply to any testing framework or UI framework.

Let’s start with a Vue component below doing the typical api request workflow:

<template>
  <div>
    Coffee List
    <div v-if='loading'>
      Loading...
    </div>
    <div v-else-if='hasError'>
      Error Fetching items
    </div>
    <div v-else-if='items.length'>
      <div v-for="(item, index) in items"
           :key='index'>
        {{ item.name }}
      </div>
    </div>
    <div v-else>
      No Items
    </div>
  </div>
</template>

<script>
import api from "@/api";

export default {
  name: "CoffeeList",
  data() {
    return {
      loading: false,
      items: [],
      hasError: false
    };
  },
  async created() {
    try {
      this.loading = true;
      const items = await api.fetchCoffees();
      this.items = items;
    } catch (e) {
      this.hasError = true;
    } finally {
      this.loading = false;
    }
  }
};
</script>

We can see in the markup template the various states to render.

First we’ll have to mock the response of the api as part of our setup.

We can do this with a combination of jest.mock() and jest.fn(). (There are more strategies for mocking in jest as well as other frameworks like sinon)

import { shallowMount, createLocalVue } from "@vue/test-utils";
import CoffeeList from "@/components/CoffeeList.vue";
const localVue = createLocalVue();
import api from "@/api";
jest.mock("@/api", () => {
  return {
    fetchCoffees: jest.fn()
  };
});

describe("CoffeeList", () => {
  describe("when request is successful and non-empty", () => {
    beforeEach(() => {
      api.fetchCoffees.mockImplementation(() => {
        return Promise.resolve([
          {
            id: 1,
            name: "coffee1"
          }
        ]);
      });
    });

    it("displays list of items", () => {
      const wrapper = shallowMount(CoffeeList, { localVue });
      expect(wrapper.text()).toContain("coffee1");
    });
  });
});

Hmmm our test fails. We see that our assertion still contains the loading indicator:

Expected string: "Coffee List   Loading..." To contain value: "coffee1"

In our component’s created lifecycle hook (react equivalent would be compponentWillMount), we expect the api call to be made. But during our assertion the asyncronous behavior has not resolved yet. We’ll need to find a way to do this.

What tools do we have?

JS testing frameworks typically support a callback to invoke when test is complete. For example by adding a done callback to it(), that signals it should wait for this function to be called to complete the test. See the respective docs for jest and mocha. Alternatively we could use async/await.

Beyond this, we still need a strategy to know when the api request is made. Typically in component lifecycle hooks these aren’t exposed publically for testing. Here we’ll describe a couple of strategies to deal with this.

Strategy 1: Waiting for the nextTick when DOM updates are applied

From Vue docs on async update queue:

Vue performs DOM updates asynchronously. Whenever a data change is observed, it will open a queue and buffer all the data changes that happen in the same event loop. If the same watcher is triggered multiple times, it will be pushed into the queue only once. This buffered de-duplication is important in avoiding unnecessary calculations and DOM manipulations. Then, in the next event loop “tick”, Vue flushes the queue and performs the actual (already de-duped) work

Here’s what our test would look like:

it("displays list of items", done => {
  const wrapper = shallowMount(CoffeeList, { localVue });
  wrapper.vm.$nextTick().then(() => {
    expect(wrapper.text()).toContain("coffee1");
    done();
  });
});

Nice, this now passes!

Strategy 2: Flush Promise resolution queue

We can flush all pending resolved promise handlers. There’s a library to do this called flush-promises. It’s a small library and encourage you to peek at the 9 lines that it implements.

it("displays list of items", done => {
  const wrapper = shallowMount(CoffeeList, { localVue });
  flushPromises().then(() => {
    expect(wrapper.text()).toContain("coffee1");
    done();
  });
});

I prefer using this strategy. As app evolves over time, we won’t know how how many async workflows a component will have. And thus we don’t know how many async tasks will be in the queue (read more about that here). Thus flush-promises helps keep your tests less brittle over time.

Continuing with testing various states

Now we have the tools to test our other states of our component. Again, these are:

  • When successful and results exist, render data
  • When successful and no results exist, render empty view
  • When error, render error view

Full test suite below:

import { shallowMount, createLocalVue } from "@vue/test-utils";
import CoffeeList from "@/components/CoffeeList.vue";
import flushPromises from "flush-promises";
import Vuex from "vuex";
const localVue = createLocalVue();
localVue.use(Vuex);
import api from "@/api";
jest.mock("@/api", () => {
  return {
    fetchCoffees: jest.fn()
  };
});

describe("CoffeeList", () => {
  describe("when request is successful and non-empty", () => {
    beforeEach(() => {
      api.fetchCoffees.mockImplementation(() => {
        return Promise.resolve([
          {
            id: 1,
            name: "coffee1"
          }
        ]);
      });
    });

    it("displays list of items", async () => {
      const wrapper = shallowMount(CoffeeList, { localVue });

      await flushPromises();
      expect(wrapper.text()).toContain("coffee1");
    });
  });

  describe("when request is successful and empty", () => {
    beforeEach(() => {
      api.fetchCoffees.mockImplementation(() => {
        return Promise.resolve([]);
      });
    });

    it("displays an empty view", async () => {
      const wrapper = shallowMount(CoffeeList, { localVue });
      await flushPromises();
      expect(wrapper.text()).toContain("No Items");
    });
  });

  describe("when request has an error", () => {
    beforeEach(() => {
      api.fetchCoffees.mockImplementation(() => {
        return Promise.reject();
      });
    });

    it("displays an error view", async () => {
      const wrapper = shallowMount(CoffeeList, { localVue });
      await flushPromises();
      expect(wrapper.text()).toContain("Error Fetching items");
    });
  });
});

Summary - General Testing Workflow

Zooming out, regardless of the testing or UI framework, there’s a general strategy we use to test async behavior of a component:

describe("testing components with async behavior", () => {
  beforeEach(() => {
    // Mock api services or state management async actions. Return Promise object.
  });

  afterEach(() => {
    // Ensure we restore mocks
  });

  it("assert behavior", async () => {
    // 1) Mount component
    // 2) Flush promise queue in some manner
    // 3) Make test assertions
  });
});

Further Reading