DEV Community

Matt Eland
Matt Eland Subscriber

Posted on • Edited on • Originally published at killalldefects.com

Refactoring C# Unit Tests

Unit tests are often treated like second class citizens and not given the same level of polish and refactoring as our production code. As a result, they can wind up brittle, unclear, and hard to maintain.

In this article, I'm going to show you a few tricks to keep your unit tests useful, maintainable, and relevant.

For this article, we'll be working with tests that test a fictitious resume processing application. We'll start with a single test, expand it, then refactor it to keep things usable.

Sample Unit Test

public class SpecialCaseTests
{
  [Fact]
  public void MattElandShouldScoreMaxValue()
  {
    // Arrange
    var resume = new ResumeInfo("Matt Eland");
    var job = new JobInfo("Software Engineering Manager", "Some Company", 42);
    resume.Jobs.Add(job);
    var provider = new KeywordScoringProvider();
    var analyzer = new ResumeAnalyzer(provider);

    // Act
    var result = analyzer.Analyze(resume);

    // Assert
    Assert.Equal(int.MaxValue, result.Score);
  }
}
Enter fullscreen mode Exit fullscreen mode

In this test we use the XUnit testing framework to run a single action against the system under test and then make an assert around it. Note that we follow an arrange, act, assert pattern to differentiate setup, execution, and verification.

Even with a simple test like this, there are some things that bug me.

Using Shouldly for Assertions

First of all, I hate the syntax for assertions. It doesn't read well and I often confuse which parameter is the expected value and which is the actual value.

Instead of:

Assert.Equal(int.MaxValue, result.Score);
Enter fullscreen mode Exit fullscreen mode

I prefer to install the Shouldly NuGet package which lets me write cleaner assertions. This lets us change the code to the following:

using Shouldly;

public class SpecialCaseTests
{
  [Fact]
  public void MattElandShouldScoreMaxValue()
  {
    // Arrange
    var resume = new ResumeInfo("Matt Eland");
    var job = new JobInfo("Software Engineering Manager", "Some Company", 42);
    resume.Jobs.Add(job);
    var provider = new KeywordScoringProvider();
    var analyzer = new ResumeAnalyzer(provider);

    // Act
    var result = analyzer.Analyze(resume);

    // Assert
    result.Score.ShouldBe(int.MaxValue);
  }
}
Enter fullscreen mode Exit fullscreen mode

Much easier to read, isn't it? There's a wide variety of methods available via Shouldy for equality, reference equality, and collection testing.

Note: Many people swear by the more popular FluentAssertions library, but I generally prefer Shouldly's more concise syntax.

Using Bogus to hide meaningless values

Looking at the prior test, it's not clear what values in the Arrange step are actually relevant to the test. In this particular test, the behavior under test is actually the rule that if the Resume is for "Matt Eland", the system should return a maximum score (hey, it's my sample application, I've got to have a little fun here).

The Bogus library can help with that by providing randomized values for the aspects of a test that are not relevant.

Bogus has a wide variety of random data generators from random numbers to names to zip codes and addresses to company names, business jargon, and hacker phrases.

Here's our test case using Bogus to hide the irrelevant:

using Bogus;
using Shouldly;

public class SpecialCaseTests
{
  [Fact]
  public void MattElandShouldScoreMaxValue()
  {
    // Arrange
    var resume = new ResumeInfo("Matt Eland");
    var faker = new Faker();
    string title = faker.Hacker.Phrase;
    string company = faker.Company.CompanyName;
    int monthsInJob = faker.Random.Int(1, 4200);
    var job = new JobInfo(title, company, monthsInJob);
    resume.Jobs.Add(job);
    var provider = new KeywordScoringProvider();
    var analyzer = new ResumeAnalyzer(provider);

    // Act
    var result = analyzer.Analyze(resume);

    // Assert
    result.Score.ShouldBe(int.MaxValue);
  }
}
Enter fullscreen mode Exit fullscreen mode

Extracting Methods to Hide Setup Details

Now that the meaningless values in our test have been hidden, the actual test is clearer, but the setup code is getting unruly. Let's extract a method for adding a random job.

using Bogus;
using Shouldly;

public class SpecialCaseTests
{
  private JobInfo CreateRandomJob() {
    var faker = new Faker();

    string title = faker.Hacker.Phrase;
    string company = faker.Company.CompanyName;
    int monthsInJob = faker.Random.Int(1, 4200);

    return new JobInfo(title, company, monthsInJob);
  }

  [Fact]
  public void MattElandShouldScoreMaxValue()
  {
    // Arrange
    var resume = new ResumeInfo("Matt Eland");
    resume.Jobs.Add(CreateRandomJob());
    var provider = new KeywordScoringProvider();
    var analyzer = new ResumeAnalyzer(provider);

    // Act
    var result = analyzer.Analyze(resume);

    // Assert
    result.Score.ShouldBe(int.MaxValue);
  }
}
Enter fullscreen mode Exit fullscreen mode

That's much more clear! While we're at it, we can extract a method for creating a resume analyzer and analyzing the resume.

using Bogus;
using Shouldly;

public class SpecialCaseTests
{
  private JobInfo CreateRandomJob() {
    var faker = new Faker();

    string title = faker.Name.JobTitle;
    string company = faker.Company.CompanyName;
    int monthsInJob = faker.Random.Int(1, 4200);

    return new JobInfo(title, company, monthsInJob);
  }

  private AnalyzerResult Analyze(ResumeInfo resume) {
    var provider = new KeywordScoringProvider();
    var analyzer = new ResumeAnalyzer(provider);

    return analyzer.Analyze(resume);
  }

