How to write good unit tests? Have a failing test

How to write good unit tests: Write failing tests first

A passing test isn’t always the only thing to look for. Seeing your test fail is important too. I learned this lesson the hard way. Let’s see why we should start writing failing tests.

To write reliable unit tests, always start writing a failing test. And, make sure it fails for the right reasons. Follow the Red, Green, Refactor principle of Test-Driven Development (TDD). Write a failing test, make it pass and refactor the code. Don’t skip the failing test part.

The Passing test

Let’s continue with the same example from our previous post on how to write good unit tests.

From our last example, we had a controller to create, update and suspend user accounts. Inside its constructor, this controller validated some email addresses from an injected configuration object.

After we refactored our test from the last post, we ended up with this:

[TestMethod]
public void AccountController_SenderEmailIsNull_ThrowsException()
{
    var emailConfig = Options.Create(new EmailConfiguration
    {
        SenderEmail = null,
        ReplyToEmail = "email@email.com",
        SupportEmail = "email@email.com"
    });

    Assert.ThrowsException<ArgumentNullException>(() =>
        MakeAccountController(emailConfig));
}

private AccountController MakeAccountController(IOptions<EmailConfiguration> emailConfiguration)
{
    var mapper = new Mock<IMapper>();
    var logger = new Mock<ILogger<AccountController>>();
    var accountService = new Mock<IAccountService>();
    var accountPersonService = new Mock<IAccountPersonService>();
    var emailService = new Mock<IEmailService>();
    var emailConfig = new Mock<IOptions<EmailConfiguration>>();
    var httpContextAccessor = new Mock<IHttpContextAccessor>();

    return new AccountController(
            mapper.Object,
            logger.Object,
            accountService.Object,
            accountPersonService.Object,
            emailService.Object,
            emailConfiguration,
            httpContextAccessor.Object);
}
Always start writing a failing test
Always start writing a failing test. Photo by Neora Aylon on Unsplash

A false positive

This time, I had a new requirement. I needed to add a new method to our AccountController. This new method read another configuration object injected into the controller.

To follow the convention of validating required parameters inside constructors, I also checked for this new configuration object. I wrote a new test and a new MakeAccountController() builder method to call the constructor with only the parameters I needed.

[TestMethod]
public void AccountController_NoNewConfig_ThrowsException()
{
    var options = Options.Create<SomeNewConfig>(null);

    Assert.ThrowsException<ArgumentNullException>(() =>
        MakeAccountController(options));
}

// A new builder method
private AccountController MakeAccountController(IOptions<SomeNewConfig> someNewConfig)
{
    var emailConfig = new Mock<IOptions<EmailConfiguration>());
    return CreateAccountController(emailConfig.Object, someNewConfig);
}

private AccountController MakeAccountController(IOptions<EmailConfiguration> emailConfig, IOptions<SomeNewConfig> someNewConfig)
{
    // It calls the constructor with mocks, except for emailConfig and someNewConfig
}

And, the constructor looked like this:

public class AccountController : Controller
{
  public AccountController(
      IMapper mapper,
      ILogger<AccountController> logger,
      IAccountService accountService,
      IAccountPersonService accountPersonService,
      IEmailService emailService,
      IOptions<EmailConfiguration> emailConfig,
      IHttpContextAccessor httpContextAccessor,
      IOptions<SomeNewConfig> someNewConfig)
  {
      var emailConfiguration = emailConfig?.Value ?? throw new ArgumentNullException($"EmailConfiguration");
      if (string.IsNullOrEmpty(emailConfiguration.SenderEmail))
      {
          throw new ArgumentNullException($"SenderEmail");
      }

      var someNewConfiguration = someNewConfig?.Value ?? throw new ArgumentNullException($"SomeNewConfig");
      if (string.IsNullOrEmpty(someNewConfiguration.SomeKey)
      {
          throw new ArgumentNullException($"SomeKey");
      }

      // etc...
  }
}

I ran the test and it passed. Move on! But…Wait! There’s something wrong with that test! Did you spot the issue?

Make your tests fail

Of course, that test is passing. The code throws an ArgumentNullException. But, that exception is coming from the wrong place. It comes from the validation for the email configuration, not from our new validation.

I forgot to use a valid email configuration in the new MakeAccountController() builder method. I used a mock reference without setting up any values. I only realized that after getting my code reviewed. Point for the code review!

private AccountController MakeAccountController(IOptions<SomeNewConfig> someNewConfig)
{
    var emailConfig = new Mock<IOptions<EmailConfiguration>());
    // Here we need to setup a valid EmailConfiguration
    return CreateAccountController(emailConfig.Object, someNewConfig);
}

Make sure to always start writing a failing test. And, this test should fail for the right reasons.

If you write your tests after writing your production code, comment some parts of your production code to see your tests failing. Or change the assertions on purpose.

When you make a failed test pass, you’re testing the test. You’re making sure it fails and passes when it should. You know you aren’t writing buggy tests or introducing false positives into your test suite.

A better test for our example would check the exception message. Like this:

[TestMethod]
public void AccountController_NoSomeNewConfig_ThrowsException()
{
    var options = Options.Create<SomeNewConfig>(null);

    var ex = Assert.ThrowsException<ArgumentNullException>(() => 
        MakeAccountController(options));
    StringAssert.Contains(ex.Message, nameof(SomeNewConfig));
}

Voilà! This task reminded me to always see my tests failing for the right reasons. Do you have passing tests? Do they pass and fail when they should? I hope they do after reading this post.

For more tips on unit testing, check my takeaways from the book The Art of Unit Testing and my post on how to write fakes with Moq.

Happy unit testing!