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

What test drivers are

  • Make tests more readable and maintainable.

  • While tests tells What needs to happen, drivers encapsulate How things are done (implementation).

Component Driver Pattern

// Component.driver.jsx
export class ComponentDriver {

  constructor() { }

  given = { };

  when = { };

  get = { };
}

Example: <NameComponent/>

Receives a saved name

Requests saving a new name

Driver: when (1)

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

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

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

Driver: when (2)

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

Interaction with the component

Driver: get

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();
    }
  };
}

Querying 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 {
  given = {
    savedName: name => {
      this.savedName = name;
      return this;
    },
  };
  when = {
    created: () => {
      this.component = mount(
        <NameComponent savedName={this.savedName}/>
      );
      return this;
    }
  };
}

Setting props for the component

Driver: Reuse

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

Reuse the driver inside other drivers, by receiving a mounted component

Driver is an encapsulation tool, keeping our test clean from implementation

Component Unit testing

Example

// 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 fill the saved name input', () => {
    driver
      .when.created()
      .when.fillName('Alexey')
    expect(driver.get.filledNameValue()).toEqual('Alexey');
  });
});

Component unit testing is not the same as unit testing a function

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

 

A component test is already an integration test

Testing callback functions

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

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

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

Testing CSS classes

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

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

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

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

Test the wiring and interactions with components and data

<App/>

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

A short TDD desclaimer

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 {
  given = {};
  when = {created: () => this.component = mount(<App/>); return this;};
  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)
  });
});
// App.spec.js
describe('App', () => {
  it('should show the saved name', () => {
    driver.create();
    expect(driver.get.savedNameValue()).toEqual('Shlomi');
  });
});

Step 1: Expect a hard coded value

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

Step 2: Implement the driver

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

Step 2: Implement the driver (reuse driver)

Step 3: Change the component

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

Questions so far?

How to: Asynchronous testing

"Wait for it..."

Async: fill name

describe('App', () => {
  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"
  });
});

Assume that all functions are implemented, but saveName is an asynchronous action. This test will fail:

How can we fix the test?

Async: naively - explicit timeout

describe('App', () => {
  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('App', () => {
  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) {return;}
    }, 50);
  });
});

Async: perfect - wix-eventually

import eventually from 'wix-eventually';

describe('App', () => {
  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('App', () => {
  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

Let's make an HTTP request!

Step 4: Expect for server data

// 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')
    );
  });
});

Step 5: Mock server data

// App.driver.jsx
import nock from 'nock';
class AppDriver {
  given = {
    fetchSavedName: res => {
      nock('http://www.my-server.com')
        .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 savedName = 'Shlomi';
    const newName = 'Alexey';
    driver
      .given.fetchSavedName({savedName})
      .given.updateSavedName({newName})
      .given.fetchSavedName({savedName: newName})
      .when.created()
      .when.fillName(newName)
      .when.saveName();

    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;
    },
    saveName: () => {
      this.get._nameComponentDriver().when.saveName();
      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')
  .reply(200, {
    id: '7ac7e961-253c-4256-a007-b7e434242c8e',
    name: 'My cool site!',
    published: true,
    isOwner: true,
    permissions: []
  });

Also prone to mistakes and could easily break

Help is needed!

Typescript to the rescue :)

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

Give it a try, you might even like it 👿 😇

Typescript prevents costly errors during compilation

Definitions are helpful in your code, but also in test.

Create Test Builders

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

export class SiteDTOBuilder {
  private id: string = chance.guid();
  private name: string = chance.word();
  private published: boolean = chance.bool();
  private isOwner: boolean = chance.bool();
  private permissions: string[] = [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('My cool site!')
      .build();

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

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

Question - The API should return a new field in the response. How would you test and implement this addition?

(test, definition, builder, component)

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 behavior and logic through the connected component

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 {fetchSite} from '../actions/site';
import {App as AppComponent} from './App';

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

A typical redux entry point

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

render(
  <Provider store={configureStore()}
    <App/>
  </Provider>,
  document.querySelector('#root')
);
  1. Wrap with Provider and pass Redux store
  2. configureStore() wraps Redux's createStore()
  3. Render the connected app to the DOM

How do we test <App/>?

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

Testing the container

// App.driver.jsx
class AppDriver {
  created: () => {
    this.component = mount(
      <Provider store={configureStore()}
        <App/>
      </Provider>
    );
    return this;
  }
}

Wrap with Redux Provider and use the exact same store!

Actual actions and reducers means you test the same behavior and not a mocked behvaior.

You might have multiple Providers

render(
  <Provider store={store}>
    <I18nextProvider i18n={translations}>
      <Router history={history} onUpdate={onUpdate}>
        <App/>
      </Router>
    </I18nextProvider>
  </Provider>,
  document.querySelector('#root')
);

It becomes ugly wrapping each component when testing

Tip #4

//testWrappers.ts
import configureStore from './configureStore';
import {ComponentWrapper} from 'react-test-wrappers/dist/src/componentWrapper';
import {reduxWrapper} from 'react-test-wrappers/dist/src/wrappers/redux';
import {i18nextWrapper} from 'react-test-wrappers/dist/src/wrappers/i18next';
import {routerWrapper} from 'react-test-wrappers/dist/src/wrappers/router';


export function testWrapper(initialState = {}) {
  return new ComponentWrapper()
    .wrapWith(reduxWrapper(configureStore(initialState)))
    .wrapWith(i18nextWrapper())
    .wrapWith(routerWrapper());
}

//App.spec.tsx
import {testWrapper} from './testWrappers'
import {App} from './App';
import {mount} from 'enzyme';

mount(testWrapper().build(<App/>));

🔥🔥🔥🔥🔥🔥

🔥🔥🔥🔥🔥🔥

Tip #5

Prefer using dependency injection mechanism instead of keeping a state in multiple modules

react-test-wrappers helps you write a code with injectable dependencies ===> testable code

😎

Even for a small project like the above counter, is a big refactor.

only one change was needed in tests.

Where?

Switching from MobX to Redux without breaking

to-mobx-container (From stores to flux)

to-redux (From flux to redux)

Almost done! 😅

JSDOM and React Performance

  1. Tests run in node env => use JSDOM to simulate DOM
  2. JSDOM is nice, but has some serious performance issues.
  3. Don't recreate the DOM in every test:
    1. It is slow!
    2. React DOM doesn't cleanup well.
  4. Do:
    1. If you change some DOM behavior, be responsible to clean afterwards.
    2. Mount and Unmount your component properly (next slide)
  5. Suggestion:
    1. Try using Jest testing framework, very efficient when it comes to JSDOM.

Proper mounting cleanup

// test/mocha-setup.js
import 'jsdom-global/register';

// App.driver.jsx
export class AppDriver {
  when = {
    created: {
      this.component = mount(
        (<App/>),
        {attachTo: document.createElement('div')}
      );
      return this;
    }
  };
  cleanup() {
    this.component.detach();
  };
}

// App.spec.js
import {AppDriver} from './App.driver';
describe('App', () => {
  afterEach(() => {
    driver.cleanup();
  });
});

Summary

  1. Test drivers - hide the implementation, make tests readable and clear.
  2. Component testing - Test the behavior and the interactions.
  3. helpful tools (nock, wix-eventually, react-test-wrappers)
  4. Typescript - Great for API calls
  5. TDD - Makes sure your code is covered, use it as a tool.

Questions?

Thank you! 🤗

Testing React Components

By Shlomi Toussia Cohen

Testing React Components

  • 2,093