Refactoring C# Unit Tests
Matt Eland
Posted on August 31, 2019
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);
}
}
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);
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);
}
}
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);
}
}
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);
}
}
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);
}
}
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);
}
}
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);
}
}
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);
}
}
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);
}
}
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);
}
}
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.
Posted on August 31, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.