How to write tests that use DateTime.Now
10 May 2021 #tutorial #csharpIn our last post about using builders to create test data, we wrote a validator for expired credit cards. We used DateTime.Now
all over the place. Let’s see how to write better unit tests that use the current time.
To write tests that use DateTime.Now, create a wrapper for DateTime.Now and use a fake or test double with a fixed date. As an alternative, create a setter or an optional constructor to pass a reference date.
Let’s continue where we left off. Last time, we wrote two tests to check if a credit card was expired using the Builder pattern. These are the tests we wrote at that time.
using FluentValidation.TestHelper;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
namespace UsingBuilders;
[TestClass]
public class CreditCardValidationTests
{
[TestMethod]
public void CreditCard_ExpiredYear_ReturnsInvalid()
{
var validator = new CreditCardValidator();
var request = new CreditCardBuilder()
.WithExpirationYear(DateTime.Now.AddYears(-1).Year)
// ^^^^^
.Build();
var result = validator.TestValidate(request);
result.ShouldHaveAnyValidationError();
}
[TestMethod]
public void CreditCard_ExpiredMonth_ReturnsInvalid()
{
var validator = new CreditCardValidator();
var request = new CreditCardBuilder()
.WithExpirationMonth(DateTime.Now.AddMonths(-1).Month)
// ^^^^^
.Build();
var result = validator.TestValidate(request);
result.ShouldHaveAnyValidationError();
}
}
These two tests rely on the current date and time. Every time we run tests that rely on the current date and time, we will have a different date and time. It means we will have different test values and tests each time we run these tests.
We want our tests to be deterministic. We learned that from Unit Testing 101. Using DateTime.Now
in our tests isn’t a good idea.
What are seams?
To replace the DateTime.Now
in our tests, we need seams.
A seam is a place to introduce testable behavior in our code under test. Two techniques to introduce seams are interfaces to declare dependencies in the constructor of a service and optional setter methods to plug in testable values.
Let’s see these two techniques to replace the DateTime.Now
in our tests.
1. Use a fake or test double to replace DateTime.Now
To make our tests more reliable, let’s create an abstraction for the current time and make our validator depend on it. Later, we can pass a fake or test double with a hardcoded date in our tests.
Let’s create an ISystemClock
interface and a default implementation. The ISystemClock
will have a Now
property for the current date and time.
public interface ISystemClock
{
DateTime Now { get; }
}
public class SystemClock : ISystemClock
{
public DateTime Now
=> DateTime.Now;
}
Our CreditCardValidator
will receive in its constructor a reference to ISystemClock
. Then, instead of using DateTime.Now
in our validator, it will use the Now
property from the clock.
public class CreditCardValidator : AbstractValidator<CreditCard>
{
public CreditCardValidator(ISystemClock systemClock)
{
var now = systemClock.Now;
// Beep, beep, boop
// Rest of the code here...
}
}
Next, let’s create a testable clock and use it in our tests.
public class FixedDateClock : ISystemClock
{
private readonly DateTime _when;
public FixedDateClock(DateTime when)
{
_when = when;
}
public DateTime Now
=> _when;
}
Notice we named our fake clock FixedDateClock
to show it returns the DateTime
we pass to it.
Our tests with the testable clock implementation will look like this,
[TestClass]
public class CreditCardValidationTests
{
[TestMethod]
public void CreditCard_ExpiredYear_ReturnsInvalid()
{
var when = new DateTime(2021, 01, 01);
var clock = new FixedDateClock(when);
// This time we're passing a fake clock implementation
var validator = new CreditCardValidator(clock);
// ^^^^^
var request = new CreditCardBuilder()
.WithExpirationYear(when.AddYears(-1).Year)
// ^^^^
.Build();
var result = validator.TestValidate(request);
result.ShouldHaveAnyValidationError();
}
[TestMethod]
public void CreditCard_ExpiredMonth_ReturnsInvalid()
{
var when = new DateTime(2021, 01, 01);
var clock = new FixedDateClock(when);
var validator = new CreditCardValidator(clock);
// ^^^^^
var request = new CreditCardBuilder()
.WithExpirationMonth(when.AddMonths(-1).Month)
// ^^^^
.Build();
var result = validator.TestValidate(request);
result.ShouldHaveAnyValidationError();
}
}
With a testable clock in our tests, we replaced all the references to DateTime.Now
with a fixed date in the past.
UPDATE (June 2024): .NET 8.0 introduced TimeProvider, a new abstraction to use and test time. With this new built-in abstraction, we don’t need to roll our own ISystemClock
.
Create constants for common test values
To make things cleaner, let’s refactor our tests. Let’s use a builder method and read-only fields for the fixed dates.
[TestClass]
public class CreditCardValidationTests
{
// vvvvv
private static readonly DateTime When = new DateTime(2021, 01, 01);
private static readonly DateTime LastYear = When.AddYears(-1);
private static readonly DateTime LastMonth = When.AddMonths(-1);
// ^^^^^
[TestMethod]
public void CreditCard_ExpiredYear_ReturnsInvalid()
{
// Notice the builder method here
var validator = MakeValidator(When);
// ^^^^^
var request = new CreditCardBuilder()
.WithExpirationYear(LastYear.Year)
// ^^^^^
.Build();
var result = validator.TestValidate(request);
result.ShouldHaveAnyValidationError();
}
[TestMethod]
public void CreditCard_ExpiredMonth_ReturnsInvalid()
{
var validator = MakeValidator(When);
var request = new CreditCardBuilder()
.WithExpirationMonth(LastMonth.Month)
// ^^^^^
.Build();
var result = validator.TestValidate(request);
result.ShouldHaveAnyValidationError();
}
private CreditCardValidator MakeValidator(DateTime when)
{
var clock = new FixedDateClock(when);
var validator = new CreditCardValidator(clock);
return validator;
}
}
That’s how we can abstract the current date and time with an interface.
2. Use a parameter in a constructor
Now, let’s see the second alternative. To replace the interface from our first example, in the constructor we can pass a delegate returning a reference date. Like this:
public class CreditCardValidator : AbstractValidator<CreditCard>
{
public CreditCardValidator(Func<DateTime> nowSelector)
// ^^^^^
{
var now = nowSelector();
// Beep, beep, boop
// Rest of the code here...
}
}
Or, even simpler we can pass a plain DateTime
parameter. Like this:
public class CreditCardValidator : AbstractValidator<CreditCard>
{
public CreditCardValidator(DateTime now)
// ^^^^^
{
// Beep, beep, boop
// Rest of the code here...
}
}
Let’s stick to a simple parameter and update our tests.
[TestClass]
public class CreditCardValidationTests
{
private static readonly DateTime When = new DateTime(2021, 01, 01);
private static readonly DateTime LastYear = When.AddYears(-1);
private static readonly DateTime LastMonth= When.AddMonths(-1);
[TestMethod]
public void CreditCard_ExpiredYear_ReturnsInvalid()
{
var validator = new CreditCardValidator(When);
// ^^^^^
var request = new CreditCardBuilder()
.WithExpirationYear(LastYear.Year)
.Build();
var result = validator.TestValidate(request);
result.ShouldHaveAnyValidationError();
}
[TestMethod]
public void CreditCard_ExpiredMonth_ReturnsInvalid()
{
var validator = new CreditCardValidator(When);
// ^^^^^
var request = new CreditCardBuilder()
.WithExpirationMonth(LastMonth.Month)
.Build();
var result = validator.TestValidate(request);
result.ShouldHaveAnyValidationError();
}
}
Yeap! As simple as that.
2.1. Write an optional setter
Another variation on this theme is to create a setter inside the CreditCardValidator
to pass an optional date. Inside the validator, we should check if the optional date is present to use DateTime.Now
or not. Something like this,
[TestMethod]
public void CreditCard_ExpiredYear_ReturnsInvalid()
{
var validator = new CreditCardValidator();
validator.CurrentDateTime = When;
// ^^^^^
var request = new CreditCardBuilder()
.WithExpirationYear(LastYear.Year)
.Build();
var result = validator.TestValidate(request);
result.ShouldHaveAnyValidationError();
}
Voilà! That’s how we can write more reliable tests that use the current date and time. You can either create an interface or pass a fixed date.
If you’re new to unit testing, read Unit Testing 101 and 4 test naming conventions. For more advanced tips on unit testing, check my posts on how to write good unit tests and how to write fakes with Moq.
And don’t miss the rest of my Unit Testing 101 series where I cover more subjects like this one.
Happy testing!