Recently, I found a NDC talk titled “.NET Testing Best Practices” by Rob Richardson.
Today I want to share five unit testing best practices I learned from that talk, along with my comments on other parts of it.
Here’s the YouTube video of the talk, in case you want to watch it, and the speaker’s website,
During the presentation, the speaker coded some unit tests for the LightActuator class. This class powers an IoT device that turns a 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.
publicclassLightActuator:ILightActuator{privateDateTimeLastMotionTime{get;set;}publicvoidActuateLights(boolmotionDetected){DateTimetime=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.stringtimePeriod=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.elseif(time.Subtract(LastMotionTime)>TimeSpan.FromMinutes(1)||(timePeriod=="Morning"||timePeriod=="Noon")){LightSwitcher.Instance.TurnOff();}}privatestringGetTimePeriod(DateTimedateTime){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,
publicclassLightActuator_ActuateLights_Tests{[Fact]publicvoidMotionDetected_LastMotionTimeChanged(){// ArrangeboolmotionDetected=true;DateTimestartTime=newDateTime(2000,1,1);// random value// ActLightActuatoractuator=newLightActuator();actuator.LastMotionTime=startTime;actuator.ActuateLights(motionDetected);DateTimeactualTime=actuator.LastMotionTime;// AssertAssert.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.
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]publicvoidMotionDetected_LastMotionTimeChanged(){boolmotionDetected=true;DateTimestartTime=newDateTime(2000,1,1);LightActuatoractuator=newLightActuator();actuator.LastMotionTime=startTime;actuator.ActuateLights(motionDetected);DateTimeactualTime=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 tests using FluentAssertions,
[Fact]publicvoidMotionDetected_LastMotionTimeChanged(){boolmotionDetected=true;DateTimestartTime=newDateTime(2000,1,1);LightActuatoractuator=newLightActuator();actuator.LastMotionTime=startTime;actuator.ActuateLights(motionDetected);DateTimeactualTime=actuator.LastMotionTime;// Before//Assert.NotEqual(actualTime, startTime);// ^^^^^// They're in the wrong order. Arrrggg!//// After, with FluentAssertionsactualTime.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,
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,
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.
We don’t add // class, // fields, and // methods in other parts of our code, so it shouldn’t be necessary in our tests either.
Instead, I prefer using blank lines to visually separate the three sections of the Arrange/Act/Assert pattern.
In the examples I’ve shown you, I completely removed those comments.
2. Name test values instead of using comments
It’s a good idea to document our test values. But, let’s avoid using comments when we can use a descriptive name.
I’d rename startTime with a comment at the end to anyStartTime or randomStartTime,
[Fact]publicvoidMotionDetected_LastMotionTimeChanged(){boolmotionDetected=true;// Before://DateTime startTime = new DateTime(2000, 1, 1); // random value// ^^^^^varanyStartTime=newDateTime(2000,1,1);// ^^^^^// or//var randomStartTime = new DateTime(2000, 1, 1);// ^^^^LightActuatoractuator=newLightActuator();actuator.LastMotionTime=anyStartTime;actuator.ActuateLights(motionDetected);DateTimeactualTime=actuator.LastMotionTime;Assert.NotEqual(anyStartTime,actualTime);}
3. Don’t expose private parts
In the talk, as part of the refactoring session, the presenter tested some internals. Specifically, he made the LastMotionTime property inside the LightActuator class public to use it inside the tests.
Even somebody in the audience raised this question too.
I understand the presenter had less than an hour to show a complete example and he chose a simple approach.
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
As an exercise left to the reader, the presenter didn’t cover testing time. But we already covered how to write tests that use DateTime.Now using a custom abstraction.
Another thing I didn’t like is that at some point in the testing session, a TimePeriodHelper was added. And that’s one of the method and class names I’d like to ban.
Ready to upgrade your unit testing skills? Check my course Mastering C# Unit Testing with Real-world Examples on Udemy. Practice with hands-on exercises and learn best practices by refactoring real-world unit tests.
And I don’t want to touch on primary constructors. It’s like classes got jealous of records and started crying for a similar feature, like a baby boy jealous of his brother.
Voilà! That’s what I don’t like about C#. Don’t take me wrong, C# is a great language with excellent tooling. But my favorite features are quite old: LINQ, async/await, and extension methods.
Some new features have lowered the barrier to entry. Now a “Hello, world” is a single line of code: Console.WriteLine("Hello, world!");.
Other C# features are making the language inconsistent and easier to write but not easier to read.
I read that Napoleon Hill, the author of “Think and Grow Rich,” talked to his mentor only once, and he said it changed his life.
I’d be happy to share some advice over a virtual coffee in an “ask-a-friend” style. But to preserve my keystrokes and help more than one person, here I go.
I’m writing for my 20-year-old self joining his first corporate job, pretending that talent was a shortcut to break the rules of the corporate world. I thought my first job would be like in a Silicon Valley startup, working barefoot and sliding between offices. It was far from that.
This is free Internet advice, so handle it with care.
If you could take away only one lesson, take this first one,
1. Learn It and Teach It
“The moment you learn something, teach it.”
That’s from the book Show Your Work by Austin Kleon. “A book for people who hate the very idea of self-promotion.”
Teaching is the most effective way to learn. When you teach what you’ve learned, you consolidate your learning and, if you choose to teach online, you start building an online brand.
If you’re still here for more lessons, let’s continue…
2. Google “How To Be a Good Developer”
I remember doing this exact same search, back in the early 2010s when I started my journey.
I remember finding “write technical specs” and “learn functional programming.” It gave me ideas and subjects to explore. That’s why I’ve always had Functional Programming in the back of my head.
I’d tell my younger self to do it again.
Google or DuckDuckGo or Bing “How to be a good developer” and see how deep the rabbit hole goes.
Be careful, you will find one post saying “Document your code” and another one, saying “Don’t document your code.” Separate the wheat from the shaft.
3. Practice a Lot
The barrier to entry into the coding world is low, and lower and lower with every day that passes.
Anyone can potentially start, but getting good at it takes years.
Another googling task: search for “Teach Yourself Programming in Ten Years.”
Start by writing an end-to-end project: a coding project that reads data from a webpage, calls a backend, persists data into a relational database, and displays it back.
You will learn a lot from this simple exercise. HTML/CSS, a UI library, HTTP/REST, a backend language, SQL, and a database engine. Quite a lot!
I can’t remember how many hours I spent coding a recipe catalog back in the day with PHP and MySQL.
4. Master the Fundamentals
Frameworks and libraries come and go.
Study subjects that stand the test of time. Design patterns, data structures, and SQL, for example.
For some reason, we’re still talking about stoicism thousands of years later.
Probably in the next decade, we will still be writing code on text files, using some flavor of Unix/Linux, and writing SQL. I wouldn’t bet all my money, though.
5. Invest in Your Soft Skills
You won’t be locked in your basement coding. This is a collaborative endeavor.
You will spend most of your time communicating and collaborating: 1-1s, estimations, and SCRUM “ceremonies.”
Invest in your soft skills. Start by reading “How to Win Friends and Influence People.” My biggest takeaway from that book is never telling someone is wrong.
6. Read the Clean Code
This is the book we all start with.
It opened a whole new world for me. Nobody taught me about good variable and function names until I found that book. I even remember one of my teachers using “stu1” and “stu2” as variable names during classes. Uncle Bob would be pissed by those names.
Don’t just read it. Study it.
But don’t become a Clean Code police officer issuing infractions around you and using your book copy as your baton. There’s always a tradeoff.
7. Ask Yourself What You Want
Start by experimenting with roles and tech stacks. You will find the one that you like the most.
I started writing PDF reports by hand, drawing lines and cells, one pixel and line at a time. I don’t do that anymore, by the way. Then, I did a bit of mobile development with Xamarin and frontend development back in the day of Bootstrap and Knockout.js. Eventually, I got tired of styling and coloring issues and moved to backend development.
Ask yourself what you want out of your career. Is it money? Connections? Growing your own business? Write a 5-year plan and be willing to correct the course. “No plan resists contact with the enemy” or reality, I prefer to say.
8. Climbing the Corporate Ladder Is a Myth
You don’t control anything about the ladder you climb. Anyone can add a new step or replace the entire ladder, at any time.
Software Engineer I, II, III, IV, V…
You will hit a point of diminishing returns and a glass ceiling as a software developer.
I’d tell my younger self eager for a fancy title: a new title comes with more meetings, extra hours, and the same salary.
Instead of optimizing for a title, optimize for a lifestyle. The best ladder to climb is the one you build for yourself.
Takeaways
Voila! That’s what I’d tell my younger self. I hope I haven’t discouraged you. Software Engineering is a great career. It’s the future. Well, that’s what I’ve been hearing for quite a while.
The most satisfying thing about this career is seeing a smile on your users’ faces when what you code helps them save hours of repetitive work.
Also, I challenged myself to learn enough React in 30 days. Probably by the time you read it, there will be a new React version doing everything differently. And, if you’re studying Golang, I studied enough Go in 30 days too.
Whether you’re starting out or already on the coding journey, join my free 7-day email course to refactor your software engineering career now.
Starting from .NET 8.0, we have new abstractions for time. We don’t need a custom ISystemClock interface. There’s one built-in. Let’s learn how to use the new TimeProvider class to write tests that use DateTime.Now.
.NET 8.0 added the TimeProvider class to abstract date and time. It has a virtual method GetUtcNow() that sets the current time inside tests. It also has a non-testable implementation for production code.
Back in the day, we wrote two tests to validate expired credit cards. And we wrote an ISystemClock interface to control time inside our tests. These are the tests we wrote:
usingFluentValidation;usingFluentValidation.TestHelper;namespaceTimeProviderTests;[TestClass]publicclassCreditCardValidationTests{[TestMethod]publicvoidCreditCard_ExpiredYear_ReturnsInvalid(){varwhen=newDateTime(2021,01,01);varclock=newFixedDateClock(when);varvalidator=newCreditCardValidator(clock);// ^^^^^// Look, ma! I'm going back in timevarcreditCard=newCreditCardBuilder().WithExpirationYear(DateTime.UtcNow.AddYears(-1).Year).Build();varresult=validator.TestValidate(creditCard);result.ShouldHaveAnyValidationError();}[TestMethod]publicvoidCreditCard_ExpiredMonth_ReturnsInvalid(){varwhen=newDateTime(2021,01,01);varclock=newFixedDateClock(when);varvalidator=newCreditCardValidator(clock);// ^^^^^// Look, ma! I'm going back in time againvarcreditCard=newCreditCardBuilder().WithExpirationMonth(DateTime.UtcNow.AddMonths(-1).Month).Build();varresult=validator.TestValidate(creditCard);result.ShouldHaveAnyValidationError();}}publicinterfaceISystemClock{DateTimeNow{get;}}publicclassFixedDateClock:ISystemClock{privatereadonlyDateTime_when;publicFixedDateClock(DateTimewhen){_when=when;}publicDateTimeNow=>_when;}publicclassCreditCardValidator:AbstractValidator<CreditCard>{publicCreditCardValidator(ISystemClocksystemClock){varnow=systemClock.Now;// Beep, beep, boop// Using now to validate credit card expiration year and month...}}
We wrote a FixedDateClock that extended ISystemClock to freeze time inside our tests. The thing is, we don’t need them with .NET 8.0.
1. Use TimeProvider instead of ISystemClock
Let’s get rid of our old ISystemClock by making our CreditCardValidator receive TimeProvider instead, like this:
publicclassCreditCardValidator:AbstractValidator<CreditCard>{// Before:// public CreditCardValidator(ISystemClock systemClock)// After:publicCreditCardValidator(TimeProvidersystemClock)// ^^^^^{varnow=systemClock.GetUtcNow();// or//var now = systemClock.GetLocalNow();// Beep, beep, boop// Rest of the code here...}}
The TimeProvider abstract class has the GetUtcNow() method to override the current UTC date and time. Also, it has the LocalTimeZone property to override the local timezone. With this timezone, GetLocalNow() returns the “frozen” UTC time as a local time.
If we’re working with Task, we can use the Delay() method to create a task that completes after, well, a delay. Let’s use the short delays in our tests to avoid making our tests slow. Nobody wants a slow test suite.
With the TimeProvider, we can control time inside our tests by injecting a fake. But for production code, let’s use TimeProvider.System. It uses DateTimeOffset.UtcNow under the hood.
We might be tempted to wrie a child class that extends TimeProvider. But, let’s hold our horses. There’s an option for that too.
Let’s rewrite our tests after that change in the signature of the CreditCardValidator.
First, let’s install the Microsoft.Extensions.TimeProvider.Testing NuGet package. It has a fake implementation of the time provider: FakeTimeProvider.
Here are our two tests using the FakeTimeProvider:
usingFluentValidation;usingFluentValidation.TestHelper;usingMicrosoft.Extensions.Time.Testing;namespaceTestingTimeProvider;[TestClass]publicclassCreditCardValidationTests{[TestMethod]publicvoidCreditCard_ExpiredYear_ReturnsInvalid(){// Before://var when = new DateTime(2021, 01, 01);//var clock = new FixedDateClock(when);varwhen=newDateTimeOffset(2021,01,01,0,0,0,TimeSpan.Zero);varclock=newFakeTimeProvider(when);// ^^^^^// Look, ma! No more ISystemClockvarvalidator=newCreditCardValidator(clock);// ^^^^^varcreditCard=newCreditCardBuilder().WithExpirationYear(DateTime.UtcNow.AddYears(-1).Year).Build();varresult=validator.TestValidate(creditCard);result.ShouldHaveAnyValidationError();}[TestMethod]publicvoidCreditCard_ExpiredMonth_ReturnsInvalid(){// Before://var when = new DateTime(2021, 01, 01);//var clock = new FixedDateClock(when);varwhen=newDateTimeOffset(2021,01,01,0,0,0,TimeSpan.Zero);varclock=newFakeTimeProvider(when);// ^^^^^varvalidator=newCreditCardValidator(clock);// ^^^^^// Look, ma! I'm going back in timevarcreditCard=newCreditCardBuilder().WithExpirationMonth(DateTime.UtcNow.AddMonths(-1).Month).Build();varresult=validator.TestValidate(creditCard);result.ShouldHaveAnyValidationError();}}
The FakeTimeProvider has two constructors. One without parameters sets the internal date and time to January 1st, 2000, at midnight. And another one that receives a DateTimeOffset. That was the one we used in our two tests.
The FakeTimeProvider has two helpful methods to change the internal date and time: SetUtcNow() and Advance(). SetUtcNow() receives a new DateTimeOffset and Advance(), a TimeSpan to add it to the internal date and time.
If we’re curious, this is the source code of TimeProvider and FakeTimeProvider from the official dotnet repository on GitHub.
If we take a closer look at our tests, we’re “controlling” the time inside the CreditCardValidator. But, we still have DateTime.UtcNow when creating a credit card. For that, we can introduce a class-level constant Now. But that’s an “exercise left to the reader.”
Voilà! That’s how to use the new .NET 8.0 abstraction to test time. We have the new TimeProvider and FakeTimeProvider. We don’t need our ISystemClock and FixedDateClock anymore.
I applied for a role as a software engineer at a FAANG or MAGMA or insert-newest-acronym here.
And I failed.
I thought: “I have more than 10 years of experience. I’ve seen quite a lot.”
A “short coding assessment” got me off guard. 80 minutes and 3 exercises made me feel like an impostor. An uppercut and 10-second countdown.
I don’t want this post to be another “hiring is broken” and “life is unfair” post. So…
If I could go back in time, this is what I’d tell myself before that coding assesment:
1. Review data structures, especially those you don’t use often.
Take the time to review data structures. Lists, hash maps, queues, trees.
Trees, is this you?
I haven’t used trees since my data structure class back in university. And probably, I wouldn’t use them if I had passed the interview and joined.
But, surprise, surprise. That was one of the questions.
2. Practice using a timer and a coding editor without auto-completion
I know it’s unrealistic. These days, we have IDEs with autocompletion and even AI at our fingertips.
But MAGMAs insist on hiring using coding platforms without autocompletion. The old way.
Since practicing a skill should be as “real” as possible, close your IDE and practice using a bare-bones text editor. And with a timer on.
3. Read all questions first. I know!
Yeah, I wanted to be an A-student playing with the rules. I jumped right to the first question.
50 minutes in and I had barely an answer for the first question. I had to decide between solving only one question or moving on and trying to solve another one. One and a half questions are better than only one, I guess.
I could have nailed the second one first. It was way easier. And definitively, I could have solved the last two questions and skipped the first one. If only I had read all the questions first.
Read all the questions and start with the easy ones. Old advice that I forgot.
Voila! That’s what I’d tell myself before that coding assessment. Yeah, hiring is broken, but we have to go through gatekeepers. Or ditch our CVs and interviewing skills and build a place for ourselves.