DEV Community

Helmuth Saatkamp for Mercedes-Benz.io

Posted on • Edited on • Originally published at Medium

Become a Master of JavaScript Testing

What a Spaceship and JavaScript have in common? Both already reached space.

Photo by NASA on Unsplash

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;
Enter fullscreen mode Exit fullscreen mode

Show code in action

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}` });
}
Enter fullscreen mode Exit fullscreen mode

Show code in action

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}` });
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Show code in action

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
Enter fullscreen mode Exit fullscreen mode

Show code in action

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);
  }
};
// ...
Enter fullscreen mode Exit fullscreen mode

Show code in action

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));
Enter fullscreen mode Exit fullscreen mode

Show code in action

Let's open the terminal and run our test:

$ npx babel-node calculator.test.js
Enter fullscreen mode Exit fullscreen mode

screenshot

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));
Enter fullscreen mode Exit fullscreen mode

Show code in action

screenshot

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));
Enter fullscreen mode Exit fullscreen mode

Show code in action

In the terminal, run the command:

$ npx jest calculator.test.js
Enter fullscreen mode Exit fullscreen mode

screenshot

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
Enter fullscreen mode Exit fullscreen mode

Thereafter, update the package.json file to run it:

...
"scripts": {
  "test": "jest"
},
...
Enter fullscreen mode Exit fullscreen mode

Show code in action

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);
  });
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Show code in action

After running Jest, this is the result:

screenshot

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' });
});
// ...
Enter fullscreen mode Exit fullscreen mode

Show code in action

Running this test, you will get the following result:

screenshot

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/);
});
Enter fullscreen mode Exit fullscreen mode

Show code in action

Length

It's also possible to compare the length between two strings using toHaveLength.

expect('abc').toHaveLength(3);
Enter fullscreen mode Exit fullscreen mode

Show code in action

It works with an array as well.

expect([1, 2, 3]).toHaveLength(3);
Enter fullscreen mode Exit fullscreen mode

Show code in action

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

Show code in action

When changing the value from 10 to 5, you will receive the following error message.

screenshot

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');
});
Enter fullscreen mode Exit fullscreen mode

Show code in action

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));
});
Enter fullscreen mode Exit fullscreen mode

Show code in action

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),
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

Show code in action

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),
      }),
    ])
  );
});
Enter fullscreen mode Exit fullscreen mode

Show code in action

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);
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

Show code in action

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 });
  });
});
Enter fullscreen mode Exit fullscreen mode

Show code in action

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 });
});
Enter fullscreen mode Exit fullscreen mode

Show code in action

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);
  });
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Show code in action

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

Enter fullscreen mode Exit fullscreen mode

Show code in action

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 }],
    }),
}));
// ...
Enter fullscreen mode Exit fullscreen mode

Show code in action

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 }],
      }),
  }),
}));
// ...
Enter fullscreen mode Exit fullscreen mode

Show code in action

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 }],
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Show code in action

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
Enter fullscreen mode Exit fullscreen mode

screenshot

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>;

}
Enter fullscreen mode Exit fullscreen mode

After using prettier, here is the result:

$ npx prettier --write unformatted_code.jsx
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Show code in action

To use ESLint, you need to install it first. Then, open the terminal and type on your project folder.

$ npx eslint --init
Enter fullscreen mode Exit fullscreen mode

Now, you can run ESLint on any file or directory, like in this example.

$ npx eslint unconsistent_code.js
Enter fullscreen mode Exit fullscreen mode

screenshot

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']
},
...
Enter fullscreen mode Exit fullscreen mode

When executed again, the result will show you an error with the new rule.

screenshot

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)

Collapse
 
andrecrimberg profile image
André Crimberg

Great article 👏🏽👏🏽