A Beginner’s Guide on How to Improve Your Software Tests

Practices you might want to know when it's time to write your tests

This article is recommended for you if you’re looking for ways to improve your software testing techniques. If you’re more experienced, feel free to comment on your practices as well. Enjoy.

Software testing is a widely used technique to inspect how the software works under different conditions. By writing tests, you can prevent long-term issues that might show up in the development process. Well-written tests help you not only to ensure the quality of your application but also to catch errors while refactoring your code.

Unlike production code, your tests must be concise, simple, and easy to read. The idea is that when someone reads your tests, they know the exact behavior of the implementation.

The purpose of this article is to present you suggestions to improve the quality of your tests and how to keep them clean, concise, and with decent coverage.

At last, we’ll be implementing our solution in Jest. Feel free to use any testing tool/framework of your preference.

The issue

For our example, let’s say that we have a general web application, and we provide a service that returns the users registered in our database. How should it be tested?

You might already have come across or written something like this:

const request = require('supertest')
const app = require('/path/to/app')

const users = [
    { name: 'John Doe', age: 29 },
    //...
]

describe('user routes', () => {
    it('should return the users when there are users registered', async () => {
        const response = await request(app).get('/users')

        expect(response.status).toBe(200)
        expect(response.body).toEqual(users)
    })

    it('should return nothing when there are no users registered', async () => {
        const response = await request(app).get('/users')

        expect(response.status).toBe(204)
    })
})

The example above is what you’ll commonly see out there. It consists of defining the success and the failure cases from your module.

But is there a way to improve them? Of course there is! Right now, you’ll see some suggestions to improve the quality of your tests.

Make use of context

Most applications follow different paths depending on the conditionals operating within them. So what can we do to cover those paths? The answer is "context".

Contextualizing is one of the most powerful things that you can do to test your application. You can easily define the success and failure cases of a function and isolate different modules.

What if our endpoint has a different behavior when there are no users registered in our database? We can cover that by contextualizing the failure case and changing the expected value based on that.

Let’s restructure our previous example and contextualize the failure case:

describe('when there are users registered', () => {
    it('should return the users', async () => {
        const response = await request(app).get('/users')

        expect(response.status).toBe(200)
        expect(response.body).toEqual(users)
    })
})

Similarly, we can go even further by labeling what module and endpoint we are testing:

describe('Testing users routes', () => {
    describe('GET: /users', () => {
        describe('when there are users registered', () => {
            it('should return the users', async () => {
                const response = await request(app).get('/users')

                expect(response.status).toBe(200)
                expect(response.body).toEqual(users)
            })
        })

        describe('when there are no users registered', () => {
            it('should return nothing', async () => {
                const response = await request(app).get('/users')

                expect(response.status).toBe(204)
            })
        })
    })
})

Use describe to contextualize your tests and emulate the scenery instead of describing everything on it. Instead, it must be used to describe what value the test must return on that context.

Write better summaries

Summarize is what developers do to label our test suites. As was said before, our test suites and cases must be labeled to describe the context and the expected return value of a function.

In general, a test summary must be concise, objective, and understandable.

Let’s go back to our example success case:

it('should return the users', async () => {
    const response = await request(app).get('/users')

    expect(response.status).toBe(200)
    expect(response.body).toEqual(users)
})

Just by reading the test summary, we can instantly notice that, in this context, the response of this endpoint should always return the registered users. But still, something does not feel right.

Let’s break down this test case. What are we actually testing?

expect(response.status).toBe(200)
expect(response.body).toEqual(users)

We’re expecting our API to respond with 200 OK and with an array of our registered users. Knowing this, let’s re-write our test case summary to better match what we are expecting from the test:

it('responds with an array of registered users', async () => {
    const response = await request(app).get('/users')

    expect(response.status).toBe(200)
    expect(Array.isArray(response.body)).toBeTruthy()
})

While writing your test summaries, you must avoid the use of the word "should." But why is that? "Should" implies something that might happen. In our case, we are expecting an output that always will be the same.

