Skip to content

Commit

Permalink
jest: Add Lolex for proper time mocking.
Browse files Browse the repository at this point in the history
At present, it's not possible (or at least not easy) to test any code
which relies on `Date.now()` for timing purposes. Timer functions such
as `setInterval()` are mockable with Jest's existing mock-timer
functionality, but its mock timers do not include a mock time. [0]

Fortunately, there is work underway to have Jest v25^H^H26 use the
Lolex time-mocking library for its fake timer implementation, which
will do exactly that! Unfortunately, we need that capability now,
rather than in the indefinite future, so we'll jump the gun a bit.

To ease conversion of tests once Lolex is available natively, we use a
small shim taken from the code on Jest's current master branch which
implements Jest's timer-control functions (not projected to change)
in terms of Lolex.

Tests for this shim -- which may also serve, in part, as examples for
Zulip contributors who would like to use Lolex -- are included.

(Additionally, inform Jest that files present in src/__tests__/aux are
not supposed to contain executable tests.)

[0] S2E1, 1967-09-15.

GitHub-PR: #3886
  • Loading branch information
rk-for-zulip authored and gnprice committed Feb 25, 2020
1 parent 2faad06 commit 6d1d1df
Show file tree
Hide file tree
Showing 6 changed files with 236 additions and 2 deletions.
39 changes: 39 additions & 0 deletions flow-typed/npm/lolex_vx.x.x.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// flow-typed signature: 314c6282bca14b5a4e072a34d665ef62
// flow-typed version: <<STUB>>/lolex_v5.1.1/flow_v0.92.0

/**
* This is an autogenerated libdef stub for:
*
* 'lolex'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/

declare module 'lolex' {
declare module.exports: any;
}

/**
* We include stubs for each file inside this npm package in case you need to
* require those files directly. Feel free to delete any files that aren't
* needed.
*/
declare module 'lolex/lolex' {
declare module.exports: any;
}

declare module 'lolex/src/lolex-src' {
declare module.exports: any;
}

