The Joy of Shifting Left our “UI Testing” 🙂 : Part I

Rishi Jain
Capillary Technologies
7 min readMar 18, 2022

We at Capillary, ensure that our code is well tested before it is shipped and being used by the end users. It is often beneficial to push testing towards the early stages of software development, also called Shift Left Testing strategy. This helps in identifying and resolving bugs as early as possible and often before they hit the production environment.

Image Credits: Oliver Howard

If you are reading this blog, probably you all might have written unit tests for your UI Applications and certainly you should. Writing unit tests ensures your single component or unit works well in isolation. But in production, will your component run in insolation? Probably not. You will have multiple components interacting with each other and writing unit tests alone won’t give you the confidence that different pieces of your application will work well together in harmony. This is where integration tests can come in handy. The idea behind integration tests is to minimize mocking as much as possible, this may include rendering the whole app or required component with necessary providers.

Image Credits: https://test-feature.reqtest.com/testing-blog/get-started-with-integration-testing/

While it is also true that end-to-end tests can give you more confidence about your application since it exactly represents end users behavioural interaction with the application. But on the downside, end-to-end tests are heavy by nature, slow to run, expensive to maintain and finding the root cause of a failure is not always easy. On the other hand, integration tests can strike off good balance with speed of execution and the confidence it generates. This blog shows you how to shift your UI tests to the left in the SDLC cycle with the integration testing approach. This will drastically limit the number of end-to-end tests you needed to write and enables to focus your UI testing on areas where it actually adds value instead of creating a headache.

Testing Pyramid. Credits: Lawrence Tan

For this reason we adopted writing Integration Tests for our UI Apps. We at Capillary, use React to create our UI applications. In the coming sections, you will read about its adoption in our React apps.

Image Credits: https://coderera.wordpress.com/2018/02/19/integration-tests-in-webapi-net-core-2-0/

Our Expectations

We had certain expectations while writing integration tests and expected it to serve the following purpose.

  1. Tests should be close enough and resembles to an end user’s interaction with the application. This will add to our confidence that multiple pieces of app works well as expected when a real user uses it.
  2. Tests should be maintainable in the long run. Frequent changes in the implementation or code refactors should require minimal changes in the tests.

The Big Problem

So many libraries, which one to choose from? Will my test break due to any refactors or change in implementation details? Will my test be maintainable? Will I be able to test the app from an end user’s perspective?

We were using Enzyme, a popular testing library for writing unit tests our React apps. However in my opinion, Enzyme has few problems with writing both unit and integration tests, which makes it not much of an appealing choice to write our test in it.

Enzyme relies much on implementation details like props, state and children components. Due to this, tests are prone to break once implementation details are changed.

Above is the test for a Counter App written using Enzyme. Notice we have access to the component state.

Few of the problem we observed with writing tests in Enzyme:

  1. Since the end user does not have access to the internal state or any life cycle methods, it makes testing from an end user’s perspective very hard.
  2. Difficulty in finding elements since Enzyme provides us with a Wrapper for the component.
  3. Shallow rendering in Enzyme has some limitation and certain hooks like useEffect do not work as expected.
// Notice how we faced difficulty in finding elements with Enzyme.
renderedComponent
.findWhere(n => n.name() === 'Input' && n.prop('name') === name)
.props()
.onChange(event);

The Solution

Introducing React Testing Library

We adopted React testing library for writing our tests. It is a popular and light weight testing library built by Kent C. Dodds (Thanks Kent!). It provides light utility functions on top of react-dom and react-dom/test-utils. It encourages you to avoid testing the implementation details of your component and write test in a way that the end user will interact with the application.

The Guiding Principles

  1. Avoid testing any implementation details like state, props, children components etc.
  2. Test the components in a way the end user will interact with the application
  3. Everything is an actual DOM node unlike wrappers when using libraries like Enzyme.
We avoid testing the any implementation details like state, props etc in React Testing Library

The problems that React Testing Library solved for us

  1. We test from users perspective, just like a real end user would. No more accessing the props and state to perform certain actions.
  2. Since you avoid testing your implementation details, your tests are resilient to frequent changes and refactors in implementation details. Your test will likely to pass even though you change the implementation details or may require minimal changes. This makes refactoring a breeze.

A Brief Introduction to React Testing Library

While using any library one might have certain questions, this could be:

  1. How can we render components in our tests?
  2. How certain user actions like click or type could be performed?
  3. How can we make API calls?

1. Render components in tests

To render components we can use utilities from testing library and render our component

import { render, screen } from '@testing-library/react';
render(<Component/>)

The render and screen utilities from react testing libraries are simple and easy to use. To Render any component we can just use render() function and pass in our Component.

The screen has various utility methods using which you can query or access the html’s body like:

Suppose for a login page, this is how you could access the usernameInput, password field and done button.

const usernameInput = screen.getByPlaceholderText(/enter username/i)
const password = screen.getByPlaceholderText(/enter password/i)
const loginBtn = screen.getByRole('button', {name: /login/i})

2. Performing user actions

If you are wondering whether user actions can be performed in our tests? Well, the answer to this question is a big ‘YES’. Not only you could type text but also perform other user events like click, mouse over etc. To accomplish this you can import the companion library from testing library, the userEvent to perform the above actions.

import userEvent from '@testing-library/user-event'
...
userEvent.type(usernameInput, "MrBeast")
userEvent.type(password, "TheSecret123")
userEvent.click(loginBtn)

Simple right ?

3. Making API calls

Before writing integration tests one might have following questions pertaining to api calls, this could be:

  1. Whether you should be making actual api calls? : This could be more expensive(time). Our tests might take a lot of time if we make an actual call. Also what if backend is not ready but you have an API contract ?
  2. Should you mock fetch? : In short No. Kent has an article explaining why.

Any better way to make api calls ?

Well, Mock Service Worker(MSW) comes here to rescue. MSW is an api mocking library that uses service workers api to intercept actual requests.

So what does this mean ?

We can create one handler.js file in our component test folder and describe the API to mock.

import { rest } from 'msw';
import { setupServer } from 'msw/node';
import 'whatwg-fetch';

export const server = setupServer(
// Describe the requests to mock.
rest.get('/login', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
success: true,
token: "testtoken",
username: "MrBeast",
}),
)
}),
)

And then use the above server in our tests like this:

//... other imports
import { server } from './handler'

beforeAll(() => {
// Establish requests interception layer before all tests.
server.listen()
})
afterAll(() => {
// Clean up after all tests are done, preventing this
// interception layer from affecting irrelevant tests.
server.close()
})

//... tests

This will ensure that whenever our App will try to make an API call to the endpoint /login it will get the defined response in handler.js file.

Well, this was it for the Part I of the blog. We will discuss how we wrote integration tests for our react app in the second part of the blog.

Few References:

--

--

Capillary Technologies
Capillary Technologies

Published in Capillary Technologies

This publication showcases candid stories highlighting how intelligent technology enables a highly scalable omnichannel CRM, eCommerce, and loyalty management software at Capillary Technologies