Charles Peters UI Engineer

Testing UI

Up until about 6 months ago, I had never really written any unit tests for code I had written. It just wasn't a part of my routine. When I started working on a team full-time, testing felt like it pretty much consumed every decision I made.

But upfront, before we get started, I should probably tell you I do not care about the semantics and differences between TDD and BDD. I'm only really interested in the practical, in-the-weeds ideas on testing; honestly I don't know why those two strategies are often pitched as mutually exclusive.

With the product I work on, we have two types of tests:

  1. Our integration test suite, which is the end-to-end test
  2. A pile of unit tests we inherited and other assorted piles of unit tests we don't run very often (and by we, I mean me)

Integration testing is very high value; it puts a strict emphasis on user needs, user flows and edge cases. They take a good bit of time to write and are very frequently run against features or discover points of failure system-wide aside from changes. More technically though, it's built using Nightmare.js and Mocha/Chai.

Code Coverage

Typically, we don't worry about code coverage. I wish we would, but it's a shared opinion that code coverage numbers aren't a significant enough evaluation of overall system health, and if we're going to quantify value of code coverage it does rank lower than integration testing or unit testing.

Unit Testing

Our unit tests largely revolve around the React components that we write. In a perfect world, every component gets propTypes, a unit test and component-level styles.

A quick note about Mocha. I really don't like Mocha, Chai, Sinon or Istanbul. I don't like them enough to consider other sources of caffeine. I have a huge bias for Jest. I like it a lot (mostly because I have the most familiarity with it). It very easily covers the concerns of the previous 4, it's faster, it doesn't silo the documentation into too many random places, and it can help auto-mock dependencies and uses snapshot testing which I love for working with React components. Now back to your regularly scheduled blog post.

So I've started to ask a lot of questions about what testing code is for and the role it should play as we're building shared UI components.

If unit testing is about testing the code I wrote without the bother of everything else, where is there more value in testing? Will a user ever arrive in a context where they reach this component in isolation like this? The code that's tested comprises a UI that goes out in front of users. Wouldn't it follow that testing the user facing portion of it is just as valuable as testing a block of code?

I don't think we as UI engineers ever want to test React or its lifecycle, there are teams at Facebook who are in charge of that. I expect React to do the things it says on the tin. I want to test if my <Component /> performs what I design it to do.

A lot of tests I write test whether the input of something is a certain type, like an object, an array, a date and an HTML element. Frankly, this kind of checking can get resolved with static type analysis with Flow or TypeScript.

So if I'm testing a React Component and the previous two sets of details, writing a unit test becomes a little more difficult. This post gives a practical look into a lot methods to writing unit tests for React, but using snapshots can resolve a lot of that very quickly.

Well, then what do we test? Testing the composition of components can be an investing idea. Let's say we had this component for a new feature:

class Feature extends Component {
  state = {
    title: '',
    body: '',
    dateAdded: new Date(),
    preferences: this.props.preferences
  }

  onSubmit = () => fetch('/api/endpoint',  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(this.state)
  })

  render() {
    return (
      <form onSubmit={this.onSubmit}>
        <Input
          label='title'
          value={this.state.title}
          onChange={e => this.setState({ title: e.target.value })}
        />
        <Area
          value={this.state.body}
          onChange={e => this.setState({ body: e.target.value })}
        />
        <footer>
          <Button
            title='Cancel'
            onClick={this.onCancel}
          />
          <Button
            disabled={this.state.title.length !== 0}
            title='Submit'
            onClick={this.onSubmit}
          />
        </footer>
      </form>
    )
  }
}

We could use Enzyme to mount this component in a JSDOM environment (with mocked XHRs) and use Enzyme to pretend to interact with the component. We could write several test cases like this:

describe('Feature', () => {
    xit('submits form w/o preference')
    xit('doesn\'t submit form w empty fields')
    xit('unmounts the Component on Cancel')
    xit('posts with new date')
})

