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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.