DEV Community

Cover image for JavaScript Testing 101
vedanth bora
vedanth bora

Posted on

JavaScript Testing 101

The goal of a test is to increase your confidence that the subject of your test is functioning the way it should be. Not all tests provide the same level of confidence (some provide very little confidence at all). If you’re not doing things correctly, you could be wasting your time and giving yourself a false sense of security (even worse than having no tests at all).

software bug is an error, flaw or fault in the design, development, or operation of computer software that causes it to produce an incorrect or unexpected result, or to behave in unintended ways. The process of finding and correcting bugs is termed "debugging" and often uses formal techniques or tools to pinpoint bugs. Since the 1950s some computer systems have been designed to deter, detect or auto-correct various computer bugs during operations.

The Middle English word bugge is the basis for the terms "bugbear" and "bugaboo" as terms used for a monster.

The term "bug" to describe defects has been a part of engineering jargon since the 1870s[7] and predates electronics and computers; it may have originally been used in hardware engineering to describe mechanical malfunctions. For instance, Thomas Edison wrote in a letter to an associate in 1878:

... difficulties arise—this thing gives out and [it is] then that "Bugs"—as such little faults and difficulties are called—show themselves[9]

The First "Computer Bug" Moth found trapped between points at Relay # 70, Panel F, of the Mark II Aiken Relay Calculator while it was being tested at Harvard University, 9 September 1947. The operators affixed the moth to the computer log, with the entry: "First actual case of bug being found". (The term "debugging" already existed; thus, finding an actual bug was an amusing occurrence.) In 1988, the log, with the moth still taped by the entry, was in the Naval Surface Warfare Center Computer Museum at Dahlgren, Virginia, which erroneously dated it 9 September 1945. The Smithsonian Institute's National Museum of American History and other sources have the correct date of 9 September 1947

First recorded bug in history


The term bug is used to describe a problem in a system. And we write a lot of systems now in code with software. And we find bugs in our systems all the time. It's one of the fun things about software is it's so fluid. And that fluidity is a great opportunity for bugs to sneak into things.

So we're finding bugs a lot more than they used to find moths in relays. But yeah, this is what a bug is and we don't want bugs in our software. So what kind of bugs are there? What kind of bugs do you find in software?

There are security bugs. What other kind of bugs can you find in software? What's the bug that you found yesterday cuz you find them all the time? What kind of bugs? Memory leaks, logic bugs. Integration bugs, you're talking to Twitter's API or something. There's a bug in that integration or even your own APIs. Race conditions, No pointer exception. Accessibility.

There are lots and lots of different kinds of bugs. And this isn't a comprehensive list or anything, but bugs can surface in all areas of our software.

So what do we do about these bugs? How do we prevent these bugs from happening? So using a type checker in JavaScript? Like TypeScript or Flow? I highly recommend that you leverage static typing in your Javascript. It will just take away a whole category of bugs that could appear in your software. It will also take you a lot longer to write JavaScript because it sometimes adding types to Javascript is really frustrating.

But in general, it makes your software better and so I definitely recommend static typing. This is also a form of static analysis. Linting, ESLint is a fantastic tool. If you're not using ESLint, I recommend you do. If you're using ESLint to check style, code style, please stop. We don't need that.

We have prettier formatted all. ESLint's job should be to catch common bugs that static type checkers can't catch. And lots of that involves domain specific things like in our code base, we can't do this because of this other thing or whatever. So you can write a custom ESLint rule for that.

So and ESLint also has a bunch of rules that are useful that static types don't generally catch, type checkers don't catch. So linting is another form of testing, and these two things are generally pretty easy to set up. And so I definitely recommend using these things.

So then testing is the next thing, the next layer of defense. ESLint can't catch logic errors. Type checking can't catch logic errors, anything like that. And so there's still a big category of bugs that we need to catch with testing.

So what kinds of test are available to us, these automated tests? I'll give you a hint, there's unit tests, what other kinds?

Or yeah, or the text is way longer in language x than English or something. So yeah, there's some things you can do to automate that, and then a whole bunch of other things. We're not gonna cover all of these. It would take weeks to cover all of these with any amount of depth.

So yeah, we're gonna focus on just a couple of these. So this is an example of static code analysis. 'three' is not defined. ESLink can save us from that. And then Flow can tell us, hey, well, three is defined, but it's a string. It should be a number.

Static code analysis

Test block example

And so it can tell us that these types are incompatible with each other. Unit tests, this is a basic unit test. We've got a little function here and we call it and we did a value back. That's the simplest form of a code automated test that I can think of.

