Testing React Components

Drivers, Common use cases, Best practices, TDD

Agenda

  • About me
  • Infra team
  • Test Drivers
  • Component Unit testing
  • Component Integration testing
  • Testing Async actions
  • Testing Network requests
  • Redux, MobX & friends
    Testing Components with state management

About me

Shlomi Toussia-Cohen

29 years old, Married to Dana, Lives in Tel-Aviv.

Hobbies: Motorcycles, Gardening.

 

~4 years FED, 11 months in Wix.

FED Guild Master in the Infra Team.

 

Current Team: Wix-Stores (Cart & Checkout)

Previous Team: CX-OS (Business-Manager)

Original Team: FED-Infra

wix-js-stack

Your first stop for new stack projects

#wix-js-stack

wix-js-stack

  • Generators:
    • client / server / fullstack / universal
    • component library / node library
  • Example projects:
    • Experiments
    • Typescript
    • HMR
    • ...
  • How to work with the stack
  • FAQ and how to (test, deploy, debug...)

yoshi

A tool for common tasks in Javascript projects

Get inspired by a project

  • Fullstack Redux based project
  • Client and Server Experiments
  • Translated components
  • Server RPC calls
  • wix-style-react components
  • Fully tested

Test Drivers

Component Driver Pattern

// Component.driver.jsx
class ComponentDriver {
  given = { };

  when = { };

  get = { };
}

Specs should tell What needs to happen.

Drivers encapsulate How things are done (implementation).

Example: NameComponent

Receives a saved name

Allows saving a new name

Driver: when (1)

// NameComponent.driver.jsx
import {mount} from 'enzyme';
import {NameComponent} from './NameComponent';

class NameComponentDriver {
  component;

  when = {
    created: () => {
      this.component = mount(<NameComponent/>);
      return this;
    }
  };
}

The most basic usage - mount the component to the DOM.

Driver: when (2)

class NameComponentDriver {
  when = {
    submitName: () => {
      this.component.find('button').simulate('click');
      return this;
    },
    fillName: name => {
      this.component.find('input')
        .simulate('change', {target: {event: name}});
      return this;
    }
  };
}

Interact with the component.

Driver: get (1)

class NameComponentDriver {
  get = {
    _byDataHook: hook => {
      return this.component.find(`[data-hook="${hook}"]`);
    },
    _savedName: () => {
      return this.get._byDataHook('saved-name');
    }
    savedNameValue: () => {
      return this.get._savedName().text();
    }
  };
}

Query the component.

Tip #1

Hide technology from your test

Tests shouldn't care if we use React, Enzyme, etc...

Prefer exposing values instead of enzyme wrappers.

Driver: given

class NameComponentDriver {
  savedName;
  given = {
    savedName: name => {
      this.savedName = name;
      return this;
    },
  };
  when = {
    created: () => {
      this.component = mount(
        <NameComponent savedName={this.savedName}/>
      );
      return this;
    }
  };
}

Fill props

Driver: usage in spec

// NameComponent.spec.js
import {NameComponentDriver} from './NameComponentDriver.driver';

describe('NameComponent', () => {
  let driver;
  beforeEach(() => {
    driver = new NameComponentDriver();
  });

  it('should display the saved name', () => {
    driver
      .given.savedName('Shlomi')
      .when.created();
    expect(driver.get.savedNameValue()).toEqual('Shlomi');
  });

  it('should change the saved name', () => {
    driver
      .given.savedName('Shlomi')
      .when.created()
      .when.fillName('Alexey')
      .when.submitName();
    expect(driver.get.savedNameValue()).toEqual('Alexey');
  });
});

Driver: Reuse

class NameComponentDriver {
  constructor(component){
    this.component = component;
  }
}

Component Unit testing

Not exactly like unit testing a function

  1. Test the output based on the component logic.
  2. Test edge cases and small details.
  3. The interactions and some scenarios.

 

A component test is already sort of an integration test

Unit: Testing callback functions

class NameComponentDriver { 
  onSave;
  given = {
    onSaveHandler: fn => {
      this.onSave = fn;
      return this;
    }
  }
}

describe('NameComponent', () => {
  it('should update the saved name', () => {
    const onSave = jest.fn();
    driver
      .given.onSaveHandler(onSubmit)
      .when.created()
      .when.saveName();

    expect(onSave).toHaveBeenCalled();
  });
});

Unit: Testing CSS classes

class NameComponentDriver { 
  get = {
   saveButtonHasClass: className =>
     this.get._saveButton().hasClass(className)
  }
}

