Javascript Testing with AVA

Peter Weber

Testing is a part of every developer’s job. Good tests ensure code we write does what we expect. In Javascript, we have many tools at our disposal, such as the excellent developer panels in Chrome and Firefox.

If you’re already testing or already know why you should be testing, jump straight to AVA examples, if you’re still not sold or want to know more read on!

During development every developer writes a number of test cases, allowing us to verify our functions or hunt down confusing bugs. Without a formal tool for testing, lots of this code will be commented out, deleted or changed. When code works the way you expect, that ad-hoc test code or commentary will get deleted during cleanup. Later when the app is refactored or another developer works on it, all of that helpful reporting is gone.

Writing a formal test is a great way to preserve this effort. Tests can cover many more edge cases and log output in a format which is clear and understandable, and which will still run after you have moved on and forgotten the details of an already solved problem.

Testing all the time

While writing code it’s common to print output to check your work, maybe using console.log() or by setting a breakpoint and stepping through to inspect variables.

This simple function will offset a number by an amount:

function offset(num, amount) {
 
  return num + amount;
}

To check that this works, you could run it using a few different numbers.

function offset(num, amount) {
 
  return num + amount;
}
 
console.log(offset(10, 50)); // 60
console.log(offset(-10, 50)); // 40

Now maybe you realize you might pass a number as a string, like 10 instead of 10. What will happen? Better do another quick check to see.

export function offset(num, amount) {
 
  return num + amount;
}
 
console.log(offset(10, 50)); // 60
console.log(offset(-10, 50)); // 40
console.log(offset(10, 50)); // ‘1050’

That’s not good, since ‘10’ is a string, the + operator is concatenating 50 instead of adding it to 10. To convert a possible string to a number, you could add parseInt, but to check it’s working might take another console.log() inside the function.

function offset(num, amount) {
  if (typeof(num) == 'string') {
    num = parseInt(num);
    console.log('parsed number', typeof(num), num);
  }
  return num + amount;
}
 
console.log(offset(10, 50)); // 60
console.log(offset(-10, 50)); // 40
console.log(offset(10, 50)); // ‘60’

That’s looking good, but what if you pass it a float, like 10.5 ? And what if that’s a string ‘10.5’? Turns out parseInt(‘10.5’) will output 10.

Might be better to use Number() instead, now the code looks like

function offset(num, amount) {
  if (typeof(num) == 'string') {
    num = Number(num);
  }
  return num + amount;
}
 
console.log(offset(10, 50)); // 60
console.log(offset(-10, 50)); // 40
console.log(offset(10, 50)); // 60
console.log(offset(10.5, 50)); // 60.5
console.log(offset(10.5, 50)); // 60.5

Now all those console.log() statements need to be removed and the extra check for type == string is probably not necessary.

function offset(num, amount) {
 
  return Number(num) + amount;
}

As you wrote that function, you probably would have also written a lot of scratch work, even for such a simple function. That “throwaway” code was helpful but was never meant to be part of the final product. If a larger application depends on this function in many places a small change in one place could create a hard to find bug in another. How can you know that making a change won’t break something else that depends on it, or that “fixing” something might break something else? As we saw with our ad-hoc tests – our “scratch work” – you’re writing tests already. Don’t throw them away. Instead, make this temporary code permanent so it can continually validate the stability of your application.

AVA tests

There are many testing frameworks for Javascript. AVA is modern (supports ES6), simple, easy to install, easy to configure, and has readable syntax and useful features like support for promises and asynchronous code.

Workflow

Testing becomes a chore when it’s added later. If your code already “works” then writing a test to prove it seems like a pointless formality. This code is often more difficult to test because you never intended for it to be tested as you wrote it.

However, if you write the test first, it becomes part of your process, it forces you to think about your desired outcomes before trying to solve a problem. This process encourages you to write code that is easier to read and think about since each test describes a single feature.

AVA provides a todo method which lets you simply describe functionality before you get to it. When planning, write a simple phrase for each separate thing the app should do, and if it seems too complicated, break it into a few smaller “tasks”. In this way, you can scaffold out an entire application, create a test file, add a few todos, then as you notice functionality that should be grouped together, move it out to its own test file.