By simply changing our test case summary to responds with an array of registered users, we already slightly improved our understanding of the implementation. Let’s compare both side-to-side:

  • When you make a request to this endpoint, it should return the users.
  • When you make a request to this endpoint, it responds with an array of registered users.

Doesn’t the first example sound like: "Well, it works. Except on Saturdays, when it’s raining.?" In short, your summary must guarantee that the test does what it says. The person that read your tests must not feel either uncertain or confused.

A proper summary shows that your implementation is idempotent. It means that on a given condition, with that given input, the expected result must always be the same, no matter how many times tested.

Avoid conditionals entirely

To keep your tests easy to read and concise, you should avoid using conditionals in your tests. If you ever need it, you should consider creating a new context and cover whatever you need there.

Mock functions, however, depend entirely on the context. In that case, you must define the return value of the mocked function on the module you’re testing.

For instance, let’s say that we are testing a controller that calls a service to fetch a user by their ID:

const userController = require('/path/to/controller')
const userService = require('/path/to/service')

jest.mock('/path/to/service')

function mockFetchUserById = (payload = { name: 'John Doe' }) => {
    return userService.fetchUserById.mockImplementationOnce(async (id = 'valid_id') => {
        if (id !== 'valid_id') {
            throw new Error('User does not exist.')
        }

        return payload
    })
}

describe('When ID is valid', () => {
    it('returns the user data', () => {
        mockFetchUserById({ name: 'John Doe' }))

        const promise = userController.find('valid_id')

        expect(() => promise).resolves.toEqual({ name: 'John Doe' })
    })
})

describe('When ID is invalid', () => {
    it('throws an error', () => {
        mockFetchUserById({ name: 'John Doe' }))

        const promise = userController.find('invalid_id')

        expect(() => promise).rejects.toThrow()
    })
})

You can notice that on the previous example, we are checking if the ID is valid_id or not. This doesn’t seems like a problem, but the ideal is to have a single test or mock per conditional. Let’s take a look on a real example:

const mockJWTDecoder = (payload = null) => {
    jwt.decode.mockImplementationOnce(() => {
        if (typeof payload === 'string' || !payload) {
            return null;
        }

        return {
            user_name: payload.user_name || 'email',
            permissions: payload.permissions || [],
        };
    });
};

This was the mock function for the JSON Web Token decoder that my team were using on our application. You can instantly notice the if statement that makes the test more complicated than it needs to be. Again, the best deal is to have a single test or mock per conditional.

Refactoring our previous function, we would achieve something like this:

describe('Testing jwt.decode', () => {
    describe('when token is invalid', () => {
        it('returns null', () => {
            jwt.decode.mockReturnValueOnce(null)
            // ...
        })
    })

    describe('when token is valid', () => {
        it('returns the payload of the token', () => {
            jwt.decode.mockReturnValueOnce({ user_name: 'johndoe', permissions: ['read', 'write'] })
            // ...
        })
    })
})

Not only would this make the code more concise, but would also improve the readability, given the fact that the mock function was removed.

Going back to our first example, here’s what the code should look like when we remove conditionals:

const userController = require('/path/to/controller')
const userService = require('/path/to/service')

jest.mock('/path/to/service')

describe('When ID is valid', () => {
    it('returns the user data', () => {
        userService.fetchUserById.mockResolvedValueOnce({ name: 'John Doe' }))

        const promise = userController.find('user_id')

        expect(() => promise).resolves.toEqual({ name: 'John Doe' })
    })
})

describe('When ID is invalid', () => {
    it('throws an error', () => {
        userService.fetchUserById.mockRejectedValueOnce(new Error('User does not exist.'))

        const promise = userController.find('user_id')

        expect(() => promise).rejects.toThrow()
    })
})

As you can see, the lower-level implementation – in our case, the user service – must be mocked to attend to our current context.

Test inner implementations

If your software has different layers, your tests should also ensure that they are communicating as expected.

