DEV Community

Cover image for Let's refactor a test: AccountController
Cesar Aguirre
Cesar Aguirre

Posted on

Let's refactor a test: AccountController

I originally posted this post on my blog. It's part of the Unit Testing 101 series. I updated it for accuracy and comprehensiveness.

Let's refactor a test to make it more readable. This test is based on a real test I had to modify in one of my past client's projects.

The test was for an ASP.NET Core API controller: AccountController. This controller created, updated, and suspended user accounts.

This test checks that a configuration object has the sender, reply-to, and contact-us email addresses sent in a welcome email. If a configuration file doesn't have one of those email addresses, the controller throws an ArgumentNullException from its constructor.

This is the test. Take a moment to see what's wrong.

[TestMethod]
public Task AccountController_SenderEmailIsNull_ThrowsException()
{
    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>();

    emailConfig.SetupGet(options => options.Value)
        .Returns(new EmailConfiguration()
        {
            ReplyToEmail = "email@email.com",
            SupportEmail = "email@email.com"
        });

    Assert.ThrowsException<ArgumentNullException>(() =>
        new AccountController(
            mapper.Object,
            logger.Object,
            accountService.Object,
            accountPersonService.Object,
            emailService.Object,
            emailConfig.Object,
            httpContextAccessor.Object
        ));

    return Task.CompletedTask;
}
Enter fullscreen mode Exit fullscreen mode

Three...two...one. Let's go.

0. Make it sync

Well, there's no async code inside the body of that test. Take a look again. But, it has a return statement, and its return type is a Task. We don't need that.

That looked weird in a unit test in the first place.

[TestMethod]
public void AccountController_SenderEmailIsNull_ThrowsException()
//     👆👆
{
    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>();

    emailConfig.SetupGet(options => options.Value)
        .Returns(new EmailConfiguration()
        {
            ReplyToEmail = "email@email.com",
            SupportEmail = "email@email.com"
        });

    Assert.ThrowsException<ArgumentNullException>(() =>
        new AccountController(
            mapper.Object,
            logger.Object,
            accountService.Object,
            accountPersonService.Object,
            emailService.Object,
            emailConfig.Object,
            httpContextAccessor.Object
        ));

    // return Task.CompletedTask; 👈👈👈
}
Enter fullscreen mode Exit fullscreen mode

1. Use builder methods to reduce the noise

If we take a closer look, our test only cares about one object: IOptions<EmailConfiguration>. All other objects are "noise" for our test. They don't have anything to do with the scenario under test.

Let's reduce the noise with a builder method: MakeAccountController(). It will receive the only parameter the test needs: IOptions<EmailConfiguration>.

After this change, our test looks like this:

[TestMethod]
public void AccountController_SenderEmailIsNull_ThrowsException()
{
    var emailConfig = new Mock<IOptions<EmailConfiguration>>();
    emailConfig
        .SetupGet(options => options.Value)
        .Returns(new EmailConfiguration
        {
            ReplyToEmail = "email@email.com",
            SupportEmail = "email@email.com"
        });

    Assert.ThrowsException<ArgumentNullException>(() =>
        MakeController(emailConfig.Object));
        // 👆👆👆
        // We hid all the noise inside a builder
}

private static AccountController MakeController(IOptions<EmailConfiguration> emailConfig)
{
    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>();
    // We don't need Mock<IOptions<EmailConfiguration>> here...
    var httpContextAccessor = new Mock<IHttpContextAccessor>();

    return new AccountController(
            mapper.Object,
            logger.Object,
            accountService.Object,
            accountPersonService.Object,
            emailService.Object,
            emailConfig,
            // 👆👆👆
            httpContextAccessor.Object);
}
Enter fullscreen mode Exit fullscreen mode

After this step, our test started to look more compact and easier to read. Now it's clear this test only cares about the EmailConfiguration class.

2. Make test values obvious

Now, let's take a look at the test name. It says "sender email is null." If you're like me, when you read this test, you expect to see a variable or property SenderEmail set to null somewhere. But that's not the case.

Let's make the test scenario obvious, really obvious,

[TestMethod]
public void AccountController_SenderEmailIsNull_ThrowsException()
//                            👆👆👆👆👆
{
    var emailConfig = new Mock<IOptions<EmailConfiguration>>();
    emailConfig
        .SetupGet(options => options.Value)
        .Returns(new EmailConfiguration
        {
            SenderEmail = null,
            // 👆👆👆
            // The test value is obvious now
            ReplyToEmail = "email@email.com",
            SupportEmail = "email@email.com"
        });

    Assert.ThrowsException<ArgumentNullException>(() =>
        MakeController(emailConfig.Object));
}
Enter fullscreen mode Exit fullscreen mode

After this step, when we find this test for the first time, we read "sender email is null" on the name and SenderEmail = null on the test body.

It's obvious. We don't have to guess where SenderEmail is set to null. We don't have to decode our test.

3. No more noise: Don't write mocks for IOptions

Finally, to keep reducing the noise, we don't need a mock on IOptions<EmailConfiguration>.

Let's stop using a fake object with the IOptions interface. That would introduce extra complexity or noise to keep following the analogy.

Let's use the Option.Create() method instead,

[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>(() =>
        MakeController(emailConfig));
}
Enter fullscreen mode Exit fullscreen mode

Voilà! That's way easier to read. Let's remember readability is one of the pillars of unit testing. To write good unit tests, let's avoid complex setup scenarios and hidden test values. Often, our tests are bloated with unneeded or complex code in the Arrange part and full of hidden test values.

Unit tests should be even more readable than production code. Don't make developers decode your tests.


Hey, there! I'm Cesar, a software engineer and lifelong learner. To support my work and upgrade your unit testing skills, check my course: Mastering C# Unit Testing with Real-world Examples on my Gumroad page. Practice with hands-on exercises and learn best practices by refactoring real-world unit tests.

🚀 Master C# Unit Testing with Real-world Examples

Happy testing!

Top comments (0)