Unit Tests

function sum(a, b) {
  return a + b
}

test('sum adds numbers', () => {
  expect(sum(1, 3)).toBe(4)
})
Enter fullscreen mode Exit fullscreen mode

And we're gonna be seeing what that test function and what that expect function are supposed to do. We'll be writing it ourselves. But that's, yeah, that's basic unit test. Integration test are generally a little bit more complicated. You have to start up the server, and then you have to hit an endpoint or something like that.

Integration Tests

let api, server

beforeAll(async () => {
  server = await startServer()
  const {port} = server.address()
  api = axios.create({
    baseURL: `http://localhost:${port}/api`
  })
})

afterAll(() => server.close())

beforeEach(() => resetDb())

test('can register a user', async () => {
  const registerData = {username: 'bob', password: 'wiley'}
  const testUser = await api
    .post('auth/register', registerData)
    .then(response => response.data.user)
  expect(testUser.username).toBe(registerData.username)

  const readUserUnauthenticated = await api
    .get(`users/${testUser.id}`)
    .then(response => response.data.user)
  expect(readUserUnauthenticated).toEqual(testUser)
})
Enter fullscreen mode Exit fullscreen mode

Maybe you have to have a database up and running. And so often these integration tests can be more complicated. This is for bank and stuff. You could also have integration tests on the UI that is mocking out all server requests and stuff, but is integrating how your UI is interacting with itself.

End to end Tests

import {assertRoute} from '../utils'

describe('authentication', () => {
  it('should allow users to register', () => {
    const user = {username: 'bob', password: 'wiley'}
    cy
      .visitApp()
      .getByText('Register')
      .click()
      .getByLabelText('Username')
      .type(user.username)
      .getByLabelText('Password')
      .type(user.password)
      .getByText('Login')
      .click()

    cy.url().should('equal', 'http://localhost:3000/')
    cy.getByTestId('username-display').should('contain', user.username)
  })
})
Enter fullscreen mode Exit fullscreen mode

And so yeah, there's more of a layer above unit test where it's testing several units together. The line between unit and integration test is a little bit fuzzy. But then we also have end-to-end tests. We're not gonna cover these today. But the general idea is you pull up the entire application and try to use it as close to as the user as possible.


Let’s try to understand this with an exercise.

We're going to write the simplest form of a test that you could possibly imagine. Below are two functions and sum is intentionally broken in this way. So, what your job is to write additional code, presumably after this code to throw a useful error to validate whether sum is working or not. So you use sum and then you validate that it is returning what it should be, an d if it's not, then you throw an error.

// sum is intentionally broken so you can see errors in the tests
const sum = (a, b) => a - b
const subtract = (a, b) => a - b
Enter fullscreen mode Exit fullscreen mode
  • Solution

    let result, expected
    
    result = sum(3, 7)
    expected = 10
    
    if (result !== expected) {
      throw new Error(`The result of ${result} is not equal to the expected ${expected}`)
    }
    
    result = subtract(5, 2)
    expected = 3
    
    if (result !== expected) {
      throw new Error(`The result of ${result} is not equal to the expected ${expected}`)
    }
    

A test is code that throws an error when the actual result of something does not match the expected output.

Now we are going to write an assertion library. The part that says actual !== expected is called an "assertion." This imperative code in the above solution is not my favourite. It would be great to kinda package that up so I could reuse this over and over again. If I wanted to do this, like for example, here's the end result if we test both of these. I'm doing the same kind of thing twice, so it would be really great if I could package that up into a single assertion.

What I want you to do is create a function that can be called with an actual value and it returns an object that has some properties on it some thing like this expect(result).toBe(expected). The one property we're using here is toBe. And that is a function that accepts the expected value. And if the actual is not equal to the expected value, then it should throw the error. And then re-factor the previous solution to use your fancy name assertion library.

  • Solution

    const {sum, subtract} = require('./math')
    
    let result, expected
    
    result = sum(3, 7)
    expected = 10
    
    expect(result).toBe(expected)
    
    result = subtract(7, 3)
    expected = 3
    
    expect(result).toBe(expected)
    
    function expect(actual) {
      return {
        toBe(expected) {
          if (actual !== expected) {
            throw new Error(`${actual} is not equal to ${expected}`)
          }
        }
      }
    }
    

It's just not readily apparent to us when we look at this error message what it was that broke. And so that's what a testing framework can do for us, is it can improve our error messages so that things are a little bit more clear what exactly is failing. And so, there are two problems here. The message isn't totally clear, and it's not running all of our tests in the file.

