Unit Testing

Prerequisites

Running Tests

If you have created a enact project using the enact cli it will have everything you need to run tests. For a single-run, execute enact test. You can also have the tests automatically run each time the filesystem changes simply with enact test. If you built your app using enact cli you can also use npm run test and npm run test-watch for short. Both commands will execute the test suite and output the results to the console. If you are working on framework modules, at a minimum you should perform the single test run on your module before creating a pull request on a branch.

File Structure

Tests should be placed into a tests directory within the component directory. Test files begin with the name of the component or item under test and end with the "-specs.js" suffix.

+ src
	+ components
		+ IconButton
			+tests
				IconButton-specs.js			<-- Tests go here!
			IconButton.js
			package.json

Tools

We use a dizzying number of tools to perform unit testing. A quick overview of the different tools can be helpful.

  • Jest - A test framework. This tool allows us to setup, assert and run tests. We can also use jest as a mocking library.
  • React Testing Library - A test library for use with React. It allows us to render components and inspect the output.
  • jsdom - A pure-JavaScript implementation of many web standards, notably the WHATWG DOM and HTML Standards, for use with Node.js.

Unit Testing

Unit testing is the core of our testing platform. Unit testing only tests the smallest units of code. Typically a test just tests a function/method. For Enact framework developers testing will also extend into component and virtual DOM testing.

We are using Jest as our testing framework. We won’t cover all the functions this gives us in this document. We suggest looking at the Jest Docs after finishing this if you want to know all of the things Jest can give you.

Say you have a function like this:

const add = (numA, numB) => {
	return numA + numB;
}

To test this function in Jest we could write something like:

test('Should add the two arguments together', () => { 
	const expected = 3;
	const actual = add(1,2)
	
	expect(actual).toBe(expected);
});

NOTE: This test is a very simplistic example. This is probably code that should not need to be tested in production, but

it’s a good way to illustrate basic unit testing.

This is a decent unit test, but what happens if somebody implemented the add() function like this:

const add = (numA, numB) => {
	return 3;
}
	
// or
	
const add = (numA, numB) => {
	return numA + 2;
}

The test would pass every single time, but the function would still fail in most cases. This is an extreme example of poor code, but it does show where unit tests can break down because of certain assumptions.

Something better would probably be:

test('Should add the two arguments together', () => {
	let expected = 3;
	let actual = add(1,2);
	
	expect(actual).toBe(expected);
	
	let expected = 30;
	let actual = add(10,20);
	
	expect(actual).toBe(expected);
	
	let expected = 123;
	let actual = add(111, 12);
	
	expect(actual).toBe(expected);
});

NOTE: Unlike this example, developers should strive for having a single expect() per test.

This way we can think of edge cases that may surface bugs in our code. For a simple function like add() this may be overkill, but for some complex code/algorithms it makes sense to test this way. Using this simple way of checking inputs and outputs we can test basically any JavaScript function that returns a value.

Testing React

To test react we use React Testing Library plus other tools you can find out about here.

const Text = (props) => {
	return <p>{props.content}</p>;
}

test('Should contain text', () => { 
	render( 
		<Text content='sample' /> 
	);
	
	const textElement = screen.queryByText('sample');
	
	expect(textElement).toBeInTheDocument();
});

React Testing Library provides the following methods regarding render.

render()

render is the DOM representation. It will print a string of the output dom that the browser sees.

rerender()

rerender is used to update the props of a rendered component in your test.

unmount()

unmount is used to cause the rendered component to be unmounted.

Why Unit Testing?

The reason to write unit tests is because they are the most simple things to test. The Enact framework code (or an Enact application based on best practices) is essentially made up of units. Sometimes code can be more complex and a simple unit test will not suffice, but a majority of the things that break can be tested by unit tests.

In the following image, automated unit tests are at the bottom meaning they are the foundation. They are the ones that should be the majority of the test suite. We can assume that if we write enough good unit tests that pass then when we combine our functions they will work in many different scenarios.

Software Testing Pyramid

Goal of Unit Testing

