Previously, I wrote about a simple way to change slow, time-sensitive tests into fast, no-wait tests using some async tricks. However, the example shown only works well when the underlying slowness involves a one-shot action. What if instead you are dealing with periodic actions and you want to speed things up without any underlying async calls?
The sample today is a TimerGroup
class that provides a simple interface on top of a collection of Timer-based actions. The initial implementation delegates directly to Timer
and thus forces the tests to wait for real wall clock seconds while the scheduler kicks in:
public sealed class TimerGroup : IDisposable { private readonly Dictionary<Guid, Timer> timers; public TimerGroup() { this.timers = new Dictionary<Guid, Timer>(); } public Guid Add(TimeSpan interval, Action action) { Guid id = Guid.NewGuid(); this.timers.Add(id, new Timer(o => action(), null, interval, interval)); return id; } public void Remove(Guid id) { Timer timer; if (this.timers.TryGetValue(id, out timer)) { this.timers.Remove(id); Cancel(timer); } } public void Dispose() { this.Dispose(true); GC.SuppressFinalize(this); } private static void Cancel(Timer timer) { using (timer) { timer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); } } private void Dispose(bool disposing) { if (disposing) { foreach (Timer timer in this.timers.Values) { Cancel(timer); } } } } [TestClass] public class TimerGroupTest { [TestMethod] public void ShouldScheduleActionOnIntervalAfterAdd() { using (TimerGroup timers = new TimerGroup()) { int invokeCount = 0; timers.Add(TimeSpan.FromSeconds(1.0d), () => ++invokeCount); Assert.AreEqual(0, invokeCount); Thread.Sleep(TimeSpan.FromSeconds(1.1d)); Assert.AreEqual(1, invokeCount); } } [TestMethod] public void ShouldScheduleActionPeriodicallyOnIntervalsAfterAdd() { using (TimerGroup timers = new TimerGroup()) { int invokeCount = 0; timers.Add(TimeSpan.FromSeconds(1.0d), () => ++invokeCount); Assert.AreEqual(0, invokeCount); Thread.Sleep(TimeSpan.FromSeconds(1.1d)); Assert.AreEqual(1, invokeCount); Thread.Sleep(TimeSpan.FromSeconds(1.0d)); Assert.AreEqual(2, invokeCount); } } [TestMethod] public void ShouldScheduleMultipleActionsPeriodicallyOnSeparateIntervalsAfterAdd() { using (TimerGroup timers = new TimerGroup()) { int invokeCount1 = 0; int invokeCount2 = 0; timers.Add(TimeSpan.FromSeconds(1.0d), () => ++invokeCount1); timers.Add(TimeSpan.FromSeconds(1.5d), () => ++invokeCount2); Assert.AreEqual(0, invokeCount1); Assert.AreEqual(0, invokeCount2); Thread.Sleep(TimeSpan.FromSeconds(1.1d)); Assert.AreEqual(1, invokeCount1); Assert.AreEqual(0, invokeCount2); Thread.Sleep(TimeSpan.FromSeconds(1.0d)); Assert.AreEqual(2, invokeCount1); Assert.AreEqual(1, invokeCount2); Thread.Sleep(TimeSpan.FromSeconds(1.0d)); Assert.AreEqual(3, invokeCount1); Assert.AreEqual(2, invokeCount2); } } [TestMethod] public void ShouldCancelActionByIdAfterRemove() { using (TimerGroup timers = new TimerGroup()) { int invokeCount1 = 0; int invokeCount2 = 0; Guid id1 = timers.Add(TimeSpan.FromSeconds(1.0d), () => ++invokeCount1); Guid id2 = timers.Add(TimeSpan.FromSeconds(1.5d), () => ++invokeCount2); Assert.AreNotEqual(id1, id2); Assert.AreEqual(0, invokeCount1); Assert.AreEqual(0, invokeCount2); Thread.Sleep(TimeSpan.FromSeconds(1.1d)); Assert.AreEqual(1, invokeCount1); Assert.AreEqual(0, invokeCount2); Thread.Sleep(TimeSpan.FromSeconds(1.0d)); Assert.AreEqual(2, invokeCount1); Assert.AreEqual(1, invokeCount2); timers.Remove(id1); Thread.Sleep(TimeSpan.FromSeconds(1.0d)); Assert.AreEqual(2, invokeCount1); Assert.AreEqual(2, invokeCount2); } } [TestMethod] public void ShouldCancelAllActionsOnDispose() { TimerGroup timers = new TimerGroup(); int invokeCount1 = 0; int invokeCount2 = 0; Guid id1 = timers.Add(TimeSpan.FromSeconds(1.0d), () => ++invokeCount1); Guid id2 = timers.Add(TimeSpan.FromSeconds(1.5d), () => ++invokeCount2); Assert.AreNotEqual(id1, id2); Assert.AreEqual(0, invokeCount1); Assert.AreEqual(0, invokeCount2); Thread.Sleep(TimeSpan.FromSeconds(1.1d)); Assert.AreEqual(1, invokeCount1); Assert.AreEqual(0, invokeCount2); timers.Dispose(); Thread.Sleep(TimeSpan.FromSeconds(1.0d)); Assert.AreEqual(1, invokeCount1); Assert.AreEqual(0, invokeCount2); } [TestMethod] public void ShouldIgnoreInvalidIdOnRemove() { using (TimerGroup timers = new TimerGroup()) { timers.Remove(Guid.Empty); } } [TestMethod] public void ShouldIgnoreMultipleRemove() { using (TimerGroup timers = new TimerGroup()) { Guid id = timers.Add(TimeSpan.FromHours(1.0d), () => { }); timers.Remove(id); timers.Remove(id); } } }
These tests are annoyingly slow in unit test terms. But don’t just take my word for it:
Let’s address this sleeping scourge step by step using a page of the Ports-Adapters-Simulators playbook.
First, we need a port to replace the direct Timer dependency and an adapter to contain the existing code. PeriodicAction
seems like a good port choice as it names the domain object (abstraction) rather than the underlying mechanism (details). It will start out as a concrete inner class of TimerGroup, doing double duty as both port and adapter, since there is no other consumer yet. With a small set of safe refactorings (mostly moving code and renaming variables), we end up with this:
public sealed class TimerGroup : IDisposable { private readonly Dictionary<Guid, PeriodicAction> actions; public TimerGroup() { this.actions = new Dictionary<Guid, PeriodicAction>(); } public Guid Add(TimeSpan interval, Action action) { Guid id = Guid.NewGuid(); this.actions.Add(id, new PeriodicAction(interval, action)); return id; } public void Remove(Guid id) { PeriodicAction action; if (this.actions.TryGetValue(id, out action)) { using (action) { this.actions.Remove(id); } } } public void Dispose() { this.Dispose(true); GC.SuppressFinalize(this); } private void Dispose(bool disposing) { if (disposing) { foreach (PeriodicAction action in this.actions.Values) { using (action) { } } } } private sealed class PeriodicAction : IDisposable { private readonly Timer timer; public PeriodicAction(TimeSpan interval, Action action) { this.timer = new Timer(o => action(), null, interval, interval); } public void Dispose() { this.Dispose(true); GC.SuppressFinalize(this); } private void Dispose(bool disposing) { using (this.timer) { this.timer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); } } } }
All of the tests still pass (slowly), so we haven’t obviously broken anything. Next, we need a prototypical seam to eventually inject another action type. In this case, we can use property setter injection to define a factory function returning PeriodicAction
instances:
// ... public TimerGroup() { this.actions = new Dictionary<Guid, PeriodicAction>(); this.Create = (i, a) => new PeriodicAction(i, a); } private Func<TimeSpan, Action, PeriodicAction> Create { get; set; } public Guid Add(TimeSpan interval, Action action) { Guid id = Guid.NewGuid(); this.actions.Add(id, this.Create(interval, action)); return id; } // ...
Tests still pass, so we can move on. But now comes the more difficult part… how do we simulate a Timer? The approach I landed on was to have a virtual clock of sorts (a rare time where — I believe — such a thing could be a good idea). It will allow a drop-in replacement for Thread.Sleep while invoking actions that have reached their deadline during the provided interval. Given the complexity of the logic that will result, we need to plan for unit tests of the simulator code. Although it sounds a bit odd (“testing a test”?), this is commonplace in the ports and adapters design style — after all, a simulator should be functionally correct and faithful to the contract defined by the port.
Let’s sketch out the expectations for VirtualClock so that we can implement the tests and the logic:
- Its public interface consists of two methods: CreateAction, Sleep.
- Sleep increments the current (relative) time by the provided interval and invokes all actions with deadlines from the current time up to (but excluding) the final time. (Mathematically, this would be the half-open interval
[T, T + I)
.) - On CreateAction, it passes the current time to the child action, as this affects the deadline. Examples:
- An action created at T=0 with interval 1 should fire at T=1, T=2, ….
- An action created at T=3 with interval T=2 should fire at T=5, T=7, ….
- Actions with earlier deadlines should be invoked before actions with later deadlines. (We’ll not define a strict ordering so any order is fine in case of a deadline tie.)
- Disposed actions should be considered canceled and never invoked again.
Before we move to the actual code, we need to extract PeriodicAction from its parent class and make it an abstract base class that we can ultimately subclass for our simulator. Essentially, we’re just moving some code and renaming a few things:
private sealed class TimerBasedAction : PeriodicAction { private readonly Timer timer; public TimerBasedAction(TimeSpan interval, Action action) : base(interval, action) { this.timer = new Timer(o => action(), null, interval, interval); } public override void Dispose() { this.Dispose(true); GC.SuppressFinalize(this); } private void Dispose(bool disposing) { using (this.timer) { this.timer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); } } } // ... public abstract class PeriodicAction : IDisposable { protected PeriodicAction(TimeSpan interval, Action action) { } public abstract void Dispose(); }
Now for the VirtualClock:
internal sealed class VirtualClock { private readonly List<VirtualAction> actions; private TimeSpan currentTime; public VirtualClock() { this.actions = new List<VirtualAction>(); } public PeriodicAction CreateAction(TimeSpan interval, Action action) { VirtualAction newAction = new VirtualAction(interval, action, this.currentTime); this.actions.Add(newAction); return newAction; } public void Sleep(TimeSpan interval) { TimeSpan start = this.currentTime; TimeSpan end = start + interval; List<Tuple<TimeSpan, VirtualAction>> actionsToInvoke = new List<Tuple<TimeSpan, VirtualAction>>(); foreach (VirtualAction action in this.actions) { foreach (TimeSpan deadline in action.DeadlinesBetween(start, end)) { actionsToInvoke.Add(Tuple.Create(deadline, action)); } } actionsToInvoke.Sort((a, b) => a.Item1.CompareTo(b.Item1)); foreach (Tuple<TimeSpan, VirtualAction> t in actionsToInvoke) { t.Item2.Invoke(); } this.currentTime = end; } private sealed class VirtualAction : PeriodicAction { private readonly TimeSpan interval; private readonly Action action; private readonly TimeSpan creationTime; private bool canceled; public VirtualAction(TimeSpan interval, Action action, TimeSpan creationTime) : base(interval, action) { this.interval = interval; this.action = action; this.creationTime = creationTime; } public IEnumerable<TimeSpan> DeadlinesBetween(TimeSpan start, TimeSpan end) { int n = (int)Math.Ceiling((double)(start - this.creationTime).Ticks / this.interval.Ticks); if (n < 1) { n = 1; } TimeSpan firstDeadline = TimeSpan.FromTicks(n * this.interval.Ticks) + this.creationTime; for (TimeSpan deadline = firstDeadline; deadline < end; deadline += this.interval) { yield return deadline; } } public void Invoke() { if (!this.canceled) { this.action(); } } public override void Dispose() { if (this.canceled) { throw new InvalidOperationException("Action is already canceled/disposed."); } this.canceled = true; } } }
Along with the implementation there is a full set of unit tests as well. I’ve omitted them to prevent this already long post from getting longer.
Now we can make the Create function of TimerGroup public and fix the slow TimerGroup tests using VirtualClock. Here is a before and after of the first test:
// BEFORE: [TestMethod] public void ShouldScheduleActionOnIntervalAfterAdd() { using (TimerGroup timers = new TimerGroup()) { int invokeCount = 0; timers.Add(TimeSpan.FromSeconds(1.0d), () => ++invokeCount); Assert.AreEqual(0, invokeCount); Thread.Sleep(TimeSpan.FromSeconds(1.1d)); Assert.AreEqual(1, invokeCount); } } // AFTER: [TestMethod] public void ShouldScheduleActionOnIntervalAfterAdd() { VirtualClock clock = new VirtualClock(); using (TimerGroup timers = new TimerGroup() { Create = clock.CreateAction }) { int invokeCount = 0; timers.Add(TimeSpan.FromSeconds(1.0d), () => ++invokeCount); Assert.AreEqual(0, invokeCount); clock.Sleep(TimeSpan.FromSeconds(1.1d)); Assert.AreEqual(1, invokeCount); } }
Code-wise, it reads much the same, but speed-wise, there is no comparison:
You can review all the code in this post (with step-by-step commit history) via the TimerSample project on GitHub.
With this approach in your back pocket, you should now have one fewer excuse to put up with slow tests.
Pingback: No time, no problem – WriteAsync .NET