JavaScript Testing with React

There's a lot to know about software testing. This article concisely covers static, unit, integration, and end-to-end testing with a focus on JavaScript. Keep in mind that most of these concepts are not exclusive to JavaScript.

Sunset Tree

As we’ll see there’s more to testing than just asserting whether things are true or false. We’ll start by clearing up some terminologies commonly used when testing. Starting with asking ourselves, what's the difference between an error, failure, and a bug? Well, an error is a human action that produces an incorrect result. An error causes a bug to emerge, which is a flaw in our component or system which results in a failure. A failure is when a component or system does not do what it's supposed to.

There are also different techniques used while designing test cases. Two common testing techniques are white-box and black-box. White-box testing is when we are interested in what happens “inside the box” meaning we design test cases based on analysis of the internal structure of a system or component. Black-Box testing is when we write non-functional or functional tests without knowing the internal structure of the artifact being tested. Both of these test design techniques create and identify test conditions, test cases, and test data.

Types of Testing

In JavaScript, we have plenty of testing frameworks, test runners, and assertion libraries that make testing easier. Examples of these are, Jest, Mocha, Karma, Puppeteer, and the list goes on. But there are also different objectives and ways of testing. In this section, we’ll look at common types of testing. The common types that we'll look at are static testing, unit testing, integration testing, and end-to-end testing.

1. Static Testing

With static testing, we never execute the software artifact that's being examined. Instead, we statically analyse it, which gives us information about possible syntax errors, bugs, code style, and optimization possibilities. The opposite of static testing is dynamic testing, which involves the execution of the artifact that's being tested. Examples of tools for this in JavaScript are ESLint, jslint, and jshint.

2. Unit testing

Unit testing is synonymous with component testing and module testing. A unit test focuses on a unit/component/module individually. In JavaScript, a unit could entail a function, an object, a module, etc.

We might test the returned value from a function or how a component renders. Testing things like pure functions, are pretty straight forward and can be tested in a node environment. Testing a React component is trickier because we need an emulation of a browser environment like jsdom, to be able to render it. But when we've cleared that up, we also have tools like Enzyme, which gives us a jQuery-like API for DOM manipulation and traversal, which makes it easier to work with the DOM.

3. Integration testing

Integration tests see if separate units work accurately once connected. In other words, we test that units or modules that have been developed independently work together as we expect them to. The ISTQB glossary gives us a concise definition, “a test level that focuses on interactions between components or systems”. But distinguishing the difference between a unit test case and an integration test case can be somewhat tricky because people have different opinions on what is what.

4. End to End (E2E) testing

End-to-End tests automate user actions so we can see if the flow of the application is working correctly from the end-users point of view. When our application grows it also becomes more complex and manual testing gets more time consuming and unrealistic. On top of that, having to manually test your web app with different browsers like Firefox, Chrome, and Explorer becomes a real hassle. With end-to-end tests, we make our lives easier by automating the process with simulations of user actions that are run in real browsers. End-to-End testing allows us, amongst other things, to do the following:

  • Ensure that the most important functions work (known as smoke testing).
  • Ensure that our polyfills work in multiple browsers.
  • Make regression testing easier by having tests that click parts of the application that are not currently changed to ensure new implementation does not interfere with that part of the application.

So how do we automate interactions with browsers? As usual with JavaScript, there is a large number of different tools to pick from. I won’t name all the tools, but popular options are Selenium, Cypress, and Puppeteer. Which probably makes you wonder, which tool should I use? The answer is that it depends, so I recommend that you look up what suits your projects the best. Protractor, for example, is popular for Angular applications since it’s created for Angular.

Testing Our Frontend Code

Now we’ll create a React app that covers all of the types of testing introduced above. We’ll implement the tests in a sequence of steps like static, unit, integration, and lastly end-to-end. The app and tests will be very simple since it’s just for illustration purposes. Which means that neither the tests or the structure of the project is perfect. Additionally, in reality, you will probably encounter things like data fetching and mocking.

Let’s start by creating our app with the create-react-app command:

npx create-react-app javascript-testing

1. Static Testing for React

We’ll use ESLint, which is a very popular static code analysis and linting tool. It’s customizable, so you can change the rules to suit your project. With create-react-app we get ESLint, out of the box. Now add the following command in your package.json file:

 "scripts": {
    ...
   "lint": "eslint src/*.js"
 },

Deliberately make sure that something in src/App.js is not correct, save and then run npm run lint, it should tell you that something is wrong in that file.

eslint test result

Great now we know it works. Let’s make sure there are no errors and continue. As previously mentioned, ESLint is customizable, and we can override the default settings and configure ESLint as we wish by creating an eslintrc.json file. I’m going to use eslint --init which will help you set everything up.

// I picked the following options after eslint --init:
1. To check syntax, find problems, and enforce code style.
2. JavaScript modules (import/export)
3. React
4. No
5. Browser
6. Use a popular style guide
7. Airbnb: https://github.com/airbnb/javascript
8. JSON
9. Yes

