HomeBlogGitHubLinkedIn

Testing event emitters with Jest

08 June, 2020 - 4 min read

Writing unit tests for code that relies on third party libraries can sometimes be tricky. We're all familiar with module mocking, but what if code that we want to cover is triggered by external events? Today we'll be improving test coverage in these cases. To make our lives easier, let's call our module analytics and assume it exposes the following methods:

// subscribe to event with callback function
analytics.on = (name: string, callback: (data: any) => void) => { ... }
// set context for service
analytics.setContext = (ctx: object) => { ... }
// send message to analytics service
analytics.send = (msg: string) => { ... }

Note: we won't be looking closely at the implementation of those methods. We'll be creating a mocked version that will only serve the purpose of ensuring the code is executed.

Tested code

Let's examine the following code. This simple component has to display content, few links, and handle events received from our analytics module.

import React from "react"
import analytics from "analytics"

const Component = ({ propagateResults }) => {
  analytics.on("open", data => {
    analytics.setConfig({ closed: false })
    propagateResults(data)
  })
  return (
    <>
      <h1>Hello world!</h1>
      <p>Lorem ipsum dolor sit amet</p>

      <a href="/blog/" onClick={() => analytics.send("clickedOnBlock")}>
        blog
      </a>
      <a href="/about/" onClick={() => analytics.send("clickedOnAbout")}>
        about
      </a>
    </>
  )
}

export default Component

Basic test

Let's start writing the test suit by shallow rendering it using enzyme.

describe("Component test cases", () => {
  it("should ensure component renders without a crash", () => {
    shallow(<Component propagateResults={() => {}} />)
  })
})

The results are somewhat expected, we're asserted that content is displayed and nothing breaks spectacularly.

 PASS  ./index.test.js
  Component test cases
    ✓ should ensure component renders without a crash

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files  |   42.86 |      100 |      25 |   42.86 |
 index.js |   42.86 |      100 |      25 |   42.86 | 6-7,14-17
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.314 s, estimated 1 s

Advanced test with module mocking

In order to improve the results, we'll have to dive into mocking analytics module with Jest. The following code will produce a mocked "module" which allows us to emit events on command. It will also enable us to subscribe callback functions using on method.

We'll mock send and setConfig properties as well, just to be sure every condition is tested properly.

jest.mock("./analytics", () => {
  const EventEmitter = require("events")
  const emitter = new EventEmitter()
  emitter.send = jest.fn()
  emitter.setConfig = jest.fn()
  return emitter
})

In order for the trick of "simulating" the event emission to work, we'll tell Jest to use fake timers and trigger the event with a setTimeout method with an arbitrary number of milliseconds.

jest.useFakeTimers()
setTimeout(() => analytics.emit("open", results), 100)

This works by replacing the internal clock with time crystals that are allowing us to freely manipulate time. Really great stuff! You can read more about it here.

Now comes the fun part of assembling all the pieces together.

jest.useFakeTimers()

describe("Component test cases", () => {
  it("should handle events", () => {
    const results = { data: [42] }
    // simulate event after 100ms
    setTimeout(() => analytics.emit("open", results), 100)

    // mock arbitrary function passed to propps
    const mockPropagate = jest.fn()
    const wrapper = shallow(<Component propagateResults={mockPropagate} />)

    // simulate clicking on links
    wrapper
      .find("a")
      .at(0)
      .simulate("click")
    wrapper
      .find("a")
      .at(1)
      .simulate("click")

    // assert onClick action has been triggered succesfully
    expect(analytics.send).toHaveBeenCalledWith("clickedOnBlock")
    expect(analytics.send).toHaveBeenCalledWith("clickedOnAbout")

    // fast-forward 100ms
    jest.advanceTimersByTime(100)
    expect(analytics.setConfig).toHaveBeenCalledWith({ closed: false })
    expect(mockPropagate).toHaveBeenCalledWith(results)
  })
})

As you can see in the last part, by advancing the time by 100ms, we've used internal emit function to trigger callback hooked inside our fake analytics module, thus allowing us to make final assertions.

 PASS  ./index.test.js
  Component test cases
    ✓ should handle events (3 ms)

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files  |     100 |      100 |     100 |     100 |
 index.js |     100 |      100 |     100 |     100 |
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.302 s, estimated 1 s