Multiple assertions per test may actually be OK

Corey Cleary - Nov 30 '22 - - Dev Community

Originally published at coreycleary.me. This is a cross-post from my content blog. I publish new content every week or two, and you can sign up to my newsletter if you'd like to receive my articles directly to your inbox! I also regularly send cheatsheets and other freebies.

In this post I want to examine the claim that "you should only ever have one assertion per test". This is oft-repeated to the point it is basically considered a rule or best practice at this point. But is it actually a good practice that should be followed universally?

Knowing or learning how to write good tests for your code can be confusing enough - you have to know when and how to use test doubles, write meaningful test cases and descriptions, when to stub a function vs use a real database/API, etc.

Having a bunch of so-called rules people have made up on top of that makes it all even more confusing. And there are a lot of different interpretations on what this "rule" even means. So I want to look at that statement further and discuss some instances when it might not make sense, and what the original rule should was (likely) meant to intend in the first place since it seems to be repeated often in developer-land.

A bad test

First let's look at an example of a test that has a lot of assertions for a single test case. The code we want to test is:

class User {
  constructor(userName) {
    if (!userName) {
      throw Error('A username must be provided')
    }

    this.userName = userName
  }

  getFormattedUserName() {
    // a simplified example just for demonstration purposes, you likely wouldn't be appending a string to a username
    const identifier = 'usr'
    return `${this.userName}-${identifier}`.toUpperCase()
  }
}
Enter fullscreen mode Exit fullscreen mode

And the bad test is:

import mocha from 'mocha'
import {expect} from 'chai'

it('should return a correctly formatted username', () => {
  expect(new User()).to.throw()

  const user = new User('testcaseuser')
  expect(user).to.be('TESTCASE-USR')
})
Enter fullscreen mode Exit fullscreen mode

This is an example where multiple assertions are bad, because it's essentially two tests in one: 1) that the class instantiated without a user name string should throw an error and 2) that the formatting works as expected. But the test case - "should return a correctly formatted username" - means only the second assertion is irrelevant.

So having multiple assertions here is bad because of an irrelevant assertion.

The fix

It can be helpful to think about what the output for your function is that you are testing. The output of getFormattedUserName() is not a thrown Error if the username is not passed in when instantiating the User class. Instead, it is a formatted username. With that in mind, here is an example of the refactored tests. The obvious thing to do here is to limit the assertions to just testing the formatting, like so:

it('should return a capitalized username', () => {
  const user = new User('testcase456')
  expect(user).to.match(/TESTCASEUSER456/)
})

it('should return a username with an appended identifier', () => {
  const user = new User('testcase456')
  expect(user).to.match(/-USR/)
})
Enter fullscreen mode Exit fullscreen mode

The original test was just one test, but in this case we broke it out into two test cases in order to test more expected output.

So now we have one assertion per test, but those tests are fairly closely related. What if we did this?

it('should return a correctly formatted username', () => {
  const user = new User('testcase456')
  expect(user, 'should capitalize username').to.match(/TESTCASE456/)
  expect(user, 'should append username').to.match(/-USR/)
})
Enter fullscreen mode Exit fullscreen mode

We can add an assertion description as the second argument to expect(), which allows us to have multiple assertions per test and which makes it clearer what the issue is if the assertion fails. (NOTE: using Mocha and Chai here, but most test frameworks should also have this ability to add an assertion description)

This code is helpful to discuss a certain point - that "no multiple assertions per test" makes more sense / may be clearer as "no multiple aspects/concepts per test". I would argue that this example above, where we have one test case - a discrete aspect/concept - with multiple assertions is actually OK. Those assertions are both testing closely related formatting concepts, which can be tested with two "sub-assertions".

Also, one of the reasons "no multiple assertions per test" has been touted as a rule is because if the assertion fails, you won't know why necessarily, but here we get around that issue by having assertion descriptions.

In the second example (multiple assertions) we only have to instantiate User once, wherease with two tests we have to do it twice. User here is so simple it probably doesn't matter, but you can imagine a scenario where the test setup is more complex. More than one assertion per test may make sense in a case like this where otherwise you’d have to copy most of test A again to make test B

Having this test as-is above vs. breaking it out into two tests - honestly I think both are OK. At a certain point it gets into splitting hairs, or more of a readability thing. But I wanted to include the test above to show that multiple assertions per test is not always bad.

Note: it should be pointed out that some test runners will skip the rest of the assertions if one fails and go to the next test, and we mitigate this a bit by having clear assertion descriptions like above, but it's worth pointing out.

Single responsibility principle

I'll jump into some other examples of where multiple assertions may make sense in a bit, but want to discuss one more thing related to when this rule does make sense.