This gives a look at what I feel is the value of testing your components: single encapsulation of a user-facing feature and then dealing with what this component should do with a focus on user behavior.

By giving these tests a user focus, your acceptance criteria for your story can easily be ported to be your actual test cases.

If you're going to write a unit test at all for your code, (seriously, type checking can get a shit-load done for you if you lean into it), there should be a baseline for working on unit tests.

Snapshot testing with Jest is a topic worthy of its own post but I think it can be really valuable. A snapshot creates a tree that describes the React component in a snapshot file (commit these into version control). As you're working through a refactor, your test runner will run against the snapshot and verify that it's still rendering correctly. On the surface this doesn't seem really valuable, but most of the time you're working on a refactor, we want to make sure we're not causing any changes to the UI and our components are handed back the props and rendering the same way. Since they're fairly easy to write they're the kind of test that feels trivial to have as a part of your workflow.

import React from 'react'
import Feature from '../Feature'
import renderer from 'react-test-renderer'


describe('Feature', () => {
    xit('submits form w/o preference')
    xit('doesn\'t submit form w empty fields')
    xit('unmounts the Component on Cancel')
    xit('posts with new date')

    it('renders correctly', () => {
      const tree = renderer.create(<Feature />).toJSON()
      expect(tree).toMatchSnapshot()
    })
})

Unit tests should be very easy to write, mocking out dependencies is tedious and isolating the scope of our module is time consuming. The more labor-intensive this process becomes the harder it is to write tests. That's one of the things I love about React + Jest: the isolation comes for free with good component hierarchies and Jest + Enzyme make mocking out dependencies relatively straight-forward.

Acceptance Testing with Nightmare.js

But there's value in what I've come to understand as 'acceptance testing'. In a really boring explanation it's a way to measure our software against user's needs and/or business requirements to see if it's "acceptable" for release. For more of the context, I see it as a technique to observe our whole UI and user flows versus testing just a single component.

In the case of something like Next.js or CRA or any locally running application, we could use Nightmare.js or Puppeteer to visit() our whole application at localhost:3000 and start making assertions about our application in a real browser environment, not mocked out one, under real conditions.

import nm from 'nightmare'

describe('New Feature to update profiles', () => {
    it('Feature updates profile', async () => {
        let page = nm().goto('http://localhost:3000')

        let result = await page
            .type('.Feature input', 'Allison')
            .click('.Feature [type=submit]')
            .wait('.Profile')
            .evaluate(() => document.querySelector('.Profile h1').textContent)
            .end()

        let cancelledResult = await page
            .type('.Feature input', 'Allison')
            .click('.Feature .btn-cancel')
            .wait('.Profile')
            .evaluate(() => document.querySelector('.Profile h1').textContent)
            .end()

        expect(result).toContain('Allison')
        expect(cancelledResult).toContain('Sandra')
    })
})

I think that by itself has more value than just unit tests by themselves. It gives us the opportunity to apply user-facing language to the features we're making. Like I said earlier about unit tests: giving these tests a user focus, your acceptance criteria for your story can easily be ported to be your actual test cases. Acceptance testing is the more traditional venue for these tests, but having this ethic in both places ensures you can be giving feedback to other members of your team in the same language that you were tasked with for the feature in front of you.


Key Observations

So I don't have much of a point in this post other than being exposed to a lot of different types of UI testing, but here are some trends I've observed in the process:

  1. React is really good at isolating concerns for testing.
  2. Unit testing should be low-hanging fruit, and I've come to find that Jest and Enzyme can make unit tests easy to write when you write components that are simple and logical to follow, ie. valuing composition over inheritance.
  3. Snapshot testing is trivial to execute and wonderful for sanity checking in any large refactor.
  4. Tests need to have a user facing value wherever possible.
  5. Recreating the universe is wonderful and everything but there's a value on how our application runs under real conditions.

Further Reading