Anthony Fung
Posted on July 26, 2023
We’ve seen how using interfaces in place of concrete classes can make our code more flexible. In the unit tests we’ve written in previous parts of this series, this layer of abstraction allowed us to replace our modules’ dependencies with testing mocks. These mocks were created and set up with specific behaviours that allowed us to isolate and precisely target parts of a system to test.
But what happens when we use part of a third-party library that doesn’t implement an interface?
This week, we’ll look at how to test code that makes use of dates and timestamps.
Timestamps and Automated Tests
Let’s imagine we’re writing a logging service. We currently have one method, Info
. It prefixes the provided message with a timestamp and then writes the formatted entry to the log.
public interface IWriter
{
void Write(string message);
}
public class LogService
{
private readonly IWriter _writer;
public LogService(IWriter writer)
{
_writer = writer;
}
public void Info(string message)
{
var utc = DateTime.UtcNow;
var formattedMsg = $"{utc} - {message}";
_writer.Write(formattedMsg);
}
}
We also have a test to check that all logged messages include the timestamp:
[Test]
public void LoggedInfoIncludesTimestamp()
{
// Arrange
var logs = new List<string>();
var writer = new Mock<IWriter>();
writer
.Setup(w => w.Write(It.IsAny<string>()))
.Callback(logs.Add);
var logger = new LogService(writer.Object);
// Act
logger.Info("Hello, World!");
// Assert
Assert.That(logs.Count, Is.EqualTo(1));
Assert.That(logs[0] == "22/07/2023 16:31:43 - Hello, World!");
}
But there’s a problem. This test can only pass if two conditions are met:
The logic and message formatting are correct.
The test is run at (and only at) 22/07/2023 16:31:43.
The first point isn’t an issue – we can always review the code and make changes where necessary.
The second point is more challenging due to the time constraint. We could try:
Altering the system date whenever the test is run.
Dynamically building the assertion based on the time that the test is run.
However, changing system settings seems drastic and impractical. It might also be impossible in some situations (e.g. on a build server). And generating assertions on-the-fly leaves room for things to go wrong, especially given the one-second validity period.
So, what else can we do?
Playing with Time
A more reliable way around the problem is to add a layer of indirection. It looks like .NET 8 will include a TimeProvider
class for this purpose. At the time of writing, it isn’t available yet. But we can introduce our own interface to do something similar. You can also use this technique if you’re reading this when TimeProvider
is available, but don’t want to use it.
public interface ITimeService
{
DateTime UtcNow { get; }
}
public class TimeService : ITimeService
{
public DateTime UtcNow => DateTime.UtcNow;
}
In the preceding code, TimeService
simply acts as a wrapper around DateTime.UtcNow
. We can use this in place of direct calls to DateTime.UtcNow
in production code:
public class LogService
{
private readonly ITimeService _timeService;
private readonly IWriter _writer;
public LogService(
ITimeService timeService,
IWriter writer)
{
_timeService = timeService;
_writer = writer;
}
public void Info(string message)
{
var utc = _timeService.UtcNow;
var formattedMsg = $"{utc} - {message}";
_writer.Write(formattedMsg);
}
}
As the code only deals with the ITimeService
interface, we can mock it in our test to simulate running at a specific time:
[Test]
public void LoggedInfoIncludesTimestamp()
{
// Arrange
var logs = new List<string>();
var writer = new Mock<IWriter>();
var timeSerivce = Mock.Of<ITimeService>(s =>
s.UtcNow == new DateTime(2023, 7, 22, 16, 31, 43));
writer
.Setup(w => w.Write(It.IsAny<string>()))
.Callback(logs.Add);
var logger = new LogService(timeSerivce, writer.Object);
// Act
logger.Info("Hello, World!");
// Assert
Assert.That(logs.Count, Is.EqualTo(1));
Assert.That(logs[0] == "22/07/2023 16:31:43 - Hello, World!");
}
This approach is useful when time is important in our logic. By controlling the clock (as far as the code being tested is concerned), it becomes a predictable part of our tests. Results become more reliable and repeatable if things go wrong, and this is helpful if we need to do any debugging. I’ve worked on a few systems now where tests have used the actual system date, and it’s frustrating when they fail on some days but not others.
Summary
Tests need a controlled environment for results to be reliable. Direct use of the system clock in program logic can introduce a level of unpredictability. Doing so creates unique variables every time that a test is run, which may lead to inconsistent outcomes.
One way to address this is to introduce a layer abstraction around dates and times. By adding a thin wrapper around methods and properties to get them, you can set up mocks to provide consistent values in tests, and real values at runtime.
You’ll avoid the frustration of tests and build chains that can randomly fail. And ultimately, you’ll have more time to spend on building great products.
Thanks for reading!
This article is from my newsletter. If you found it useful, please consider subscribing. You’ll get more articles like this delivered straight to your inbox, plus bonus developer tips too!
Posted on July 26, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.