All the required dependencies will be installed automatically. Now you can look in your eslintrc.json file to see how it’s configured. If you run npm run lint you'll get some errors. One of the errors will say, 6:5 error JSX not allowed in files with extension '.js', let’s say we do not agree, then we may add the following in our eslintrc.json:

"rules": {
    "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }]
}

We run ESLint again and see that it still complains a lot about the serviceWorker.js file. But I do not want to test that file, so I create a .eslintignore file and added src/serviceWorker.js to it. Note that I’m not saying that any of this section is best practice, it’s only meant to show you that you can customize your ESLint. A good thing to keep in mind is that the default config provided by create-react-app seems to be sufficient in many cases. Now we’ve added static testing to our project.

2. Unit Testing for React

In this section, we'll test a component by using Jest and Enzyme. Create-React-App uses Jest by default, which means we can start adding our unit tests very easily. I added "jest": true to "env": { ... } in our eslintrc.json so it knows we're using Jest. However, this guide is not intended to teach you how to use Jest, Enzyme, or React, so please check out Jest’s React tutorial, or this Enzyme Cheatsheet if you need information on how to use them.

Luckily Enzyme suits really well with React but is not exclusive for React, so we'll also need to add an adapter. Install the following:

npm i --save-dev enzyme enzyme-adapter-react-16

Since we used create-react-app, we can create a src/setupTests.js file, which will be loaded by Jest before each test. Add the following to src/setupTests.js:

import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
 
configure({ adapter: new Adapter() });

Now let’s create a simple component that we can test. Create the following component in src/Greet.js:

import React from 'react';
 
function Greet(props) {
 
   if (props.name) {
       return <p>Greetings, {props.name}!</p>
   } else {
       return <p>Greetings, traveler!</p>
   }
}
 
export default Greet;

Now create a test file called Greet.test.js. In the test suite, we create two unit tests that ensure that our component renders as we intended. Copy and paste the code below and then run the command, npm test, and it should be all green.

import React from 'react';
import Greet from './Greet';
import { mount } from 'enzyme';
 
it('should render without a name', () => {
  const wrap = mount(<Greet />);

  expect(wrap.text()).toEqual('Greetings, traveler!');
});
 
it('should render with a name', () => {
  const wrap = mount(<Greet name={'Gandalf'} />);

  expect(wrap.text()).toEqual('Greetings, Gandalf!');
});

3. Integration Testing for React

Now we want to see if our components work together in harmony, let’s add some more logic to our src/App.js component. Make sure your src/App.js file looks like this:

import React, { useState } from 'react';
import Greet from './Greet';
import './App.css';
 
function App() {
 const [showName, setShowName] = useState(false);
 return (
   <div>
     <h1>Hello World!</h1>
     <Greet name={showName ? 'Gandalf' : undefined} />
    
     <button onClick={() => setShowName(!showName)}>Toggle name</button>
   </div>
 );
}
 
export default App;

Now we have two components working together and, therefore, we should make sure they work in harmony. So, add the following code to src/App.test.js:

import React from 'react';
import App from './App';
import Greet from './Greet';
import { mount } from 'enzyme';
 
it('it should find child <Greet/> and have correct text', () => {
  const wrap = mount(<App />);
 
  // find elements
  const child = wrap.find(Greet);
  const button = wrap.find('button');
 
  // it should be traveler now right?
  expect(child.text()).toEqual('Greetings, traveler!');
 
  // simulate click
  button.simulate('click');
 
  // it should be gandalf now right?
  expect(child.text()).toEqual('Greetings, Gandalf!');
});

Just as the React docs say, “with components, the distinction between a “unit” and “integration” test can be blurry”, we can see that our test suite still looks very similar to Greet.test.js.

4. End-To-End Testing for React

For our end-to-end testing, we’ll use Selenium, we’ll create a similar test to our integration test but now it’ll be run in a real browser. Start by installing it:

npm install selenium-webdriver

And then you can read how to set everything up, here. I only use firefox in the example, now create e2e.test.js and add the following to it:

const { Builder, By } = require('selenium-webdriver');
 
describe('sees if its possible to click the button and get correct text', () => {
 
   let driver;
   jest.setTimeout(30000); // avoid 5000ms timeout
 
   beforeEach(async () => {
       driver = await new Builder().forBrowser('firefox').build();
       await driver.get('http:/localhost:3000');
       await driver.manage().setTimeouts({ implicit: 5000 }); // ensure it waits 5 seconds for an element to load
   })
 
   afterEach(() => {
       driver.quit();
   })
 
   it('should have different text before and after click', async () => {
       const pText = await driver.findElement(By.css('p')).getText();
 
       expect(pText).toBe('Greetings, traveler!');
 
       await driver.findElement(By.css('button')).click();
 
       const pTextAfterClick = await driver.findElement(By.css('p')).getText();
 
       return expect(pTextAfterClick).toBe('Greetings, Gandalf!');
   })
})

Make sure that the server is running on localhost:3000 and then run the tests. As you will see e2e tests are slow and are one of the reasons why we usually run them last or only when necessary.