Let's return to the example above, but this time instead of writing the function- write what you want to accomplish.

test.todo('Offset a number by an amount');

Running the test now gives this output (1 test todo) AVA test todo

Once you’re ready to work on that functionality, remove the “todo” method and begin writing assertions.

Instead of adding a temporary console.log() to your code, you can “assert” an output in the test and validate it.

test('Offset a number by an amount', t => {
 
  t.is(offset(10, 50), 60);
});

AVA has a number of assertions which you can use to check various conditions, in this case it’s verifying that the return value of offset() is 60.

.is() asserts the first argument is equal to the second. offset(10, 50) === 60

Running this test now will fail since there is not an offset() function at all.

AVA no function

I’ll add a few more tests then write a basic function.

test('Offset a number by an amount', t => {
 
  t.is(offset(10, 50), 60);
  t.is(offset(-10, 50), 40);
  t.is(offset(10.5, 50), 60.5);
});
function offset(num, amount) {
  return num + amount;
}

The test now passes!

AVA test pass

To check that this function works with a string value as an argument, just add one more test assertion.

test('Offset a number by an amount', t => {
 
  t.is(offset(10, 50), 60);
  t.is(offset(-10, 50), 40);
  t.is(offset(10.5, 50), 60.5);
  t.is(offset('10', 50), 60);
});

Update the function to handle string values:

function offset(num, amount) {
  return parseInt(num) + amount;
}

This “fix” does return the expected value when passing a number as a string, but AVA caught something important- using parseInt will break this function for floats.

AVA test fail

If I already deleted my test code from earlier, I'd likely never check this specific case again, but once a test is written it will always alert you when you break something that used to work.

Using AVA

Install AVA with npm i ava

All config gets added directly to package.json

I’m using babel so I’ll add

"ava": {
    "require": [
      "babel-register"
    ],
    "babel": "inherit"
  }

Running Tests

For convenience you can add some scripts to the “test” section of package.json

"scripts": {
    "test": "ava tests/*.js --verbose",
    "test:watch": "ava tests/*.js --verbose --watch",
    "ava": "ava --verbose"
  }

These 3 commands can be run as:

  • npm test
  • npm run test:watch
  • npm run ava tests/example.js

Change these to your own preference, I think the --verbose flag is helpful, and --watch is handy if you want AVA to continually check your work. Sometimes it’s useful to just have the ava command so you can test a single file.

Writing Tests

At the beginning of your test file include AVA itself, and any of your code that you want to test.

import test from 'ava';
import {offset} from './../lib/example';

Each test is a function, and within each test you will declare assertions that must all pass for your test to pass. The last argument is a “message” which is very helpful in quickly seeing which assertion failed when you have several in a single test.

t.is(amount, 50, ‘Amount should be 50);

There are several specific and useful assertions like deepEqual which checks that an object has the properties you expect, but you’re not limited to this list. For example, there aren’t any “type checking” assertions but to verify that a returned value is an object you could write

t.is(typeof(point), ‘object’)

Additionally, you can initialize as much of your app as you need to test a feature, passing in fake or real data to check your expectations.

test('Can get queue length', t => {
  const Q = new Queue();
 
  Q.load('intro', screenplay.intro); // Load the intro
  t.is(Q.length(), screenplay.intro.length, ‘Loaded length should equal original’);
 
  Q.clear();
  t.is(Q.length(), 0, ‘Cleared queue should be empty’);
});

To check the length() method I need to first initialize my “queue” class and load sample data. Then after clearing the data, I can check the length() method again to verify it still works.

When checking a large number of assertions you could also write a loop to reduce the repetition.

const cases = [
  {input: undefined, expected: [[0,0], [100,100]]},
  {input: [[0,0], [1000,1000]], expected: [[0,0], [1000,1000]]},
  {input: [[-100,-100], [0,0]], expected: [[-100,-100], [0,0]]},
];
 
cases.forEach(item => {
  t.deepEqual(bounds(item.input), item.expected);
});

(ht: @gabesullice)


AVA makes testing easy and convenient in Javascript, and writing tests will make your code more stable, readable and organized. Besides catching mistakes or letting you mock examples, test coverage will give you more confidence when you extend an existing project or refactor your own work.