A Matter of Time
Time-dependent testing in Jest
Testing JavaScript code that has time-dependencies can be problematic since you want to remove any actual delays from your tests and predictably control “when” the tests are run. Jest provides some functionality to handle this but it must be used correctly. Interacting with the system to obtain the current date/time is also challenging for testing purposes but becomes simpler if this access is encapsulated within its own module. The techniques shown here enable you to write fast tests that predictably control time.
The sample code for this article is available at https://github.com/kbwood/testtime.
Simple timeouts
Suppose you have a popup message component that briefly displays some text on the page.
The component simply accepts the message text and a flag to control its visibility, whereas the corresponding container manages that visibility flag. When the message changes the component becomes visible and after a certain time it disappears again. You can use a setTimeout for this, with the appropriate delay, as shown in the container code below.
import { compose, lifecycle, mapProps, withState } from 'recompose'
import Message from '../components/Message'
const MESSAGE_DELAY = 3000
const enhance = compose(
withState('visible', 'setVisible', false),
lifecycle({
componentWillReceiveProps({ setVisible, text }) {
if (text && text !== this.props.text) {
setVisible(true)
clearTimeout(this.timer)
this.timer = setTimeout(() => {
setVisible(false)
}, MESSAGE_DELAY)
}
},
componentWillUnmount() {
clearTimeout(this.timer)
}
}),
mapProps(({ setVisible, ...otherProps }) => otherProps)
)
export default enhance(Message)
To test the delayed code you use the fake timers provided by Jest, which allow you to take control of the clock for setTimeout and setInterval calls (amongst others). Before you run any of your tests you need to turn the fake timers on with a call to jest.useFakeTimers. Make sure that at the end of your tests you restore the status quo by calling jest.useRealTimers.
Within your test you initialise the container and then trigger the timeout processing by changing the text property. You control the internal clock through the jest.advanceTimersByTime function to adjust the time by a specified number of milliseconds. In this case you check that the message is immediately visible, then jump to just before the timeout (MESSAGE_DELAY -1) to check that it is still visible. Then add one more millisecond to verify that it disappears.
import React from 'react'
import { mount } from 'enzyme'
import Message from '../components/Message'
import MessageContainer from './Message'
const MESSAGE_DELAY = 3000
describe('(Container) Message', () => {
beforeAll(() => {
jest.useFakeTimers()
})
afterAll(() => {
jest.useRealTimers()
})
it('should display a new message for a specific period', () => {
const container = mount(<MessageContainer text="" />)
expect(container.find(Message).prop('visible')).toBe(false)
container.setProps({ text: 'message' })
container.update()
expect(container.find(Message).prop('visible')).toBe(true)
jest.advanceTimersByTime(MESSAGE_DELAY - 1)
container.update()
expect(container.find(Message).prop('visible')).toBe(true)
jest.advanceTimersByTime(1)
container.update()
expect(container.find(Message).prop('visible')).toBe(false)
})
})
If you’re not concerned with the actual length of the timeout you can simply run all outstanding timers by calling jest.runAllTimers and then check the results. Note that this function runs any existing timeouts or intervals that are set, and also calls any further timers that these may create. In this test (an alternative to the one above) you again check that the message is immediately visible upon changing the text and then is hidden after the timer has expired.
it('should display a new message for a short time', () => {
const container = mount(<MessageContainer text="" />)
expect(container.find(Message).prop('visible')).toBe(false)
container.setProps({ text: 'message' })
container.update()
expect(container.find(Message).prop('visible')).toBe(true)
jest.runAllTimers()
container.update()
expect(container.find(Message).prop('visible')).toBe(false)
})
Date objects
Sometimes you need to get the current date and time for display or logging.
Although you can sprinkle calls to Date throughout your code, it simplifies matters to centralise that process, making it easier to test the code that depends on it through mocking.
const getDateTime = (...args) => new Date(...args)
export { getDateTime }
You can test this function in isolation by mocking the calls to the Date constructor through jest.spyOn. Don’t forget to restore the original Date at the end of your tests by calling mockRestore on your mocked function. You need to keep a reference to the real Date so that you can create the appropriate objects even though you’ve mocked the constructor. Use mockImplementationOnce to control how your mock responds to the next call to it.
import { getDateTime } from './DateTime'
describe('(Utils) getDateTime', () => {
const RealDate = Date
beforeAll(() => {
jest.spyOn(window, 'Date')
})
afterAll(() => {
window.Date.mockRestore()
})
it('should return the current date', () => {
const now = new RealDate()
Date.mockImplementationOnce(() => now)
expect(getDateTime()).toEqual(now)
})
it('should return the specified date', () => {
Date.mockImplementationOnce((y, mo, d, h, m, s) =>
new RealDate(y, mo, d, h, m, s))
expect(getDateTime(2019, 1-1, 2, 3, 4, 5)).toEqual(
new RealDate(2019, 1-1, 2, 3, 4, 5))
})
})
Repeating intervals
To make use of this new getDateTime function, and demonstrate the testing of a setInterval call, you can create a clock component that updates every second to display the current time.
As before, the component just accepts the time to be shown and renders it, whereas the corresponding container controls the regular updating of that time. Once mounted, the container starts a periodic callback that retrieves the current date/time and sets it for the underlying component. Ensure that you clear the timer when the component is unmounted to avoid a memory leak.
import { compose, lifecycle, mapProps, withState } from 'recompose'
import { getDateTime } from '../utils/DateTime'
import Clock from '../components/Clock'
const TIMER_DELAY = 1000
const enhance = compose(
withState('time', 'setTime', () => getDateTime()),
lifecycle({
componentDidMount() {
this.timer = setInterval(() => {
this.props.setTime(getDateTime())
}, TIMER_DELAY)
},
componentWillUnmount() {
clearInterval(this.timer)
}
}),
mapProps(({ setTime, ...otherProps }) => otherProps)
)
export default enhance(Clock)
Testing is similar to the setTimeout example. However, you mock out the getDateTime call to gain control over the values that it returns (using jest.mock automatically reverts at the end of the test). Although you control the timer through the advanceTimersByTime call, that doesn’t update the actual system clock, so you need to increment the current time in parallel. To facilitate this process you mock the getDateTime call to return a variable containing a given Date and then manipulate that value as necessary.
import React from 'react'
import { mount } from 'enzyme'
import Clock from '../components/Clock'
import { getDateTime } from '../utils/DateTime'
import ClockContainer from './Clock'
jest.mock('../utils/DateTime')
const TIMER_DELAY = 1000
describe('(Container) Clock', () => {
let currentTime
beforeAll(() => {
getDateTime.mockImplementation(() => currentTime)
jest.useFakeTimers()
})
afterAll(() => {
jest.useRealTimers()
})
beforeEach(() => {
currentTime = new Date(2019, 1-1, 2, 3, 4, 5)
})
it('should update the clock as time passes', () => {
const container = mount(<ClockContainer />)
const clock = container.find(Clock) expect(clock.prop('time')).toEqual(
new Date(2019, 1-1, 2, 3, 4, 5))
currentTime.setTime(currentTime.getTime() + TIMER_DELAY)
jest.advanceTimersByTime(TIMER_DELAY)
expect(clock.prop('time')).toEqual(
new Date(2019, 1-1, 2, 3, 4, 6))
currentTime.setTime(currentTime.getTime() + 5 * TIMER_DELAY)
jest.advanceTimersByTime(5 * TIMER_DELAY)
expect(clock.prop('time')).toEqual(
new Date(2019, 1-1, 2, 3, 4, 11))
})
})
As in the first example, if you’re not concerned with the actual time period you can run all the timers and check their results. However, since you are using setInterval, each timer expiry effectively creates a new timer for the next period. So you can’t call the jest.runAllTimers function used previously as that would run forever. Instead you must call jest.runOnlyPendingTimers, which just runs those timers that have already been invoked, but no new ones that they may create.
it('should update the clock every second', () => {
const container = mount(<ClockContainer />)
const clock = container.find(Clock)
expect(clock.prop('time')).toEqual(
new Date(2019, 1-1, 2, 3, 4, 5))
currentTime.setTime(currentTime.getTime() + TIMER_DELAY)
jest.runOnlyPendingTimers()
expect(clock.prop('time')).toEqual(
new Date(2019, 1-1, 2, 3, 4, 6))
currentTime.setTime(currentTime.getTime() + TIMER_DELAY)
jest.runOnlyPendingTimers()
expect(clock.prop('time')).toEqual(
new Date(2019, 1-1, 2, 3, 4, 7))
})
Animation frames
If you are performing UI updates for scrolling or animations you might be using requestAnimationFrame to schedule these changes between repaints. That’s great for front-end performance, but harder for testing since the updates happen asynchronously.
The simplest way to deal with these calls is to make them synchronous for the purposes of your tests. You mock requestAnimationFrame to immediately invoke the callback function passed to it. Don’t forget to restore its original functionality at the end of your tests.
beforeAll(() => {
jest.spyOn(window, 'requestAnimationFrame').
mockImplementation(cb => cb())
})
afterAll(() => {
window.requestAnimationFrame.mockRestore()
})
Conclusion
Writing tests for time-dependent code that are fast and predictable can be a challenge. Fortunately Jest provides tools for dealing with these calls — you take over the timer clock and control exactly how it ticks. Along with ways to encapsulate Date for better testing control, you are now a master of time.