describe('NameComponent', () => {
  it('should have the correct save button styling', () => {
    driver
      .when.created();

    expect(driver.get.saveButtonHasClass('submitStyle')).toBeTruthy();
  });
});

Same technique will work when using css modules (behind the scenes we use proxy for .scss files)

Unit testing is not always enough

The component lives inside you app, so you need to test the integration as well.

Components Integration testing

TDD - fun time

Let's TDD

<App/>

The component is responsible to fetch and display the saved name from the server and update it.

Step 0 - Boilerplate

// App.jsx
import React from 'react';
export const App = () => <div>App</div>;

// App.driver.jsx
import React from 'react';
import {App} from './App';
export class AppDriver {
  component;
  when = {created: this.component = mount(<App/>)};
  get = {_byDataHook: hook => this.component.find(`[data-hook="${hook}]"`)}
}

// App.spec.js
import {AppDriver} from './App.driver';
describe('App', () => {
  let driver;
  beforeEach(() => {
    driver = new AppDriver();
  });

  it('should pass', () => {
    driver.when.created();
    expect(true).toEqual(true)
  });
});

Step 1: Expect some name

// App.spec.js
describe('App', () => {
  it('should show the saved name', () => {
    driver.create();
    expect(driver.get.savedNameValue()).toEqual('Shlomi');
  });
});

// App.driver.jsx
class AppDriver {
  get = {
    _savedName: this.get._byDataHook('app-saved-name'),
    savedNameValue: this.get._savedName().text()
  }
}

Red

Step 2: Display hardcoded name

// App.jsx
import React from 'react';
export const App = () => (
  <div>
    <div data-hook="app-saved-name">
      Shlomi
    </div>
  </div>
);

Green

Refactor is not needed at the moment

