Using a Layer of Abstraction for Testing Code that Depends on Time

last updated: Nov 16th, 2020

Continuing with this month's theme, Time, I want to share a strategy I've used in the past when I've needed to write unit tests for logic that depends on time, timers, timeouts, etc.

Take for example a cache where items can expire. Suppose you want items to be evicted once they have gone untouched for some period of time.

One simple approach might be to take the idle expiry as a parameter. Say 20 minutes in a production build and only 100 milliseconds during testing.

Testing this way can lead to brittle tests. By brittle I mean that your tests will pass most of the time. But occasionally, if your machine is busy or the tests run on a different machine, it's possible that you'll get some false negatives.

In addition, your tests will take longer to run than necessary.

Here's what I do instead.

Example Code

Let's define a simple time-based cache first.

export class Cache<K extends string | number | symbol, T> {    private readonly timeout: number;    private readonly items: Partial<Record<K, T>>;    private readonly expiry: Partial<Record<K, number>>;    constructor(timeout: number) {        this.timeout = timeout;        this.items = {};        this.expiry = {};    }    public set(key: K, item: T) {        this.items[key] = item;        this.expiry[key] = Date.now() + this.timeout;    }    public get(key: K): T | undefined {        // Is the item in the cache?        const item = this.items[key];        if (item !== undefined) {            // Is the item still not expired?            const now = Date.now();            if (this.expiry[key] < now) {                // Update the expiry and return the item.                this.expiry[key] = now + this.timeout;                return item;            }            // Item is in cache but has expired.            // Evict the item and return undefined.            delete this.items[key];            delete this.expiry[key];        }    }}

Layer of Abstraction

I introduce a layer of abstraction between my code and the time functions.

Instead of calling time functions like Date.now() directly, I call them indirectly. For instance, see the changes in the cache below.

type FnGetTime = () => number;export class Cache<K extends string | number | symbol, T> {    private readonly timeout: number;    private readonly items: Partial<Record<K, T>>;    private readonly expiry: Partial<Record<K, number>>;    private readonly fnGetTime: FnGetTime;//    constructor(timeout: number) {    constructor(timeout: number, fnGetTime?: FnGetTime) {        this.timeout = timeout;        this.items = {};        this.expiry = {};        this.fnGateTime = fnGetTime || Date.now();    }    public set(key: K, item: T) {        this.items[key] = item;//        this.expiry[key] = Date.now() + this.timeout;        this.expiry[key] = this.fnGetTime() + this.timeout;    }    public get(key: K): T | undefined {        // Is the item in the cache?        const item = this.items[key];        if (item !== undefined) {            // Is the item still not expired?//            const now = Date.now();            const now = this.fnGetTime();            if (this.expiry[key] < now) {                // Update the expiry and return the item.                this.expiry[key] = now + this.timeout;                return item;            }            // Item is in cache but has expired.            // Evict the item and return undefined.            delete this.items[key];            delete this.expiry[key];        }    }}

The main code that creates the cache doesn't need to change.

private readonly sessionCache = new Cache<SessionId, Session>(TWENTY_MINS);

But now my testing code can pass in a special time generation function to the constructor and not have to rely on real time.

Controlling Time

Let's create a test helper to give out timestamps for our tests. We'll pass in the times we want it to generate, and it will provide a method for advancing to the next time.

export const makeTimeGenerator = (...times: number[]) => {    let index = 0;    const timeGenerator = () => {        return times[index];    };    timeGenerator.advance = () => {        index++;    };    return timeGenerator;};

Implementing the Tests

Now the tests can look like this:

import { makeTimeGenerator } from './makeTimeGenerator';const TWENTY_MINS = 20 * 60 * 1000;describe('cache', () => {    it('does not expire items before the expiry time', () => {        const timeGenerator = makeTimeGenerator(0, TWENTY_MINS - 1);        const cache = new Cache<string, number>(TWENTY_MINS, timeGenerator);        cache.set('foo', 42);        timeGenerator.advance();        const result = cache.get('foo');        expect(result).toBe(42);    });    it('expires items at the expiry time', () => {        const timeGenerator = makeTimeGenerator(0, TWENTY_MINS);        const cache = new Cache<string, number>(TWENTY_MINS, timeGenerator);        cache.set('foo', 42);        timeGenerator.advance();        const result = cache.get('foo');        expect(result).toBeUndefined();    });});

A Quick Note About the Real World

The simple example above of a session cache was just for illustration purposes. Note that in a real environment where you've got multiple web servers, you'd be relying on a cache like Redis to store sessions on a dedicated server.

Level up Your React + Redux + TypeScript

with articles, tutorials, sample code, and Q&A.