I originally posted an extended version of this post on my blog a long time ago in a galaxy far, far away.
Recently, I found an NDC Conference talk titled ".NET Testing Best Practices" by Rob Richardson.
Today I want to share five unit testing best practices I learned from that talk.
By the way, here's the YouTube video of the talk and the speaker's website, in case you want to take a look.
During the presentation, the speaker coded some unit tests for the LightActuator
class. This class powers an IoT device that turns the light switch on or off based on a motion sensor input.
The LightActuator
turns on lights if any motion is detected in the evening or at night. And, it turns off lights in the morning or if no motion has been detected in the last minute.
Here's the LightActuator
class, Source
public class LightActuator : ILightActuator
{
private DateTime LastMotionTime { get; set; }
public void ActuateLights(bool motionDetected)
{
DateTime time = DateTime.Now;
// Update the time of last motion.
if (motionDetected)
{
LastMotionTime = time;
}
// If motion was detected in the evening or at night, turn the light on.
string timePeriod = GetTimePeriod(time);
if (motionDetected
&& (timePeriod == "Evening" || timePeriod == "Night"))
{
LightSwitcher.Instance.TurnOn();
}
// If no motion is detected for one minute, or if it is morning or day, turn the light off.
else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1)
|| (timePeriod == "Morning" || timePeriod == "Noon"))
{
LightSwitcher.Instance.TurnOff();
}
}
private string GetTimePeriod(DateTime dateTime)
{
if (dateTime.Hour >= 0 && dateTime.Hour < 6)
{
return "Night";
}
if (dateTime.Hour >= 6 && dateTime.Hour < 12)
{
return "Morning";
}
if (dateTime.Hour >= 12 && dateTime.Hour < 18)
{
return "Afternoon";
}
return "Evening";
}
}
And here's the first unit test the presenter live-coded,
public class LightActuator_ActuateLights_Tests
{
[Fact]
public void MotionDetected_LastMotionTimeChanged()
{
// Arrange
bool motionDetected = true;
DateTime startTime = new DateTime(2000, 1, 1); // random value
// Act
LightActuator actuator = new LightActuator();
actuator.LastMotionTime = startTime;
actuator.ActuateLights(motionDetected);
DateTime actualTime = actuator.LastMotionTime;
// Assert
Assert.NotEqual(actualTime, startTime);
}
}
Of course, the presenter refactored this test and introduced more examples throughout the rest of the talk. But this initial test is enough to prove our points.
Let's go...
1. Adopt a new naming convention
In this talk, I found a new naming convention for our unit tests.
To name test classes, we use <ClassName>_<MethodName>_Tests
.
For test methods, we use <Scenario>_<ExpectedResult>
.
Here are the test class and method names for our sample test,
public class LightActuator_ActuateLights_Tests
// πππ
{
[Fact]
public void MotionDetected_LastMotionTimeChanged()
// πππ
{
// Beep, beep, boop...π€
// Magic goes here πͺ
}
}
2. Label your test parameters
Instead of simply calling the method under test with a list of parameters, let's label them for more clarity.
For example, instead of simply calling ActuateLights()
with true
, let's create a documenting variable,
[Fact]
public void MotionDetected_LastMotionTimeChanged()
{
bool motionDetected = true;
DateTime startTime = new DateTime(2000, 1, 1);
LightActuator actuator = new LightActuator();
actuator.LastMotionTime = startTime;
// Before
//actuator.ActuateLights(true);
// ππ
// After
actuator.ActuateLights(motionDetected);
// πππ
DateTime actualTime = actuator.LastMotionTime;
// Beep, beep, boop...π€
}
3. Use human-friendly assertions
Looking closely at the sample test, we notice the Assert part has a bug.
The actual and expected values inside NotEqual()
are in the wrong order. The expected value should go first. Arrrggg!
[Fact]
public void MotionDetected_LastMotionTimeChanged()
{
bool motionDetected = true;
DateTime startTime = new DateTime(2000, 1, 1);
LightActuator actuator = new LightActuator();
actuator.LastMotionTime = startTime;
actuator.ActuateLights(motionDetected);
DateTime actualTime = actuator.LastMotionTime;
Assert.NotEqual(actualTime, startTime);
// πππ
// They're in the wrong order. Arrrggg!π¬
}
To avoid flipping them again, it's a good idea to use more human-friendly assertions using libraries like FluentAssertions or Shouldly.
Here's our test using FluentAssertions,
[Fact]
public void MotionDetected_LastMotionTimeChanged()
{
bool motionDetected = true;
DateTime startTime = new DateTime(2000, 1, 1);
LightActuator actuator = new LightActuator();
actuator.LastMotionTime = startTime;
actuator.ActuateLights(motionDetected);
DateTime actualTime = actuator.LastMotionTime;
// Before
//Assert.NotEqual(actualTime, startTime);
// πππ
// They're in the wrong order. Arrrggg!π¬
//
// After, with FluentAssertions
actualTime.Should().NotBe(startTime);
// πππ
}
4. Don't be too DRY
Our sample test only covers the scenario when any motion is detected. If we write another test for the scenario with no motion detected, our tests look like this,
public class LightActuator_ActuateLights_Tests
{
[Fact]
public void MotionDetected_LastMotionTimeChanged()
{
bool motionDetected = true;
// ππ
DateTime startTime = new DateTime(2000, 1, 1);
LightActuator actuator = new LightActuator();
actuator.LastMotionTime = startTime;
actuator.ActuateLights(motionDetected);
DateTime actualTime = actuator.LastMotionTime;
Assert.NotEqual(startTime, actualTime);
// πππ
}
[Fact]
public void NoMotionDetected_LastMotionTimeIsNotChanged()
{
bool motionDetected = false;
// ππ
DateTime startTime = new DateTime(2000, 1, 1);
LightActuator actuator = new LightActuator();
actuator.LastMotionTime = startTime;
actuator.ActuateLights(motionDetected);
DateTime actualTime = actuator.LastMotionTime;
Assert.Equal(startTime, actualTime);
// ππ
}
}
The only difference between the two is the value of motionDetected
and the assertion method at the end.
We might be tempted to remove that duplication, using parameterized tests.
But, inside unit tests, being explicit is better than being DRY.
Turning our two tests into a parameterized test would make us write a weird Assert part to switch between Equal()
and NotEqual()
based on the value of motionDetected
.
Let's prefer clarity over dryness. Tests serve as a living documentation of system behavior.
5. Replace dependency creation with auto-mocking
ActuateLights()
uses a static class to turn on/off lights,
public void ActuateLights(bool motionDetected)
{
DateTime time = DateTime.Now;
if (motionDetected)
{
LastMotionTime = time;
}
string timePeriod = GetTimePeriod(time);
if (motionDetected && (timePeriod == "Evening" || timePeriod == "Night"))
{
LightSwitcher.Instance.TurnOn();
// πππ
}
else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1)
|| (timePeriod == "Morning" || timePeriod == "Noon"))
{
LightSwitcher.Instance.TurnOff();
// πππ
}
}
It'd be hard to assert if the lights were turned on or off with a static method.
A better approach is to replace LightSwitcher.Instance
with an interface.
But adding a new dependency to the LightActuator
would break our tests.
Instead of manually passing the new LightSwitch
abstraction to the LightActuator
constructor inside our tests, we could rely on auto-mocking tools like Moq.AutoMocker.
Here's our test using AutoMocker,
[Fact]
public void MotionDetected_LastMotionTimeChanged()
{
bool motionDetected = true;
DateTime startTime = new DateTime(2000, 1, 1);
var ioc = new AutoMocker();
// πππ
var actuator = ioc.CreateInstance<LightActuator>();
// πππ
actuator.LastMotionTime = startTime;
actuator.ActuateLights(motionDetected);
DateTime actual = actuator.LastMotionTime;
actual.Should().NotBe(startTime);
}
I've already used a similar approach with TypeBuilder and AutoFixture.
Parting thoughts
VoilΓ ! Those are the five lessons I learned from this talk.
My favorite quote from the talk:
"What's cool about unit testing is we can debug our code by writing code"
β Rob Richardson
But, after writing and getting my tests reviewed, there are two changes I'd do:
- I'd remove the
// Arrange
,// Act
, and// Assert
comments inside the sample test. In fact, in the examples I've shown you, I completely removed them. - I'd use a descriptive variable name to document our test values instead of using comments. Instead of writing
startTime
with// random value
, I'd rename it toanyStartTime
orrandomStartTime
.
This is a good talk and I've stolen some ideas for my presentations.
If you want to upgrade your unit testing skills, check my course: Mastering C# Unit Testing with Real-world Examples on Udemy. Write readable and maintainable unit tests in 1 hour by refactoring real-world tests β Yes, real tests. No more boring tests for a Calculator class.