Step 3: Use NameComponent
(Suggestion #1 - refactor component)

// App.jsx
import React from 'react';
import {NameComponent} from './NameComponent/NameComponent';
export const App = () => (
  <div>
    <NameComponent name="Shlomi"/>
  </div>
);
// App.spec.js
describe('App', () => {
  it('should show the saved name using NameComponent', () => {
    driver.create();
    expect(driver.get.savedNameValueRefactor()).toEqual('Shlomi');
  });
});

// App.driver.jsx
class AppDriver {
  get = {
    _nameComponent: () => this.component.find('NameComponent'),
    savedNameValueRefactor: () =>
      this.get._nameComponent().find('[data-hook="saved-name"]')
  }
}

Step 3: Use NameComponent
(Suggestion #2 - new test)

// App.spec.js
describe('App', () => {
  it('should show the saved name using NameComponent', () => {
    driver.create();
    expect(driver.get.savedNameRefactor()).toEqual('Shlomi');
  });
});

//App.driver.jsx
import {NameComponentDriver} from '../NameComponent/NameComponent.driver';
class AppDriver {
  get = {
    _nameComponent: () =>
      this.component.find('NameComponent'),
    _nameComponentDriver: () =>
      new NameComponentDriver(this.get._nameComponent())
    savedNameValueRefactor: () =>
      this.get._nameComponentDriver().get.savedNameValue()
  }
}

Step 3: Use NameComponent
(Suggestion #3 - new test, reuse driver)

Step 4: Change the component

// App.jsx
import React from 'react';
import {NameComponent} from './NameComponent/NameComponent';
export const App = () => (
  <div>
    <NameComponent name="Shlomi"/>
  </div>
);

Fix:

Remove failing test

Refactor:

delete: get._savedName(), get.savedNameValue()

rename: get.savedNameValueRefactor() => get.savedNameValue()

Questions so far?

How to: Asynchronous testing

"Wait for it..."

Async: save name

describe('NameComponent', () => {
  it('should change the saved name', () => {
    driver
      .given.savedName('Shlomi')
      .when.created()
      .when.fillName('Alexey')
      .when.saveName();

    expect(driver.get.savedNameValue()).toEqual('Alexey');
    // Fail: Expected "Shlomi" to equal "Alexey"
  });
});

Only for now, assume that saving name is an asynchronous action.

This test will now fail:

Async: naively - explicit timeout

describe('NameComponent', () => {
  it('should change the saved name', (done) => {
    driver
      .given.savedName('Shlomi')
      .when.created()
      .when.fillName('Alexey')
      .when.saveName();

    setTimeout(() => {
      expect(driver.get.savedNameValue()).toEqual('Alexey');
      done();
    }, 1000);
  });
});

Async: better - polling and retry

describe('NameComponent', () => {
  it('should change the saved name', (done) => {
    driver
      .given.savedName('Shlomi')
      .when.created()
      .when.fillName('Alexey')
      .when.saveName();

    const timeout = setTimeout(() => done('error'), 1000);

    const interval = setInterval(() => {
      try {
        expect(driver.get.savedNameValue()).toEqual('Alexey');
        clearTimeout(timeout);
        clearInterval(interval);
        done();
      } catch (e) {continue;}
    }, 50);
  });
});

Async: perfect - wix-eventually

import eventually from 'wix-eventually';

describe('NameComponent', () => {
  it('should change the saved name', () => {
    driver
      .given.savedName('Shlomi')
      .when.created()
      .when.fillName('Alexey')
      .when.saveName();

    return eventually(() =>
      expect(driver.get.savedNameValue()).toEqual('Alexey')
    );
  });
});

Async: async-await

import eventually from 'wix-eventually';

describe('NameComponent', () => {
  it('should change the saved name', async() => {
    driver
      .given.savedName('Shlomi')
      .when.created()
      .when.fillName('Alexey')
      .when.saveName();

    await eventually(() =>
      expect(driver.get.savedNameValue()).toEqual('Alexey')
    );

    console.log('and do more things after fulfilled');
  });
});

Tip #2

Create a preconfigured eventually

import wixEventually from 'wix-eventually';

const defaultTimeouts = {timeout: 500, interval: 10};

async function eventually(condition, timeouts = defaultTimeouts) {
  const timeouts = {...defaultTimeouts, ...timeouts};
  return await wixEventually.with(timeouts)(condition);
}

Back to the <App/>

Recap

  1. Created the component and driver
  2. Displayed some hard coded name
  3. Used the NameComponent to display the hard coded name

Step 5: expect for server results

// App.spec.js
import eventually from 'wix-eventually';
describe('App', () => {
  it('should show the saved name', async () => {
    driver
      .given.fetchSavedName({savedName: 'Shlomi'})
      .create();

    await eventually(() =>
      expect(driver.get.savedNameValue()).toEqual('Shlomi')
    );
  });
});

// App.driver.jsx
import nock from 'nock';
class AppDriver {
  given = {
    fetchSavedName: res => {
      nock('http://www.my-server.com') // HARD-CODED for now
        .get('/savedName')
        .reply(200, res);
      return this;
    }
  }
}

Step 6: component fetches data

Red

// App.jsx
import React from 'react';
import {NameComponent} from './NameComponent/NameComponent';
import axios from 'axios';

export class App extends React.Component {
  constructor() {
    this.state = {savedName: ''};
  }

  componentDidMount() {
    axios.get('/savedName')
      .then(data => this.setState({savedName: data.savedName}));
  }

  render() {
    return (
      <div>
        <NameComponent savedName={this.state.savedName}/>
      </div>
    );
  }
}

How would you test the update name flow? (test + driver)

Possible implementation: Test

describe('App', () => {
  it('should update the saved name', async() => {
    const currentName = 'Shlomi';
    const newName = 'Alexey';
    driver
      .given.fetchSavedName({savedName: currentName})
      .given.updateSavedName({savedName: newName})
      .given.fetchSavedName({savedName: newName})
      .when.created()
      .when.fillName(newName)
      .when.submitName();

    await eventually(() =>
      expect(driver.get.savedNameValue()).toEqual(newName)
    );
  });
});

Possible implementation: Driver

class AppDriver {
  given = {
    updateSavedName: (params) => {
      nock('http://www.my-server.com')
        .post('/savedName', params)
        .reply(200);
      return this;
    }
  };

  when = {
    fillName: name => {
      this.get._nameComponentDriver().when.fillName(name);
      return this;
    },
    submitName: () => {
      this.get._nameComponentDriver().when.submitName();
      return this;
    }
  };
}

Tip #3 http mocks

Verify all requests are fulfilled
Avoid unused mocks from leaking to next tests

export function validateAllMocksCalled() {
  if (!nock.isDone()) {
    const errorMessage = `pending mocks: ${nock.pendingMocks()}`;
    this.clearMocks();
    throw new Error(errorMessage);
  }
}

export function clearMocks() {
  nock.cleanAll();
  nock.emitter.removeAllListeners('no match');
}

afterEach(() => {
  validateAllMocksCalled();
  clearMocks();
});

Mocking data is exhausting

nock('......')
  .get('/site', {
    id: '7ac7e961-253c-4256-a007-b7e434242c8e',
    name: 'My site',
    published: true,
    isOwner: true,
    permissions: []
  });

Also prone to mistakes and could easily break

Use typescript :)

export interface ISiteDTO {
  id: string;
  name: string;
  published: boolean;
  isOwner: boolean;
  permissions: string[];
}

Even if you don't like it 👿 😇

This is helpful in your code, but also in test

Create Test Builders

// test/builders/SiteDTOBuilder.ts
import { ISiteDTO } from '../../src/common/types/siteDTO';
import { Chance } from 'chance';
const chance = Chance();

export class SiteDTOBuilder {
  private id = chance.guid();
  private name = chance.word();
  private published = chance.bool();
  private isOwner = chance.bool();
  private permissions = [chance.word(), chance.word()];

  withId(id: string) {
    this.id = id;
    return this;
  }

  public build(): ISiteDTO {
    return {
      id: this.id,
      name: this.name,
      published: this.published,
      isOwner: this.isOwner,
      permissions: this.permissions
    };
  }
}

export const aSiteDTOBuilder = () => new SiteDTOBuilder();

And now your test is great!

import { aSiteDTOBuilder } from '../../test/builders/SiteDTOBuilder';

describe('Some Component', () => {
  it('should display a site', () => {
    const site = aSiteDTOBuilder()
      .withName('Alexey')
      .build();

    driver
      .given.fetchSite(site)
      .when.created()

    expect(driver.get.siteName()).toEqual(site.name);
  });
});

Time to take a deep breath

Redux, MobX & friends

What should I test?

In Redux

  • Actions
  • Reducers
  • Selectors
  • Components

In MobX

  • Observers and Observables
  • Computed Values
  • Actions
  • Stores
  • Components

Test the bheavior of the connected / injected components

Connected Component

Container pattern

//App.tsx
import * as React from 'react';
export class App <{siteName: string; fetchSite: Function}, void> {
  componentWillMount() {
    this.props.fetchSite();
  }
  render() {
    return (<div data-hook="site-name">{this.props.siteName}</div>);
  }
}

//App.container.ts
import {connect} from 'react-redux';
import {IState} from '../types/state';
import {fetchSite} from '../actions/site';
import {App as AppComponent} from './App';

function mapStateToProps(state: IState) {
  return {siteName: state.site.name};
}
export const App = connect(mapStateToProps, {fetchSite})(AppComponent);

A typical redux app

//client.tsx
import * as React from 'react';
import {render} from 'react-dom';
import {Provider} from 'react-redux';
import {App} from './App/App.container';

render(
  <Provider store={configureStore()}
    <App/>
  </Provider>,
  document.querySelector('#root')
);

Wrap with Provider and pass redux store
Render the connected app to the DOM

A typical redux app

import axios from 'axios';
import { ISiteDTO } from '../../common/types/siteDTO';
import * as types from '../actionTypes/siteActionTypes';

export async function fetchSite(siteId: string) {
  return dispatch => {
    const res = await axios.get(`/site?siteId=${siteId}`);
    dispatch(fetchSiteSuccess(res.data))
  };
}

function fetchSiteSuccess(site: ISiteDTO) {
  return {
    type: types.FETCH_SITE_SUCCESS,
    site
  };
}

fetchSite async action (using thunks)

A typical redux app

import { ISiteDTO } from '../../common/types/site';
import * as types from '../actionTypes/siteActionTypes';

const initialState: ISiteDTO = {
  id: '',
  name: '',
  published: false,
  isOwner: true,
  permissions: null
};

export function site(state = initialState, action): ISiteDTO {
  switch (action.type) {
    case types.FETCH_SITE_SUCCESS:
      return Object.assign({}, state, action.site);
    default:
      return state;
  }
}

The site reducer

Title Text

<Provider store={configureStore()}
    <App/>
  </Provider>,

// App.driver.jsx
class AppDriver {
  get = {
    savedNameValue: this.get._byDataHook('saved-name').text()
  }
}

Red

More things to write about

  • Testing components in state management libraries
    • don't test actions, reducers, stores, component solely.
    • test the logic through the components.
    • test wrappers, initial state
    • switching library
  • jsdom performance issues and optimization
    • import jsdom before react-dom jsdom-global/register
    • resetting dom between tests => slow.
    • mount => on the body. react doesn't kill the component.
    • mounting on a specific node = > better
  • dependency injection in react

Testing environment

  • Tests runs in NodeJS (super fast)
  • Prefer using Jest or Mocha
    (Karma+Jasmine are also supported).

Simulating browser

  • Use JSDOM to fake DOM and window.
  • Jest uses JSDOM under the hood (in an efficient way)

React Testing Utilities

  • Enzyme
    makes it easier to assert, manipulate, and traverse your React Components' output.

Copy of Testing React Components

By Shlomi Toussia Cohen

Copy of Testing React Components

  • 431