Let’s say that we implement a new feature in our application. Now, we should be able to group the registered users based on their age. We’ll be filtering them in our endpoint with a query parameter. Therefore:

  • GET: /users – Returns all users
  • GET: /users/?older_than=[age] – Returns all users older than given age

Suppose we have two services for that – fetchAllUsers and fetchAllUsersOlderThan – and both are being called on the same controller, based on the older_than query param. How do we properly test if the service called is the expected one? For that, we’re going to create a new unit test of the controller:

jest.mock('/path/to/services/user.service', () => ({
    fetchAllUsers: jest.fn(() => []),
    fetchAllUsersOlderThan: jest.fn(() => [])
})

const res = {
    send: () => res,
    status: () => res
}

describe('Testing user controller', () => {
    describe('.fetchUsers', () => {
        describe('when no query parameter is passed', () => {
            it('calls userService.fetchAllUsers', () => {
                const req = {}

                userController.fetchUsers(req, res)

                expect(userService.fetchAllUsers).toHaveBeenCalled()
            })
        })

        describe('when older_than query parameter is passed', () => {
            it('calls userService.fetchAllUsersOlderThan', () => {
                const req = {
                    query: { older_than: 20 }
                }

                userController.fetchUsers(req, res)

                expect(userService.fetchAllUsersOlderThan).toHaveBeenCalledWith(20)
            })
        })
    })
})

So, why do we need to mock the user service? In this context, we are explicitly testing what our controller is calling and not the return value. Let’s take a look at our controller:

const userService = require('/path/to/user.service')

const isFiltering = (age) => Boolean(age)

exports.fetchUsers = (req, res) => {
    const { older_than: age } = req.query

    const users = isFiltering(age)
        ? userService.fetchAllUsersOlderThan(age)
        : userService.fetchAllUsers()

    const status = (users.length === 0) ? 204 : 200

    return res.status(status).send(users)
}

Other suggestions

The "One-assert-per-test" rule for unit tests

This rule, as the name suggests, consists of using one assertion per test case.

Let’s say that we have the following factory:

function createPerson(name, age) {
    return {
        name,
        age
    }
}

Here’s what it would look like with the one-assert-per-test:

describe('test createPerson', () => {
    it('returns an object', () => {
        const person = createPerson('John Doe', 29)
        expect(typeof person).toBe('object')
    })

    it('assigns the values correctly', () => {
        const person = createPerson('John Doe', 29)
        expect(person).toEqual(expect.objectContaining({
            name: 'John Doe',
            age: 29
        }))
    })
})

However, this applies only for unit tests. If you are testing HTTP calls, database access or any other heavy asynchronous operation through integration tests, you should avoid using this rule, since it makes the tests slower because you would be running the same heavy code over and over again unnecessarily.

Some developers might say that this rule is better to keep things organized. Others might say that it’s not necessary at all. It’s up to the developer/team to decide whether to apply this rule or not.

Should I test private methods?

The short answer is no. This has already been said before.

More experienced developers would say that if your private method is complex enough to be tested, you should consider refactoring it to a dedicated module and write the tests for the implementation.

Conclusion

Let’s compare our initial example to our final result before:

user routes
    ✓ should return the users when there are users registered
    ✓ should return nothing when there are no users registered

And after the tests rewrites:

Testing users routes
    GET: /users
        when there are users registered
            and no query parameter is passed
                ✓ responds with an array of registered users
            and older_than query parameter is passed
                ✓ responds with an array of users older than given age
        when there are no users registered
            ✓ returns HTTP status 204

Testing user controller
    .fetchUsers
        when no query parameter is passed
            ✓ calls userService.fetchAllUsers
        when older_than query parameter is passed
            ✓ calls userService.fetchAllUsersOlderThan

What do you think? Not only the tests became more descriptive and isolated but also contextualized to be effortlessly inspected and read.

We’ll be wrapping up for today. If you have any suggestions, experiences, or tips you want to share, please let me know in the comments!

You can find me on GitHub and LinkedIn.

Special thanks to Felipe Nolleto.

Thank you for reading, and happy coding!

We want to work with you. Check out our "What We Do" section!