The goal in unit testing, as well as testing in general, is to give us confidence in our code. We want to know that our code works, and in the future won’t break. We strive to add value with the tests we add. Not just add tests to cover every single possible outcome. Before you add a test, determine if it is necessary condition/scenario to test and will it benefit the project in the long run?

What Unit Testing Should Not Do

Although testing is very helpful, it is not necessary to pursue 100% code coverage. We believe it is not realistic. In the framework, we try to cover as much as we think will help, but we do not want to spend time on tests that add no value.

For instance, you don’t need to test other things that are already robust (likely having their own tests already), such as standard library functions or basic JavaScript behavior.

// this is probably going to work
const returnArg = (arg) => {
	return arg;
};

test('Should return arg', () => { 
	const actual = returnArg('sample');
	expect(actual).toBe('sample');
});

This example looks quite silly, but let’s look at it in a React context:

//original code
const Text = ({content, ...rest}) => {
	return (<p {...rest}>{content}</p>);
};

//breaking change
const Text = ({cont, ...rest}) => {
	return (<p {...rest}>{cont}</p>);
};

//Example A - Bad
test('Should pass prop to component', () => {
	render(
		<Text data-testid='text' content='sample' />
	);

	const textElement = screen.getByTestId('text');
	const expected = 'sample';

	expect(textElement).toHaveAttribute('content', expected);
});

//Example B - Better

test('Should contain text', () => {
	render(
		<Text content='sample' />
	);

	const textElement = screen.queryByText('sample');

	expect(textElement).toBeInTheDocument();
}); 

In this example a change was made to the Text component. We’ve changed the property name from content to cont.

In test Example A, we can be very confident that React will do this correctly (pass the property content with the value "sample"). We know that React will pass properties to child components. There is a high chance that this test won’t break, even if somebody makes a change to the code. Example A will continue to pass because it tests passing arguments, not what the component is supposed to display.

In test Example B, we have something that is fairly simple, but has a higher chance of breaking. By testing the final output and not the property we get an accurate test. Also, this is likely the only test we’d need for such a simple component.

How Tests Influence Code

TDD can influence our code before we even write it. It can help us focus on making our functions simple and robust, but in order to do that we should learn a bit about the code that is easiest to test.

Pure Functions

The easiest functions to test are “Pure Functions” From wikipedia pure functions are described as:

  1. The function always evaluates the same result value given the same argument value(s). The function result value cannot

depend on any hidden information or state that may change while program execution proceeds or between different executions of the program, nor can it depend on any external input from I/O devices (usually—see below). 2. Evaluation of the result does not cause any semantically observable side effect or output, such as mutation of mutable objects or output to I/O devices (usually—see below)

Simply stated, this means we can’t have anything other than the arguments determine the code’s arguments result.

//Pure Function
const add2 = (num) => {
	return num + 2
}
add2(4) //6

//Class Version
class Num {
	constructor(num){
		//this is state
		this.num = num;
	}
	//Non-Pure
	add2(){
		return this.num + 2
	}
}

const newNum = new Num(4)
newNum.add2() //6

In the example above if you call the pure function add2(4) you will always get 6.

In the class version, however, the number passed to the constructor is kept as an instance variable. If that instance variable changes the result of add2() will change as well. Since it relies on mutable state, the function is not pure. This is called a side effect.

We try to use pure functions as much as possible and it can make our code easy to test and reason about.

Side Effects

Side Effects include:

  • Mutable State
  • Mutable Value
  • HTTP requests
  • Database Calls
  • Basically anything out side the scope of a function

Side effects are difficult to test, so we prefer to limit them to only when needed. Favor pure functions and stateless functional components if possible. That means do not abuse React component state if you do not need it.

Unfortunately, we can’t always write pure functions. Sometimes we may have to use React’s component state, we may have to make an AJAX call, etc. These are good reasons to not use pure functions. These functions are tougher to test but, not impossible. For example, if you are using Redux for complex application/component state management, you may be using side effects to get some of your data. The Async Action Creators section in https://redux.js.org/recipes/writing-tests shows how you can write tests in those cases.

What to do next

Please refer to our document Test Driven Development(TDD), it contains how to run tests and how they fit in our testing strategy. It also contains more information about TDD and the methodology behind it. This document is a deeper dive into the unit test specifically.