Question

First of all I know it is bad practice to have multiple assertions in unit test.

But sometimes you need to test some atomic transaction. As a simplified example, let's take some banking application that has the Account class:

class Account 
  attr_accessor :balance

  def transfer(to_account, amount)
    self.balance -= amount
    to_account.balance += amount
    Audit.create(message: "Transferred #{amount} from #{self.number} to #{to_account.number}."
  end

end

In this situation I want to check 3 things together:

  1. Source account balance decreased by amount
  2. Destination account balance increased by amount
  3. Audit record is inserted

What is the best way to test the @account.transfer method?

Was it helpful?

Solution

In this situation I want to check 3 things together:

I'd argue that what you really want is to describe the behavior of these things under certain conditions, and thus ensure that the behavior meets your specifications. That might mean things happen together; or it might mean that some things only happen in one set of conditions and not others, or that an exception causes everything to be rolled back to its original state.

There is no magic to having all your assertions in one test, except to make things faster. Unless you are facing a severe performance penalty (as often happens in full-stack tests), it is much better to use one assertion per test.

RSpec makes it straightforward to extract the test setup phase so that it is repeated for each example:

class Account 
  attr_accessor :balance

  def transfer(to_account, amount)
    self.debit!(amount)
    to_account.credit!(amount)
    Audit.create!(message: "Transferred #{amount} from #{self.number} to #{to_account.number}."
  rescue SomethingBadError
    # undo all of our hard work
  end

end

describe Account do
  context "when a transfer is made to another account" do
    let(:other_account} { other_account }
    context "and the subject account has sufficient funds" do
      subject { account_with_beaucoup_bucks }
      it "debits the subject account"
      it "credits the other account"
      it "creates an Audit entry"
    end
    context "and the subject account is overdrawn" do
      subject { overdrawn_account }
      it "does not debit the subject account"
      it "does not credit the other account"
      it "creates an Audit entry" # to show the attempted transfer failed
    end
  end
end

If all three tests in the "happy path" passed, then they all "happened together", since the initial system state was the same in each case.

But you also need to ensure that things don't happen when something goes wrong, and that the system goes back to its original state. Having multiple assertions makes it easy to see that this works as expected, and when tests fail, exactly how they failed.

OTHER TIPS

Multiple assertions per test is not always a bad practice. If the multiple asserts verify the same behaviour, there's no problem with it. The problem exists when trying to verify more than one behaviour in the same test. Of course there are some risks with multiple asserts per test. One of them is you may accidentally leave values from a previous test set that invalidates a previous test in a weird way. Also, when one assert is false, all the other left will not be executed, which can cause difficulties to understand what's goin on. But be reasonable, you can have multiple asserts asserting the same behaviour, preferrably short ones and with no extra setup.

In the simple case you brought, I would use multiple asserts, because it is so simple. But of course it can get a lot more complicated, like negative balance, different types of accounts and stuff. Then it would be better to use different tests with one (preferrably) assert. I would organize it like this:

  • 1 to test the behaviour of the current Account (simplest case);
  • 1 to every different path the method can have (exceptions, negative balance etc.);
  • 1 to test Audit in every of these possibilities;

  • 1 to test the behaviour of the current to_account (simplest case);

  • 1 to every different path the method can have. (exceptions, negative balance etc.) ;
  • 1 to test Audit in every of these possibilities;

Since the Audit test is pretty simple and requires no extra setup, you can also test it along with Account and to_account.

Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top