If you find yourself having to continually add assertions to your test case, it might be a sign your "unit under test" is doing too much and is violating the "Single responsibility principle" (SRP), that states "every class, module, or function in a program should have one responsibility/purpose in a program." This is a key sign that you might need to refactor your function, and is a good example of too many assertions being a code smell that you can use to reevaluate the code you're testing.

This code smell is a sign you should break that function down into multiple ones. You can keep the same test, but treat it more as an "integration" test, and include more "scoped" smaller unit tests after you've broken that into separate tests.

Now that we've covered the general intent of the rule/principle in question and shown some examples of bad tests that likely led to this becoming a rule, let's look at some other types of examples where it's more nuanced and where multiple assertions may make more sense.

REST API testing

One use case where you probably should use multiple assertions for your test case is if you are testing a REST API. Imagine you are testing an API endpoint and you want to test a successfull response is returned with expected data:

describe('GET /v1/accounts', () => {
  it('should return all the accounts', () => {
    return api.get('/v1/accounts')
      .expect(200)
      .expect('Content-Type', 'application/json')
      .expect(res => {
        expect(res.body.data).to.deep.equal({
          accounts: [
            {id: 1, name: 'John'}
            {id: 2, name: 'Jack'}
          ]
        })
      })
  })
})
Enter fullscreen mode Exit fullscreen mode

In this example, the case we are testing is that the endpoint returns all expected accounts. As part of that, it has assertions on a couple of the response headers, in addition to an assertion for the actual response body/accounts.

At first glance this appears to be in violation of the "one assertion per test" principle, so you might be inclined to rewrite the tests like so:

describe('GET /v1/accounts', () => {
  it('should return 200 OK', (done) => {
    api.get('/v1/accounts')
      .expect(200, done)
  })

  it('should return Content-Type application/json', (done) => {
    api.get('/v1/accounts')
      .expect('Content-Type', 'application/json', done)
  })

  it('should return accounts', (done) => {
    api.get('/v1/accounts')
      .expect(res => {
        expect(res.body.data).to.deep.equal({
          accounts: [
            {id: 1, name: 'John'}
            {id: 2, name: 'Jack'}
          ]
        })
      })
  })
})
Enter fullscreen mode Exit fullscreen mode

This gets us to one assertion per test, but does it make sense?

Firstly, you now have to execute the GET call multiple times, which adds additional test execution time / latency to your tests. Additionally, API tests by nature are closer to integration tests since their execution ends up hitting multiple parts of the code, so it's not implausible in these scenarios to need more than one assertion to test the output / result of what you are testing.
Also, those headers could be considered to be part of the response body, and thus part of a "singular" test case.
Lastly, test headers by themselves don't exactly constitute a "unit" to be tested - I'd rather test all the headers together with a case that tests for appropriate header responses. Sure, if one of those headers changes and it breaks the test, depending on your test runner the rest of the assertions may not run, but you will know right away which header change broke the test.

So in this scenario I think the first example test is acceptable rather than testing each individual header individually.

An alternative - which would still include multiple assertions per test - would be to test the response body in one test case and the headers (multiple assertions) in another test case, like so:

describe('GET /v1/accounts', () => {
  it('should return the appropriate headers', () => {
    return api.get('/v1/accounts')
      .expect(200)
      .expect('Content-Type', 'application/json')
  })

  it('should return all accounts', () => {
    return api.get('/v1/accounts')
      .expect(res => {
        expect(res.body.data).to.deep.equal({
          accounts: [
            {id: 1, name: 'John'}
            {id: 2, name: 'Jack'}
          ]
        })
      })
  })
})
Enter fullscreen mode Exit fullscreen mode

This is more of a "middle ground" and something you may want to consider when writing endpoint tests.

Object and property testing

This could be a blogpost by itself, but I want to briefly cover it because this is a scenario where you might need multiple asserts per test in order to verify the test case.

Imagine a simplified function that returns an object like:

const getUserPreferences = () => {
  // the below is hardcoded just for demonstration purposes
  return {
    preferences: {
      emailCommunication: false,
      phoneCommunication: false,
      smsCommunication: true
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

We can imagine this is some object related to notification preferences from an online service I signed up for.

The test for this could look like:

it('should return notification preferences', () => {
  const preferences = getUserPreferences()
  expect(preferences).to.deep.equal({
    preferences: {
      emailCommunication: false,
      phoneCommunication: false,
      smsCommunication: true
    }
  })
})
Enter fullscreen mode Exit fullscreen mode

That test will pass, with only one assertion, but what if we want to add another property to the returned object later on? Like physicalMailCommunication for example.

We could update the test like so:

it('should return notification preferences', () => {
  const preferences = getUserPreferences()
  expect(preferences).to.deep.equal({
    preferences: {
      emailCommunication: false,
      phoneCommunication: false,
      smsCommunication: true,
      physicalMailCommunication: true
    }
  })
})
Enter fullscreen mode Exit fullscreen mode

Or we could do something like this:

it('should return notification preferences', () => {
  const {emailCommunication, phoneCommunication, smsCommunication, physicalMailCommunication} = getUserPreferences()
  expect(emailCommunication).to.be.false
  expect(phoneCommunication).to.be.false
  expect(smsCommunication).to.be.true
  expect(physicalMailCommunication).to.be.true
})
Enter fullscreen mode Exit fullscreen mode

This is a case where having multiple assertions per test is valid, because you are testing properties of the object. Now, you might not need to check each property like this, and instead could just do a deep object euality check like the first test. If the test fails Mocha (and most test runners) will show you the properties of the object that don't match the expected values. But testing each property one by one may be valid if you have a function that returns an object where you don't care about testing all properties and only want/need to test a few. And in that case asserting on deep object equality won't work.

Multiple assertions in an integration test

Lastly, with integration tests you're likely to run into scenarios where you need to have multiple assertions for a given test case in order to test it properly.

An example of this is in some tests I wrote for a Redis Streams library, you can see the test linked here and a slightly simplified version below:

it('should subscribe to a consumer group and process the event', async () => {
  const workerFunction = () => {};
  await streamClient.subscribe({
    groupName, streamName, readTimeout: 20, workerFunction,
  });

  const eventPayload = {
    test: 'this is a subscribe test',
  };
  const streamClient2 = new EventStreamClient();
  await streamClient2.publish({ streamName, eventMeta: defaultEventMeta, eventPayload });

  // we can't spy on private functions (workerFunction is private w/in the closure in subscribe
  // but testing there are items, and they've been acked (not on PEL) works just as well
  const eventFromStream = await getRange(streamName, 1);
  const formatted = getFormatted(eventFromStream);
  expect(formatted[0]).to.include(`${PAYLOAD_PREFIX}-test`, 'this is a subscribe test');

  const pending = await getPending(streamName, groupName);
  expect(pending[0]).to.equal(0);
});
Enter fullscreen mode Exit fullscreen mode

The subscribe() function above subscribes to events from a stream, processes the received event and - if processing happens successfully - finally ACK's (acknowledges) it. This test case is testing a single concept - that it processes the event (and in order to do that, subscribes to the event stream) - but has two assertions.

This is because we need to first check the event, as part of test setup, is published to the stream. If that part fails, then anything related to the subscribe() function will fail and we want that to happen early because we need to fix that. This first assertion is also needed, because as noted in the code comment we can't spy on the workerFunction passed in to make sure it was called. So instead of having a separate assertion for that, like expect(workerFunction)to.have.been.called, we can make sure those events are there and then they are no longer in pending status, because only successfully processed items - the workerFunction does the processing - are ACK'd and removed from the pending status.

And that's what the second assertion checks.

As you work from unit to integration to end-to-end tests, this can become more common. That's because generally with integration tests you're testing a larger scope, and with end-to-end tests you're testing a larger scope than that. "Scope" meaning number of touchpoints. So it follows that you'll likely need more assertions to verify the behavior.

Summary

Hopefully you have a more nuanced understanding of the "multiple assertions rule" now, and can make a more informed decision on how you should write any given test for your application. I didn't want to provide anything prescriptive, because each application and context is different and rules/best practices become dogmas when this is not taken into account. Every single production application you've ever worked on for a company has done things slightly (if not totally) differently. That doesn't mean that just because it's different that its correct and you can ignore bad practices, but it's important to take into consideration.

The point is not that you have to or should use multiple assertions per test - you may not have any. But moreso that it's not a hard rule you should strictly adhere to. Context is everything.

If you find yourself having multiple assertions per test, consider the following:

  • Does having to add more assertions mean that the code being tested is doing too much? Does it need to be refactored to better handle a single responsibility?
  • Are you focusing on what the "output" of the unit under test is? Or are you testing extraneous and irrelevant things? This especially applies to "pure functions" that don't call a database, API, etc.
  • Are there multiple assertions because you are testing integration or end-to-end tests, that would be difficult to only assert on one output or action being performed?

If you consider the above and decide that multiple assertions makes sense, then buck the "rule" and go for it. And if you want to learn how to better structure your tests using Mocha, check out this other post on doing that.

Love JavaScript but still getting tripped up by unit testing, architecture, etc? I publish articles on JavaScript and Node every 1-2 weeks, so if you want to receive all new articles directly to your inbox, here's that link again to subscribe to my newsletter!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .