Make Assertions, Not Assumptions


This entry is part [part not set] of 3 in the series Unit Testing

The key to writing a good unit test is to make assertions, not assumptions. Don’t assume that the function will handle any and all arguments gracefully. Or that another dev would never call the function for a purpose other than what you’ve intended. Don’t assume that another dev would never try to add to or take away from the function’s code in such a way as to change its primary purpose.

In fact, rather than making assumptions, our job is to make assertions. Assert that the function will handle any unexpected argument, e.g. a null object, a negative integer, an empty string, etc. Assert that the function returns what you expect it to return. Ensure that any exceptions are thrown as expected. If we assume nothing and assert everything, we can trust the integrity of the function. And if we cover all of our business logic with competent unit tests, and continue to run all unit tests as part of our deployment pipeline, we can trust the integrity of the application as a whole.

In this final part of the series we’re going to walk through writing the unit tests for the CastVote function of our voting application. At the end of the previous part of the series, we listed all the scenarios that can and should be tested. Let’s start with the first one, which is that the function should throw an exception if the citizen cannot be found.

Inside our test class let’s add a public function called CastVote_NoCitizen_Throws. It doesn’t need any parameters and doesn’t need to return a value. What it does need is the [Fact] attribute which identifies the function as a unit test for the xUnit framework’s test runner.

Arrange

I always start out a test function by adding a code comment for each of the three A’s from the AAA pattern: Arrange, Act, and Assert. What do we need to arrange for this unit test? If you remember, we already created a fake repository and configured all of its method calls to return the “happy path” results. So we really only need to override the configuration for one particular repository method in order to test this scenario.

// Arrange
A.CallTo(() => _repo.GetCitizen(CitizenId)).Returns(null);

Act

The next step is to Act; in other words, call the function. Since the first 7 scenarios that we’re testing are all expected to throw exceptions, we’ll need a way to “catch” the exception so that we can make assertions about it. Luckily for us, xUnit provides a way to do just that.

// Act
var exception = Record.Exception(() => _service.CastVote(CitizenId, BallotItemId, ValidOption, null));

Assert

The last step is to Assert. And remember — assume nothing, assert everything! What all do we need to assert in this test? Well first, we need to assert that it actually threw a RecordNotFoundException with a message of “Could not find that citizen”. xUnit provides a class called Assert with several methods that can be used to make assertions. We’ll use .NotNull(), .IsType<T>(), and .Equal().

// Assert
Assert.NotNull(exception);
Assert.IsType<RecordNotFoundException>(exception);
Assert.Equal("Could not find that citizen", exception.Message);

The next set of assertions all deal with whether or not a particular function was called. In this instance we know that GetCitizen should have been called, but all of the other functions would have happened after the exception was thrown, therefore they should not have been called. FakeItEasy’s A.CallTo() method allows for making these type of assertions.

A.CallTo(() => _repo.GetCitizen(A<int>._)).MustHaveHappenedOnceExactly();
A.CallTo(() => _repo.GetBallotItem(A<int>._)).MustNotHaveHappened();
A.CallTo(() => _repo.IsCitizenEligibleToVoteOnBallotItem(A<int>._, A<int>._)).MustNotHaveHappened();
A.CallTo(() => _repo.GetVote(A<int>._, A<int>._)).MustNotHaveHappened();
A.CallTo(() => _repo.AddVote(A<int>._, A<int>._, A<int>._, A<string>._)).MustNotHaveHappened();

Notice the use of the A<T>._ property (shorthand for A<T>.Ignored)? We tried to avoid that when arranging our faked objects. We wanted to configure things as tightly as possible. But sometimes when making assertions we want each one to cover as much ground as possible. Here’s why. Let’s say we used specific arguments when asserting that a function was never called. But then during the course of running the test, it was called, just with different arguments. The test would still pass, which is possibly not what we intended.

The full picture

Here’s the entire suite of unit tests for this function.

[Fact]
public void CastVote_NoCitizen_Throws()
{
    // Arrange
    A.CallTo(() => _repo.GetCitizen(CitizenId)).Returns(null);

    // Act 
    var exception = Record.Exception(() => _service.CastVote(CitizenId, BallotItemId, ValidOption, null));

    // Assert
    Assert.NotNull(exception);
    Assert.IsType<RecordNotFoundException>(exception);
    Assert.Equal("Could not find that citizen", exception.Message);
    A.CallTo(() => _repo.GetCitizen(A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.GetBallotItem(A<int>._)).MustNotHaveHappened();
    A.CallTo(() => _repo.IsCitizenEligibleToVoteOnBallotItem(A<int>._, A<int>._)).MustNotHaveHappened();
    A.CallTo(() => _repo.GetVote(A<int>._, A<int>._)).MustNotHaveHappened();
    A.CallTo(() => _repo.AddVote(A<int>._, A<int>._, A<int>._, A<string>._)).MustNotHaveHappened();
}

[Fact]
public void CastVote_NoBallotItem_Throws()
{
    // Arrange
    A.CallTo(() => _repo.GetBallotItem(BallotItemId)).Returns(null);

    // Act
    var exception = Record.Exception(() => _service.CastVote(CitizenId, BallotItemId, ValidOption, null));

    // Assert
    Assert.NotNull(exception);
    Assert.IsType<RecordNotFoundException>(exception);
    Assert.Equal("Could not find that ballot item", exception.Message);
    A.CallTo(() => _repo.GetCitizen(A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.GetBallotItem(A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.IsCitizenEligibleToVoteOnBallotItem(A<int>._, A<int>._)).MustNotHaveHappened();
    A.CallTo(() => _repo.GetVote(A<int>._, A<int>._)).MustNotHaveHappened();
    A.CallTo(() => _repo.AddVote(A<int>._, A<int>._, A<int>._, A<string>._)).MustNotHaveHappened();
}

[Fact]
public void CastVote_NotEligible_Throws()
{
    // Arrange
    A.CallTo(() => _repo.IsCitizenEligibleToVoteOnBallotItem(CitizenId, BallotItemId)).Returns(false);

    // Act
    var exception = Record.Exception(() => _service.CastVote(CitizenId, BallotItemId, ValidOption, null));

    // Assert
    Assert.NotNull(exception);
    Assert.IsType<IneligibleVoteException>(exception);
    A.CallTo(() => _repo.GetCitizen(A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.GetBallotItem(A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.IsCitizenEligibleToVoteOnBallotItem(A<int>._, A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.GetVote(A<int>._, A<int>._)).MustNotHaveHappened();
    A.CallTo(() => _repo.AddVote(A<int>._, A<int>._, A<int>._, A<string>._)).MustNotHaveHappened();
}

[Fact]
public void CastVote_VoteExists_Throws()
{
    // Arrange
    A.CallTo(() => _repo.GetVote(CitizenId, BallotItemId)).Returns(Vote);

    // Act
    var exception = Record.Exception(() => _service.CastVote(CitizenId, BallotItemId, ValidOption, null));

    // Assert
    Assert.NotNull(exception);
    Assert.IsType<AlreadyVotedException>(exception);
    A.CallTo(() => _repo.GetCitizen(A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.GetBallotItem(A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.IsCitizenEligibleToVoteOnBallotItem(A<int>._, A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.GetVote(A<int>._, A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.AddVote(A<int>._, A<int>._, A<int>._, A<string>._)).MustNotHaveHappened();
}

[Fact]
public void CastVote_WriteInNotAllowed_Throws()
{
    // Act
    var exception = Record.Exception(() => _service.CastVote(CitizenId, BallotItemId, WriteInOption, WriteInValue));

    // Assert
    Assert.NotNull(exception);
    Assert.IsType<InvalidVoteException>(exception);
    Assert.Equal("The write-in option is not available for that ballot item", exception.Message);
    A.CallTo(() => _repo.GetCitizen(A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.GetBallotItem(A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.IsCitizenEligibleToVoteOnBallotItem(A<int>._, A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.GetVote(A<int>._, A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.AddVote(A<int>._, A<int>._, A<int>._, A<string>._)).MustNotHaveHappened();
}

[Fact]
public void CastVote_WriteInNotProvided_Throws()
{
    // Arrange
    A.CallTo(() => _repo.GetBallotItem(BallotItemId)).Returns(BallotItemWithWriteIn);

    // Act
    var exception = Record.Exception(() => _service.CastVote(CitizenId, BallotItemId, WriteInOption, null));

    // Assert
    Assert.NotNull(exception);
    Assert.IsType<InvalidVoteException>(exception);
    Assert.Equal("The write-in value cannot be null or empty", exception.Message);
    A.CallTo(() => _repo.GetCitizen(A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.GetBallotItem(A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.IsCitizenEligibleToVoteOnBallotItem(A<int>._, A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.GetVote(A<int>._, A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.AddVote(A<int>._, A<int>._, A<int>._, A<string>._)).MustNotHaveHappened();
}

[Fact]
public void CastVote_InvalidOption_Throws()
{
    // Act
    var exception = Record.Exception(() => _service.CastVote(CitizenId, BallotItemId, InvalidOption, null));

    // Assert
    Assert.NotNull(exception);
    Assert.IsType<InvalidVoteException>(exception);
    Assert.Equal("That option is not valid", exception.Message);
    A.CallTo(() => _repo.GetCitizen(A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.GetBallotItem(A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.IsCitizenEligibleToVoteOnBallotItem(A<int>._, A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.GetVote(A<int>._, A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.AddVote(A<int>._, A<int>._, A<int>._, A<string>._)).MustNotHaveHappened();
}

[Fact]
public void CastVote_Works()
{
    // Act
    var actualResult = _service.CastVote(CitizenId, BallotItemId, ValidOption, null);

    // Assert
    Assert.NotNull(actualResult);
    Assert.IsType<VoteConfirmation>(actualResult);
    A.CallTo(() => _repo.GetCitizen(A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.GetBallotItem(A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.IsCitizenEligibleToVoteOnBallotItem(A<int>._, A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.GetVote(A<int>._, A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.AddVote(A<int>._, A<int>._, A<int>._, A<string>._)).MustHaveHappenedOnceExactly();
}

[Fact]
public void CastVote_WriteIn_Works()
{
    // Arrange 
    A.CallTo(() => _repo.GetBallotItem(BallotItemId)).Returns(BallotItemWithWriteIn);

    // Act
    var actualResult = _service.CastVote(CitizenId, BallotItemId, WriteInOption, WriteInValue);

    // Assert
    Assert.NotNull(actualResult);
    Assert.IsType<VoteConfirmation>(actualResult);
    A.CallTo(() => _repo.GetCitizen(A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.GetBallotItem(A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.IsCitizenEligibleToVoteOnBallotItem(A<int>._, A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.GetVote(A<int>._, A<int>._)).MustHaveHappenedOnceExactly();
    A.CallTo(() => _repo.AddVote(A<int>._, A<int>._, A<int>._, A<string>._)).MustHaveHappenedOnceExactly();
}

And there you have it. I hope you now have a better understanding of how to write unit tests. But more importantly, why you should write them.

Series Navigation

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.