Friday, June 17, 2016

TDD of a react component


TLDR: 

Building a react component using TDD forces you to focus on the component's interface - and ends up with a cleaner, more well defined component. However, the JavaScript space is so complex that figuring out how to write the tests is a project in itself. This blog explains bits of what you need.

The Tests

Recently at work, I've had to learn the React framework (as well as Redux and a few others). After a bit of googling, the sensible place to start was writing a simple login component. The role of this component is to show two fields: username and password, and submit the data to a loginService (I come from a back end development perspective and have developed a habit of wrapping external integration points in a service).

The tests I decided on were:

  • The component has a username field
  • The component has a password field
  • The component has a submit button
  • The component calls the login service's login method when submit is pressed
  • The component passes the value of username to the login service
  • The component passes the value of password to the login service
  • If login is successful then the component redirects to "/app"
  • If the login is not successful then the component displays an error.
These tests can, broadly, be broken down into three categories: structural, interaction start, and interaction end.

Test Setup

For those who are interested, here are my imports:
import React from 'react';
import LoginForm from '../../src/components/LoginForm.jsx';
import {mount} from 'enzyme';
import LoginService from "../../src/services/LoginService.jsx"

I use Karma/Jasmine for the test runner and, in addition, have webpack setup to produce a deployable bundle including the tests so I can run the tests in my user's web browser if they are having issues. That tells me which tests are failing in their browser with their set of plugins, configuration, etc, etc.

Structural

My initial approach to writing the structural test was:
  • Render the component using ReactTestUtils
  • Extract the DOM component using react-dom
  • Wrap the DOM in a jQuery object
  • Find the appropriate field and assert there is one of it.
However, after writing the test that way, I found out about the enzyme library. Enzyme, essentially, wraps the first 3 steps into 1 call.

For field tagging, I decided to use the "class" attribute. Could have used "id" attribute instead, but class made sense at the time.

My first test looked something like this:
it("Has a username field", () => {
  // setup
  var noop = () => {};
  var loginForm = mount(<LoginForm/>);

  // test and asserts
  expect(loginForm.find("input.username").length).toEqual(1);
});

Interaction Start

Here I quickly found that setting the value of a field in React/Enzyme is easy. Well, it's easy after an hour or so of using google. Note that I haven't written the loginService yet, so I'm just creating a mock object. Which I'd do anyway - because otherwise this test is not self-contained. Note that I eventually refactored this code to pass the LoginService object as part of the React context. That ended up being a nicer way to inject global singletons as dependancies - passing all of them pervasively through the react tree was going to be a lot of overhead for little gain.
it("Passes the username to the defined login method", () => {
  // setup
  var successPromise = { then: (s,r) => s("test token")};
  var loginService = new LoginService();
  spyOn(loginService, "login").and.returnValue(successPromise);
  var loginForm = mount(<LoginForm loginService={loginService}/>);
  var usernameField = loginForm.find("input.username");
  usernameField.simulate("change", {target: {value: "username"}});

  // test
  loginForm.find("button.submitLogin").simulate("click");

  // asserts
  expect(loginService.login).toHaveBeenCalledWith("username", jasmine.anything());
});

Interaction End

After some thought about this test, I came to the conclusion that the login form should not be responsible for the decision of what to do after the login succeeds. So I added a parameter for the "loginSuccess" function and verified that the function was called.

it("Passes the onLogin method to the 'then' promise returned by the login service", (done) => {
  // asserts
  var loginSuccess = token => {
    expect(token).toEqual("test token");
    done();
  };

  // setup
  var successPromise = { then: (s,r) => s("test token")};
  var loginService = new LoginService();
  spyOn(loginService, "login").and.returnValue(successPromise);
  loginForm = mount(
    <LoginForm onLogin={loginSuccess} loginService={loginService}/>;

  // test
  loginForm.find("button.submitLogin").simulate("click");
});


No comments: