DEV Community

loading...
Cover image for A Beginner's Guide to Unit-testing with Jest

A Beginner's Guide to Unit-testing with Jest

dsasse07 profile image Daniel Sasse Updated on ・7 min read

Unit testing is an integral part of Test-Driven Development (TDD) which is the process of defining the desired actions of a function and what we expect it to do (or not do) before we begin work on the actual function. Approaching software development in this fashion serves a number of purposes:

  • this process can help define a path to success by outlining the tasks that must be done over the course of the function.
  • this process can help identify edge-case scenarios and ensure that your code continues to function as expected in these situations.
  • As the codebase continues to grow and be modified, this process also ensures that changes to other parts of the codebase do not negatively effect the performance of the tested function.

Programming languages have their own frameworks for developing unit tests. For Javascript, Jest is one of the most widely used testing frameworks, and I hope this blog serves as a beginner's guide for those looking to get started in writing their own Jest tests.

We will walk through the process of setting up basic Jest tests and the files, but you can view the repo containing all of the code here

Contents

Setting Up Jest

Steps:

  • Create a new directory, and cd into that directory.
  • Set up the NPM environment
mkdir jest-example && cd jest-example 
npm init -y
Enter fullscreen mode Exit fullscreen mode
  • Install Jest
npm i jest --save-dev
Enter fullscreen mode Exit fullscreen mode
  • Configure the NPM environment to use Jest by modifying the package.json file created earlier. This edit will cause the command npm test to run the tests we will be building.
// In package.json
"scripts": {
  "test": "jest"
}
Enter fullscreen mode Exit fullscreen mode

Identify Desired Actions

To begin writing the tests, we must define what the function we will be building should do, and what the expected outcome should be when the function is invoked.

For our example, let's consider an object containing information about a user's blog posts:

const user = {
    username: "user1",
    blogs: [
      {
        title: "Entry 1"
        likes: 130,
        content: "Blog 1 Content..."
      },
      {
        title: "Entry 2"
        likes: 100,
        content: "Blog 2 Content..."
      }
    ]
  }
Enter fullscreen mode Exit fullscreen mode

We will be writing two functions,

  • getTotalLikes to get the total number of likes of the given user's posts,
  • getMostPopularBlog to return the blog object of a specified user with the most likes.

Following the TDD process, we will develop tests for these functions prior to working out the logic for the functions themselves.

Initializing the Test File

Typically, tests are written in a tests or __tests__ subdirectory of the app, and we will follow this same convention. From the root of our example project, let's create a tests directory and the file which will contain our tests.

mkdir tests && cd tests && touch exampleFunctions.test.js
Enter fullscreen mode Exit fullscreen mode

The first thing we must do in this new file is to import the functions that we will be testing (it's ok that they have not yet been written.) For the sake of this blog, we will be writing both of the sample functions into the same .js file, and we will use destructuring in the import to get access to both of those functions.

// jest-example/tests/exampleFunctions.test.js
const { getTotalLikes, getMostPopularBlog } = require('../exampleFunctions')
Enter fullscreen mode Exit fullscreen mode

Both of the example functions discussed above will be tested using the same sample user object mentioned previously, so we can define this globally for our tests file as well.

// jest-example/tests/exampleFunctions.test.js
const { getTotalLikes, getMostPopularBlog } = require('../exampleFunctions')
const user = {
    username: "user1",
    blogs: [
      {
        title: "Entry 1"
        likes: 130,
        content: "Blog 1 Content..."
      },
      {
        title: "Entry 2"
        likes: 100,
        content: "Blog 2 Content..."
      }
    ]
  }
Enter fullscreen mode Exit fullscreen mode

Writing tests

Tests typically contain these general components:

  • a describe function is invoked which accepts two arguments:
    • a string (a description that will appear in the terminal when tests are run, which "describes" the test block)
    • a callback function which will contain the individual tests..
  • One (or more) test function which accepts two arguments:
    • a string describing the action of the specific test,
    • a callback function containing an expect function and a matcher function.
    • The expect function accepts the function invocation being tested, and is chained to the matcher which describes the expected results.

In the getTotalLikes function, we expect that when the function is passed a user object, the return value will be an integer that is the sum of the likes on all of the blogs of that user. Including this into our test file would look like this:

  // jest-example/tests/exampleFunctions.test.js
const { getTotalLikes, getMostPopularBlog } = require('../exampleFunctions')
const user = {
    username: "user1",
    blogs: [
      {
        title: "Entry 1",
        likes: 130,
        content: "Blog 1 Content..."
      },
      {
        title: "Entry 2",
        likes: 100,
        content: "Blog 2 Content..."
      }
    ]
  }

describe('getTotalLikes', () => {
  test('should return the total likes of a user', () => {
    expect( getTotalLikes(user) ).toBe(230)
  })
})
Enter fullscreen mode Exit fullscreen mode

Here, the .toBe matcher is used to define the expected output of the function invocation written in the preceeding expect statement. The .toBe matcher returns truthy if the output of the function is equal to the value passed into the matcher. The Jest framework has a number of defined matchers, such as:

  • toBeNull matches only null
  • toBeUndefined matches only undefined
  • toBeDefined is the opposite of toBeUndefined
  • toBeTruthy matches anything that an if statement treats as true
  • toBeFalsy matches anything that an if statement treats as false
  • toBeGreaterThan or toBeLessThan for number value comparisons
  • toMatch accepts a Regex pattern to match a string output
  • toContain can be used to see if a value is contained in an Array

More common Jest Matchers can be found in the official introduction here or a complete list can be found in the official docs here

For our second function, we can define the expected output object within the describe block's scope and pass this object into our matcher. Doing this, we will again be checking for equality; however when dealing with objects, we must use .toEqual instead, which iterates through all of the values of the objects to check for equality.

With this in mind, we must add this final describe block to our test file:

describe('getMostPopularBlog', () => {
  test('should return the most popular blog of a user', () => {
    const output = {
        title: "Entry 1",
        likes: 130,
        content: "Blog 1 Content..."
    }
    expect( getMostPopularBlog(user) ).toEqual(output)
  })
})
Enter fullscreen mode Exit fullscreen mode

Running the Tests

The tests we have written should clearly fail because we have not yet written the functions; however, we can run the test to ensure that they are properly set up.

To run the tests, run npm test (which matches the command we defined in the package.json). We are wonderfully greeted with the expected failures that our functions are not defined, and it indicates that our test file is prepared.

 FAIL  tests/exampleFunctions.test.js
  getTotalLikes
     should return the total likes of a user (1 ms)
  getMostPopularBlog
     should return the most popular blog of a user

   getTotalLikes  should return the total likes of a user

    TypeError: getTotalLikes is not a function
Enter fullscreen mode Exit fullscreen mode

Writing the functions

Create a new file in /jest-example which will contain our functions. The name of the file should match the filename of the test file, minus the .test extension.

In /jest-example

touch exampleFunctions.js
Enter fullscreen mode Exit fullscreen mode

In this file we need to define out two functions, and ensure that we export those functions so that our test file can access them.

function getTotalLikes(user){

}
function getMostPopularBlog( user){

}
module.exports = { getTotalLikes, getMostPopularBlog }
Enter fullscreen mode Exit fullscreen mode

If we save and run our tests again, we will see that all four tests still fail (which is expected), but Jest provides a ne message to us indicating what happened.

  getTotalLikes
     should return the total likes of a user (3 ms)
  getMostPopularBlog
     should return the most popular blog of a user (1 ms)

   getTotalLikes  should return the total likes of a user

    expect(received).toBe(expected) // Object.is equality

    Expected: 230
    Received: undefined
Enter fullscreen mode Exit fullscreen mode

This message indicates that our test is able to find the matching function, unlike before, but now instead of getting the expected value that was passed to the matcher, no value is being returned from our function. Let's implement the logic for our two functions as shown below:


function getTotalLikes( user ){
  // iterate through the blog entries and sum the like values
  const totalLikes = user.blogs.reduce( (total, blog) => {
    return total += blog.likes
  }, 0)

  return totalLikes
}

function getMostPopularBlog( user ){
  // Iterate through users blogs, and update the tracking object to
  // continually have the index of the blog with most likes, and the 
  // number of likes for comparison
  const maxLikes = user.blogs.reduce( (max, blog, index) => {
      if (blog.likes > max.likes) {
        return {
          index: index, 
          likes: blog.likes
        }
      } else {
        return max
      }
  }, {index: undefined, likes: 0} )

  //Select get the blog object by looking up the index stored in the tracker
  const topBlog = user.blogs[ maxLikes.index ]
  return topBlog
}

module.exports = { getTotalLikes, getMostPopularBlog }
Enter fullscreen mode Exit fullscreen mode

Now, if we run the tests one final time, we are greeted with pass indicators:


 PASS  tests/exampleFunctions.test.js
  getTotalLikes
     should return the total likes of a user (1 ms)
  getMostPopularBlog
     should return the most popular blog of a user (1 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.713 s, estimated 1 s
Enter fullscreen mode Exit fullscreen mode

Conclusion

Testing is powerful. Even with these limited tests, we would would be able to see if changes further along in the development process negatively impact the work we have already done. For example, if the structure of the API response that we used to build the user object changed, running the test file would indicate an issue prior to that change going into effect. This is especially important in development teams, where multiple developers are working on the same codebase. The tests help ensure that new code remains compatible and functional with the codebase and with that of other developers.

However, the reliability and power of testing is limited by the comprehensiveness of the test scenarios. As you are building tests, remember to consider the edge case scenarios that could break the function of your application, and write tests to simulate those. For example:

  • What would we expect to happen if the user was not found?
  • What is the expected behavior if two posts have the same number of likes?
  • What is the expected behavior if a user has no blogs?

The topic of testing goes very deep, but hopefully this helps you get started with understanding the testing process and developing your own tests.

Resources:

Discussion (0)

pic
Editor guide