// Filename aliases
declare module 'lolex/lolex.js' {
declare module.exports: $Exports<'lolex/lolex'>;
}
declare module 'lolex/src/lolex-src.js' {
declare module.exports: $Exports<'lolex/src/lolex-src'>;
}
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ module.exports = {

// Finding and transforming source code.

testPathIgnorePatterns: ['/node_modules/'],
testPathIgnorePatterns: ['/node_modules/', '/src/__tests__/aux/'],

// When some source file foo.js says `import 'bar'`, Jest looks in the
// directories above foo.js for a directory like `node_modules` to find
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"jest-environment-jsdom": "^24.9.0",
"jest-environment-jsdom-global": "^1.2.0",
"jest-extended": "^0.11.2",
"lolex": "^5.1.1",
"metro-react-native-babel-preset": "^0.54.1",
"prettier": "^1.18.2",
"prettier-eslint": "^9.0.0",
Expand Down
86 changes: 86 additions & 0 deletions src/__tests__/aux/lolex.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// @flow strict-local

import LolexModule from 'lolex';

This comment has been minimized.

Copy link
@chrisbobbe

chrisbobbe Feb 25, 2020

Contributor

I'm seeing a Flow error in this commit; it looks like it wasn't caught by CI because CI tests only run on the tip of a branch, and this was one of a selection of non-tip commits merged from #3886.

Error ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ src/__tests__/aux/lolex.js:3:8

Importing from an untyped module makes it any and is not safe! Did you mean to add // @flow to the top of lolex?
(untyped-import)

This comment has been minimized.

Copy link
@chrisbobbe

chrisbobbe Feb 25, 2020

Contributor

(@ray-kraesig)

This comment has been minimized.

This comment has been minimized.

Copy link
@gnprice

gnprice Feb 25, 2020

Member

I don't reproduce locally.

You'll need to run yarn (aka yarn install) if you haven't already, because a new dependency was added. Perhaps that's the symptom of that?

This comment has been minimized.

Copy link
@chrisbobbe

chrisbobbe Feb 25, 2020

Contributor

Ah! This was a false alarm — just running yarn didn't work, but yarn flow stop && yarn flow start fixed the issue. Sorry for this false alarm.


/*
* At present (Jest v24.9.0), Jest does not override Date.now() when using a
* fake timer implementation. This means that any timer-based code relying on
* Date.now() for throttling (etc.) will be very confused.
*
* The good news is that Jest is very close to using Lolex internally -- see,
* e.g., https://github.com/facebook/jest/pull/7776 -- at which point that
* behavior will be available to us via Jest. The bad news, alas, is that it's
* not there yet.
*
* For now, we borrow slices of Jest's planned Lolex-based timer implementation.
*/

/**
* A Lolex-backed implementation of certain relevant Jest functions.
*
* Carved from the more-complete, not-yet-NPM-available implementation at:
* https://github.com/facebook/jest/blob/9279a3a97/packages/jest-fake-timers/src/FakeTimersLolex.ts
*
* Instantiating one of these will switch Jest over to using Lolex's
* `Date.now()` replacement. Calling `.dispose()` on that instantiation will
* remove that. (Behavior in the presence of multiple Lolex instances is not
* defined. Don't do that.)
*
* Users of this class are recommended to use Jest's setup and teardown
* functions, perhaps as follows:
*
* ```
* describe('description', () => {
* const lolex: Lolex = new Lolex();
*
* afterEach(() => { lolex.clearAllTimers(); });
* afterAll(() => { lolex.dispose(); });
*
* // ...tests...
* });
* ```
*/
export class Lolex {
/** The installed Lolex clock object. (Name also taken from Jest's
implementation, for simplicity's sake. */
_clock;

constructor() {
this._clock = LolexModule.install();
}

clearAllTimers(): void {
this._clock.reset();
}

getTimerCount(): number {
return this._clock.countTimers();
}

runOnlyPendingTimers(): void {
this._clock.runToLast();
}

advanceTimersByTime(msToRun: number): void {
this._clock.tick(msToRun);
}

setSystemTime(now?: number): void {
this._clock.setSystemTime(now);
}

dispose(): void {
this._clock.uninstall();
}

/**
* Convenience function; not part of jest-lolex interface.
*
* Per Lolex's implementation, adjusts both the clock and the relative
* timestamps of all timers. This can be used to simulate an environment in
* which timers are entirely stopped while their hosting process is inactive.
*/
unsafeAdvanceOnlyTime(ms: number) {
this.setSystemTime(Date.now() + ms);
}
}
105 changes: 104 additions & 1 deletion src/__tests__/metatests.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @jest-environment jest-environment-jsdom-global */
// @flow strict
// @flow strict-local
import { Lolex } from './aux/lolex';

/**
* This file should not test any part of the application. It exists to test that
Expand Down Expand Up @@ -73,3 +74,105 @@ describe('jsdom-global', () => {
}
});
});

describe('lolex', () => {
const lolex: Lolex = new Lolex();

afterAll(() => {
lolex.dispose();
});

afterEach(() => {
// clear any unset timers
lolex.clearAllTimers();
});

test('Date.now() is mocked by Lolex', () => {
const start = Date.now();
lolex.advanceTimersByTime(5.5e9); // ~63.66 days
const end = Date.now();

const apparentDuration = end - start;

expect(apparentDuration).toBeGreaterThan(5e9);
expect(apparentDuration).toBeLessThan(6e9);
});

test('setInterval is triggered by Lolex', () => {
let count = 0;
setInterval(() => {
++count;
}, 1e9);

lolex.advanceTimersByTime(5.5e9);

expect(count).toBe(5);
});

test('setTimeout is triggered by Lolex', () => {
let flag = false;

setTimeout(() => {
flag = true;
}, 3e6);
setTimeout(() => {
flag = false;
}, 6e6);

lolex.advanceTimersByTime(4.5e6);

expect(flag).toBe(true);
});

test('timer count is properly maintained', () => {
expect(lolex.getTimerCount()).toBe(0);
setInterval(() => {}, 10);
setInterval(() => {}, 10);
setInterval(() => {}, 10);
setInterval(() => {}, 10);
setInterval(() => {}, 10);

expect(lolex.getTimerCount()).toBe(5);

setTimeout(() => {}, 10);
setTimeout(() => {}, 10);
setTimeout(() => {}, 10);
setTimeout(() => {}, 10);
setTimeout(() => {}, 10);

expect(lolex.getTimerCount()).toBe(10);

lolex.advanceTimersByTime(20);
expect(lolex.getTimerCount()).toBe(5);

lolex.clearAllTimers();
expect(lolex.getTimerCount()).toBe(0);
});

test('runOnlyPendingTimers runs timers as expected', () => {
const start = Date.now();

// set up eternal interval
let count = 0;
const interval = 10;
setInterval(() => ++count, interval);

const maxTime = 1000;

// set up several timeouts, of length at most `maxTime`
const timeOuts = 37;
for (const k of Array(timeOuts).keys()) {
setTimeout(() => {}, ((k + 1) / timeOuts) * maxTime);
}

lolex.runOnlyPendingTimers();
const end = Date.now();

// A long time has passed...
expect(end - start).toBeGreaterThanOrEqual(maxTime);
// ... only the interval timer is still active...
expect(lolex.getTimerCount()).toBe(1);
// ... and it has been fired appropriately many times.
expect(count).toBeCloseTo((end - start) / interval, 1);
});
});
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6154,6 +6154,11 @@ loglevel@^1.4.1:
resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.3.tgz#77f2eb64be55a404c9fd04ad16d57c1d6d6b1280"
integrity sha512-LoEDv5pgpvWgPF4kNYuIp0qqSJVWak/dML0RY74xlzMZiT9w77teNAwKYKWBTYjlokMirg+o3jBwp+vlLrcfAA==

lolex@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/lolex/-/lolex-5.1.1.tgz#9587144854511d27940ee5e113dcb7de9b0fd666"
integrity sha512-dEwHz1CJ8DsdgfpiimgQQEhEJYOEiJ69a0s4aJDNHajaTqOJuF34vBAWVa/sS0V8aQvt72p+KgQ3pRmEVJM+iA==

loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1, loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
Expand Down

0 comments on commit 6d1d1df

Please sign in to comment.