  [Fact]
  public void MattElandShouldScoreMaxValue()
  {
    // Arrange
    var resume = new ResumeInfo("Matt Eland");
    resume.Jobs.Add(CreateRandomJob());

    // Act
    var result = Analyze(resume);

    // Assert
    result.Score.ShouldBe(int.MaxValue);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we're down to a very concise and readable test method. As an added bonus, if we change the signature of Analyzer or want to use a different provider for tests, we can substitute it in one method instead of having to maintain it in each individual test case.

It's almost time to expand our tests, but before we do that, let's introduce a base class that other test classes can inherit from.

Extracting Abstract Classes to Improve Tests

We can increase the visibility of our two private methods and pull them into a new abstract class called ResumeTestsBase, then have SpecialCaseTests inherit from it.

Here's our base class:

using Bogus;

public abstract class ResumeTestsBase
{

  private Faker _faker;
  protected Faker Faker => _faker ?? _faker = new Faker();

  protected JobInfo CreateRandomJob(int monthsInJob = -1) {
    string title = Faker.Name.JobTitle;
    string company = Faker.Company.CompanyName;

    // Ensure we have a valid months in job if not specified
    if (monthsInJob <= 0) {
      monthsInJob = Faker.Random.Int(1, 4200);
    }

    return new JobInfo(title, company, monthsInJob);
  }

  protected AnalyzerResult Analyze(ResumeInfo resume) {
    var provider = new KeywordScoringProvider();
    var analyzer = new ResumeAnalyzer(provider);

    return analyzer.Analyze(resume);
  }
}
Enter fullscreen mode Exit fullscreen mode

Note that we moved Faker to a lazily instantiated property so we can reuse it in other methods in the future. We also made the CreateMonthsInJob method parameterized to help a future test.

This base class allows us to have a very minimal and focused test class:

using Shouldly;

public class SpecialCaseTests : ResumeTestsBase
{
  [Fact]
  public void MattElandShouldScoreMaxValue()
  {
    // Arrange
    var resume = new ResumeInfo("Matt Eland");
    resume.Jobs.Add(CreateRandomJob());

    // Act
    var result = Analyze(resume);

    // Assert
    result.Score.ShouldBe(int.MaxValue);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we can add in a new class to test new aspects of the system under test.

using Shouldly;

public class MonthsInJobTests : ResumeTestsBase
{
  [Fact]
  public void FiveMonthsInJobShouldScoreAFive()
  {
    // Arrange
    var resume = new ResumeInfo(Faker.Name.FullName);
    resume.Jobs.Add(CreateRandomJob(5));

    // Act
    var result = Analyze(resume);

    // Assert
    result.Score.ShouldBe(5);
  }
}
Enter fullscreen mode Exit fullscreen mode

Okay, that's fine, but we should test more than just one value. Scaling it up begins to present problems:

using Shouldly;

public class MonthsInJobTests : ResumeTestsBase
{
  [Fact]
  public void FiveMonthsInJobShouldScoreAFive()
  {
    // Arrange
    var resume = new ResumeInfo(Faker.Name.FullName);
    resume.Jobs.Add(CreateRandomJob(5));

    // Act
    var result = Analyze(resume);

    // Assert
    result.Score.ShouldBe(5);
  }

  [Fact]
  public void OneMonthInJobShouldScoreAOne()
  {
    // Arrange
    var resume = new ResumeInfo(Faker.Name.FullName);
    resume.Jobs.Add(CreateRandomJob(1));

    // Act
    var result = Analyze(resume);

    // Assert
    result.Score.ShouldBe(1);
  }

}
Enter fullscreen mode Exit fullscreen mode

The duplication factor is starting to present itself again. Thankfully all modern .NET testing frameworks support parameterized tests. In XUnit this is called a Theory and it looks like this:

using Shouldly;

public class MonthsInJobTests : ResumeTestsBase
{
  [Theory]
  [InlineData(1)]
  [InlineData(5)]
  [InlineData(10)]
  [InlineData(900)]
  public void ResumesShouldScoreForTotalMonthsInJob(int monthsInJob)
  {
    // Arrange
    var resume = new ResumeInfo(Faker.Name.FullName);
    resume.Jobs.Add(CreateRandomJob(monthsInJob));

    // Act
    var result = Analyze(resume);

    // Assert
    result.Score.ShouldBe(monthsInJob);
  }
}
Enter fullscreen mode Exit fullscreen mode

Using Theory tests we have a four clearer tests using one method in fewer lines of code than two tests using the Fact attribute.

The test runner will see this test case as four separate tests and run each individually, passing in the InlineData to the parameters for the method.


These are just some basics on creating clear, concise, and maintainable unit tests. There are many other libraries and techniques out there, but these basic techniques will help you build a solid test suite that shines in simplicity, utility, and maintainability.

Top comments (4)

Collapse
 
telexen profile image
Jake C

Some great stuff here. I always appreciate using plain private functions to simplify new test data each test over a single shared setup.

Also check out AutoFixture. I don't know if Bogus has this feature, but it can also generate data as theory parameters to help avoid unimportant clutter in the test.

Collapse
 
integerman profile image
Matt Eland

Yeah, I'm going to write a separate article on AutoFixture. It's like Bogus on Wheaties!

Collapse
 
erikwhiting88 profile image
Erik

Excellent write up, thank you for sharing

Collapse
 
integerman profile image
Matt Eland

My pleasure. Much of it was ported from a recent talk I did on .Net unit testing libraries. The slides are available at slideshare.net/mobile/MattEland/ho... and I really recommend checking out my dev.to article on Scientist .NET as well.