What a Spaceship and JavaScript have in common? Both already reached space.
The Crew Dragon Spaceship from SpaceX uses JavaScript in the main cockpit panels[1]. It's super cool to see where the language has come and what can be achieved with it.
Just like rockets, spaceships, and many others, critical and non-critical projects, require a lot of testing before production launch. Otherwise, a “KaBuM! Effect” could happen, and unless it is a firework, it won't make anyone happy.
In any case, testing is not complicated, and even if most of us are not building things that can explode, treat them as if they are of equal importance. Testing makes error detection easier and can also save a lot of time. It can be tricky at first, but with practice and experience, it becomes an ally, you just need to make it part of your daily work.
Before getting started, we need to take a look and understand how things work under the hood. This article will cover the basics of testing using JavaScript, including:
Testing Fundamentals
One of the most common phrases in software development is: “whattaf*ck…”, some say that the quality of the code can be measured by the FPS (f*cks per second) heard during the development process. $h!t happens, and fixing it can be simple, but if it is a little more complicated, it can take days, weeks, and even months to solve it. Thus, the idea of creating an automated test is to try to catch as many errors as possible in our code before they happened.
Imagine that you are building a spaceship, and this spaceship requires a calculator module and if it fails it can explode. You aim to make sure the results are always correct. Then, you start creating the first method of this module.
// calculator.js
export const sum = (a, b) => a + b;
To test this code, you have to check the result of your function to validate your assumption.
// calculator.test.js
import { sum } from './calculator.js';
const expected = 4;
const result = sum(2, 2);
if (result !== expected) {
throw new Error(`KaBuM! It Exploded!`, { cause: `${result} is not equal to ${expected}` });
}
In the example, you run and test to check if the result is what you expected. Although this implementation works, it cannot be reused. To simplify the testing process, extract the logic into a new method, that way it can now be used for more cases.
// testing.js
export const expect = value => ({
toEqual(expected) {
if (value !== expected) {
throw new Error(`KaBuM! It Exploded!`, { cause: `${value} is not equal to ${expected}` });
}
}
});
Now, update our previous code.
// calculator.test.js
import { sum } from './calculator.js';
import { expect } from './testing.js';
expect(sum(2, 2)).toEqual(4);
expect(sum(2, 'a')).toEqual(NaN); // Error
Much better! However, there is no description showing what is being tested, and if you start adding more tests and one fail, the remaining tests will not run, so let's fix it by encapsulating this code inside a try/catch:
// testing.js
export const test = (description, fn) => {
try {
fn();
console.log(`✓ ${description}`);
} catch (error) {
console.error(`✕ ${description}`);
console.error(error);
}
};
// ...
Now, use our new function inside our test code.
// calculator.test.js
import { sum } from './calculator.js';
import { expect, test } from './testing.js';
test('sum numbers', () => expect(sum(2, 2)).toEqual(4));
Let's open the terminal and run our test:
$ npx babel-node calculator.test.js
In case of an error in the code, you will see the following error message.
// calculator.test.js
...
test('sum numbers', () => expect(sum(1, 2)).toEqual(4));
Congratulations! You have now created a simple JavaScript Testing Framework. The good news is that there are already some great tools for testing automation. The most famous is Jest, and you can make your test compatible with it by just removing one line of code and run it:
// calculator.test.js
import { sum } from './calculator.js';
test('sum numbers', () => expect(sum(2, 2)).toEqual(4));
In the terminal, run the command:
$ npx jest calculator.test.js
There is more than just that. Let's move on and learn more about how to test an application, even without running the code (Yep, this is possible in JavaScript).
Testing with Jest
Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It has already built in most of the features you expect from a testing framework: A great set of exceptions, code coverage, mocking, runs fast, good documentation, and an incredible community around it.
First things first, you need to install Jest
$ npm install --save-dev jest
Thereafter, update the package.json file to run it:
...
"scripts": {
"test": "jest"
},
...
npm run test
or also manually: npx jest
If you like, you can also run jest --init
to create a configuration file.
Comparing Values
Let's go ahead, now you are going to create a weapon module for your spaceship. Start creating a simple test example:
describe('Weapon Module', () => {
test('a simple test', () => {
expect(2 + 2).toBe(4);
});
// ...
});
After running Jest, this is the result:
The primary comparison methods are toBe
and toEqual
, toBe
uses === to check strict equality, while toEqual
makes a deep comparison of the properties of the values using Object.is.
// ...
const weapon = { type: 'laser' };
test('check object with toEqual', () => {
expect(weapon).toEqual({ type: 'laser' });
});
test('check object with toBe', () => {
expect(weapon).toBe({ type: 'laser' });
});
// ...
Running this test, you will get the following result:
It is up to you to decide which one fits better in your test case, but if you are starting with testing, using the toEqual
method will probably be the best alternative.
Comparing Strings
Regular Expression
Jest does have support for comparing strings. Besides, the regular toEqual
regex can also be used for comparison. All you need is to call the toMatch
method and pass in the regex string.
const text = 'hello world';
test('string comparison', () => {
expect(text).toMatch(/hello/);
});
Length
It's also possible to compare the length between two strings using toHaveLength
.
expect('abc').toHaveLength(3);
It works with an array as well.
expect([1, 2, 3]).toHaveLength(3);
Comparing Numbers
Besides the basic comparison methods, you can easily compare numbers in your tests by utilizing the following methods:
- toBeGreaterThanOrEqual
- toBeGreaterThan
- toBeLessThanOrEqual
- toBeLessThan
In the following example, you can use a loop to check if the result is less than 10.
test('loop less than', () => {
for (let i = 1; i < 10; i++) {
expect(i).toBeLessThan(10);
}
});
When changing the value from 10 to 5, you will receive the following error message.
Comparing Arrays
The toContain
method is used for array comparison, which checks if the values are included in the list.
test('check an array', () => {
const weapons = ['phaser', 'laser', 'plasma cannon', 'photon torpedo'];
expect(weapons).toContain('laser');
});
Comparing Dynamic Values
In a situation where you don't have an exact value, but you know the type of the object, so you can use the expect.any
method.
Primitive Values
For primitive values like string, number, and booleans, you can use:
- expect.any(String)
- expect.any(Number)
- expect.any(Boolean)
test('check dynamic string', () => {
expect('disruptor').toEqual(expect.any(String));
expect(1).toEqual(expect.any(Number));
expect(false).toEqual(expect.any(Boolean));
});
Objects
You can check an Object with objectContaining
to see if an object contains some properties inside. In this case, you don't need to match the same properties from the object you want to evaluate.
test('check dynamic object', () => {
const weapon = { type: 'laser', damage: 100, range: 10, available: false };
expect(weapon).toEqual(
expect.objectContaining({
damage: expect.any(Number),
type: expect.any(String),
available: expect.any(Boolean),
})
);
});
Arrays
It's also possible to use arrayContaining
to check the values, and you can even combine them with all previous checks.
test('check dynamic array', () => {
const weapons = [
{ type: 'phaser', damage: 150, range: 15, speed: 'fast' },
{ type: 'photon cannon', damage: 10000, range: 100, speed: 'slow' },
];
expect(weapons).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: expect.any(String),
damage: expect.any(Number),
range: expect.any(Number),
speed: expect.any(String),
}),
])
);
});
Asynchronous Code
There are multiple ways to handle asynchronous code, depending on your needs.
Callback
The easiest way to handle callback is to use a single done argument when calling the callback function. For example,
test('test callback', done => {
initBattleMode((data) => {
try {
expect(data).toEqual({ ready: true });
done();
} catch (error) {
done(error);
}
});
});
Promise
Asynchronous code with a promise is a lot easier, as all you need to do is to return the promise. Have a look at the modified example:
test('test promise', () => {
return initBattleMode().then((data) => {
expect(data).toEqual({ ready: true });
});
});
Async/await
On the other hand, using async/await is a lot more straightforward. So let's reuse the previous example and modify it to use async/await instead.
test('test async', async () => {
const data = await initBattleMode();
expect(data).toEqual({ ready: true });
});
This is just a taste. For a complete list of matches, take a look at the reference docs.
Mocking Fundamentals
Occasionally, when doing our tests, you can't rely on real data because it's slow, private, or for other reasons.
Mocking allows you to intercept or erase the actual implementation of a function, capture calls (and the parameters passed in those calls), and enable test-time configuration of returned values.
One way to deal with this situation is to mock (faking) your data. Jest has already built-in some great tools with data mocking. It uses a custom resolver for imports in your tests, making it simple to mock any object outside your test's scope. In addition, you can use mocked imports with the rich Mock Functions API to spy on function calls with readable test syntax.
Let's focus on two types of mocks using Jest, the mock function, and the mock module.
Mock Functions
To mock a function, you just need to declare the method as a jest function: jest.fn()
, with that, you can start our evaluation. Here is a quick example:
// engine.test.js
// ...
describe('Rocket Engine', () => {
const cb = jest.fn();
beforeEach(() => {
cb.mockReset();
});
test('check callback response', () => {
cb.mockImplementationOnce(() => 2).mockImplementation(() => 1);
expect([1, 2, 3].map(cb)).toEqual([2, 1, 1]);
expect(cb).toHaveBeenCalledTimes(3);
});
// ...
});
First, declare your mock method, and then you defined the first and the default outputs. Next, check if the output matches our expected result. Thereafter, check if the method was called the amount of time expected and if the parameters were correct.
If you want to learn more, refer to the reference docs
Mock Modules
Mocking a module works similarly to mocking a function, but instead of applying it to a function, you have to intercept a module import.
Back to the spaceship idea, create a startEngine
method that receives a callback function as a parameter and does an HTTP call to an API server. In this case, you have to mock the unfetch
module.
// engine.js
import fetch from 'unfetch';
export const startEngine = async (callback) => {
const res = await fetch('https://api.space.com/rocket/engine/start');
const json = await res?.json();
if (json) {
if (callback) {
callback(json);
}
return json;
}
return undefined;
};
Now, declare the values you want to the mock module.
// engine.test.js
// ...
jest.mock('unfetch', () => () => ({
json: () =>
Promise.resolve({
status: 'ready',
fuel: '100%',
power: 100,
sensors: [{ type: 'temp', value: 50, active: true }],
}),
}));
// ...
There is also an alternative way to declare your module as an esModule.
// engine.test.js
// ...
jest.mock('unfetch', () => ({
__esModule: true,
default: () => ({
json: () =>
Promise.resolve({
status: 'ready',
fuel: '100%',
power: 100,
sensors: [{ type: 'temp', value: 50, active: true }],
}),
}),
}));
// ...
The first parameter is the modules name, and the second one is the factory method. You now have configured it to make the output values always be the same.
// engine.test.js
describe('Rocket Engine', () => {
const cb = jest.fn();
beforeEach(() => {
cb.mockReset();
});
test('check engine response', async () => {
const data = await startEngine();
expect(data).toMatchObject({
power: 100,
fuel: '100%',
status: 'ready',
sensors: [{ type: 'temp', value: 50, active: true }],
});
});
});
Using the previous mock, you can check the results. When executing, the output should be the same as declared before.
$ npx jest mock.test.js
For a complete list of mock functions, see the reference docs.
Static Code Analysis in JavaScript
There are a ton of ways your program can break. JavaScript is a loosely typed language. The most common bugs are typos and incorrect types, like the wrong variable name or the sum operation of two strings instead of integers.
What is “Static Analysis”?
So, what does mean “static analysis” of code? The answer is:
Predicting defects in code without running it.
Since JavaScript is a scripting language, instead of the compiler running the code analysis, you need to use formatters and linters to get the job done.
Formatters
Formatters are tools that can fix any style inconsistencies it finds automatically. For this purpose, tools like Prettier or StandardJS can do the job. There are a couple of options to configure it to best match your criteria, and it can be integrated with the most popular editors and IDEs.
To show you how does it work, here is an example of an unformatted code:
// unformatted_code.jsx
function HelloWorld({greeting = "hello", greeted = '"World"', silent = false, onMouseOver,}) {
if(!greeting){return null};
// TODO: Don't use random in render
let num = Math.floor (Math.random() * 1E+7).toString().replace(/\.\d+/ig, "")
return <div className='HelloWorld' title={`You are visitor number ${ num }`} onMouseOver={onMouseOver}>
<strong>{ greeting.slice( 0, 1 ).toUpperCase() + greeting.slice(1).toLowerCase() }</strong>
{greeting.endsWith(",") ? " " : <span style={{color: '\grey'}}>", "</span> }
<em>
{ greeted }
</em>
{ (silent)
? "."
: "!"}
</div>;
}
After using prettier, here is the result:
$ npx prettier --write unformatted_code.jsx
// formatted_code.jsx
function HelloWorld({ greeting = 'hello', greeted = '"World"', silent = false, onMouseOver }) {
if (!greeting) {
return null;
}
// TODO: Don't use random in render
let num = Math.floor(Math.random() * 1e7)
.toString()
.replace(/\.\d+/gi, '');
return (
<div className="HelloWorld" title={`You are visitor number ${num}`} onMouseOver={onMouseOver}>
<strong>{greeting.slice(0, 1).toUpperCase() + greeting.slice(1).toLowerCase()}</strong>
{greeting.endsWith(',') ? ' ' : <span style={{ color: 'grey' }}>", "</span>}
<em>{greeted}</em>
{silent ? '.' : '!'}
</div>
);
}
As you can see, the main benefit is that you don't need to worry about these minor inconsistencies anymore. It does that for you automatically.
Remember that you write code for the machine to interpret, but for humans to read.
The clearer and more consistent your code is, the easier it is to understand what is happening.
Linters
Code linting is a way to increase code quality. It analyzes the code and reports a list of potential code quality concerns. Currently, the most used tool for that is ESLint.
ESLint is a tool for identifying and reporting on patterns found in ECMAScript/JavaScript code
Let's check our example:
// unconsistent_code.js
function sayHello(name) {
alert('Hello ' + name);
}
name = 'John Doe';
sayHello(name)
To use ESLint, you need to install it first. Then, open the terminal and type on your project folder.
$ npx eslint --init
Now, you can run ESLint on any file or directory, like in this example.
$ npx eslint unconsistent_code.js
The linter shows where are the errors in our code, based on a set of rules in the eslinrc.{js,json,yaml}
file. You can also add, remove, or change any rules. For example, let's add a rule to check if we are missing a semicolon.
// eslint.js
...
rules: {
semi: ['error', 'always']
},
...
When executed again, the result will show you an error with the new rule.
This was a simple example, but the bigger the project, the more it makes sense to use it and catch many trivial errors that could take some time if done manually.
There are some sets of rules that can be extended, so you won't need to set them one-by-one like the recommended rules (" extends": "eslint:recommended"
), and others made by the community like the Airbnb or Standard that you can include into your project.
For a complete list of rules, refer to the reference docs.
Conclusion
In this article, you've learned more about how to start adding tests to your program and understanding the foundations of testing in JavaScript, Jest, Mocking, and Static Code Analysis and that's only the beginning. But don't worry about that, the most important thing is to add your tests while you are coding, saving you from numerous problems in the future.
Top comments (1)
Great article 👏🏽👏🏽