But now here's the problem 😖... If I see that error message, how do I know that the sum function is the one that's broken? It could be the subtract module. Also, the source of the test doesn't do a good job of keeping tests isolated (visually or otherwise).

So let's write a helper function to make that work:

const {sum, subtract} = require('./math')

test('sum adds numbers', () => {
  const result = sum(3, 7)
  const expected = 10
  expect(result).toBe(expected)
})

test('subtract subtracts numbers', () => {
  const result = subtract(7, 3)
  const expected = 4
  expect(result).toBe(expected)
})

function test(title, callback) {
  try {
    callback()
    console.log(`✓ ${title}`)
  } catch (error) {
    console.error(`✕ ${title}`)
    console.error(error)
  }
}

function expect(actual) {
  return {
    toBe(expected) {
      if (actual !== expected) {
        throw new Error(`${actual} is not equal to ${expected}`)
      }
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we can put everything relevant to a given test within our "test" callback function and we can give that test a name. Then we use that test function to not only give a more helpful error message but also run all the tests in the file (without bailing on the first error)! Here's the output now:

$ node 4.js
✕ sum adds numbers
Error: -4 is not equal to 10
    at Object.toBe (/Users/kdodds/Desktop/js-test-example/4.js:29:15)
    at test (/Users/kdodds/Desktop/js-test-example/4.js:6:18)
    at test (/Users/kdodds/Desktop/js-test-example/4.js:17:5)
    at Object.<anonymous> (/Users/kdodds/Desktop/js-test-example/4.js:3:1)
    at Module._compile (module.js:635:30)
    at Object.Module._extensions..js (module.js:646:10)
    at Module.load (module.js:554:32)
    at tryModuleLoad (module.js:497:12)
    at Function.Module._load (module.js:489:3)
    at Function.Module.runMain (module.js:676:10)
✓ subtract subtracts numbers
Enter fullscreen mode Exit fullscreen mode

Sweet! Now we see the error itself and we see the title of the test so we know which one to go about fixing.

So all we need to do now is write a CLI tool that will search for all our test files and run them! That bit is pretty simple at first, but there are a LOT of things we can add on top of it. 😅

At this point, we're building a testing framework and test runner. Luckily for us, there are a bunch of these built already! I've tried a ton of them and they're all great. That said, nothing comes close to serving our use cases better than Jest 🃏. It's an amazing tool (learn more about Jest here).

So, instead of building our own framework, let's just go ahead and switch our test file to work with Jest. As it so happens, it already does! All we have to do is remove our own implementation of test and expect because Jest includes those in our tests as global objects! So here's what it looks like now:

const {sum, subtract} = require('./math')

test('sum adds numbers', () => {
  const result = sum(3, 7)
  const expected = 10
  expect(result).toBe(expected)
})

test('subtract subtracts numbers', () => {
  const result = subtract(7, 3)
  const expected = 4
  expect(result).toBe(expected)
})
Enter fullscreen mode Exit fullscreen mode

When we run this file with Jest, here's what the output looks like:

$ jest
 FAIL  ./5.js
  ✕ sum adds numbers (5ms)
  ✓ subtract subtracts numbers (1ms)sum adds numbers

expect(received).toBe(expected)

    Expected value to be (using Object.is):
      10
    Received:
      -4

      4 |   const result = sum(3, 7)
      5 |   const expected = 10
    > 6 |   expect(result).toBe(expected)
      7 | })
      8 |
      9 | test('subtract subtracts numbers', () => {

      at Object.<anonymous>.test (5.js:6:18)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        0.6s, estimated 1s
Ran all test suites.
Enter fullscreen mode Exit fullscreen mode

You can't tell from the text, but that output is colored. Here's an image of the output:

TEST output image

It has color coding which is really helpful in identifying the parts that are relevant 😀 It also shows the code where the error was thrown! Now that's a helpful error message!


conclusion

So, what's a JavaScript test? It's simply some code which sets up some state, performs some action, and makes an assertion on the new state. We didn't talk about common framework helper functions like [beforeEach](https://facebook.github.io/jest/docs/en/api.html#beforeeachfn-timeout) or [describe](https://facebook.github.io/jest/docs/en/api.html#describename-fn), and there are a lot more assertions we could add like [toMatchObject](https://facebook.github.io/jest/docs/en/expect.html#tomatchobjectobject) or [toContain](https://facebook.github.io/jest/docs/en/expect.html#tocontainitem). But hopefully this gives you an idea of the fundamental concepts of testing with JavaScript.

I hope this is helpful to you!

Top comments (0)