Зависящее от времени тестирование в Jest

Тестирование кода JavaScript, зависящего от времени, может быть проблематичным, поскольку вы хотите удалить любые фактические задержки в своих тестах и ​​предсказуемо контролировать, когда тесты запускаются. Jest предоставляет некоторую функциональность для решения этой проблемы, но ее необходимо использовать правильно. Взаимодействие с системой для получения текущей даты / времени также является сложной задачей для целей тестирования, но становится проще, если этот доступ инкапсулируется в ее собственный модуль. Показанные здесь методы позволяют писать быстрые тесты, которые предсказуемо контролируют время.

Пример кода для этой статьи доступен по адресу https://github.com/kbwood/testtime.

Простые таймауты

Предположим, у вас есть компонент всплывающего сообщения, который ненадолго отображает некоторый текст на странице.

Компонент просто принимает текст сообщения и флаг для управления его видимостью, тогда как соответствующий контейнер управляет этим флагом видимости. При изменении сообщения компонент становится видимым и через некоторое время снова исчезает. Вы можете использовать для этого setTimeout с соответствующей задержкой, как показано в коде контейнера ниже.

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)

Для тестирования отложенного кода вы используете поддельные таймеры, предоставляемые Jest, которые позволяют вам управлять часами для вызовов setTimeout и setInterval (среди прочего). Перед запуском любого из ваших тестов вам необходимо включить фальшивые таймеры с помощью вызова jest. UseFakeTimers. Убедитесь, что в конце ваших тестов вы восстановили статус-кво, вызвав jest. UseRealTimers.

В рамках теста вы инициализируете контейнер, а затем запускаете обработку тайм-аута, изменяя свойство text. Вы управляете внутренними часами с помощью функции jest. AdvanceTimersByTime, чтобы настроить время на указанное количество миллисекунд. В этом случае вы проверяете, что сообщение сразу видно, а затем переходите непосредственно перед таймаутом (MESSAGE_DELAY -1), чтобы убедиться, что оно все еще отображается. Затем добавьте еще одну миллисекунду, чтобы убедиться, что она исчезнет.

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)
    })
})

Если вас не интересует фактическая длина тайм-аута, вы можете просто запустить все невыполненные таймеры, вызвав jest. RunAllTimers, а затем проверить результаты. Обратите внимание, что эта функция запускает любые существующие тайм-ауты или установленные интервалы, а также вызывает любые дополнительные таймеры, которые они могут создать. В этом тесте (альтернативе описанному выше) вы снова проверяете, что сообщение сразу видно при изменении текста, а затем скрывается после истечения таймера.

    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 по всему коду, это упрощает централизацию этого процесса, упрощая тестирование кода, который от него зависит, с помощью имитации.

const getDateTime = (...args) => new Date(...args)

export { getDateTime }

Вы можете протестировать эту функцию изолированно, имитируя вызовы конструктора Date с помощью jest. SpyOn. Не забудьте восстановить исходную Date в конце ваших тестов, вызвав mockRestore для вашей имитируемой функции. Вам необходимо сохранить ссылку на настоящую Date, чтобы вы могли создавать соответствующие объекты, даже если вы имитировали конструктор. Используйте mockImplementationOnce, чтобы контролировать, как ваш макет реагирует на следующий его вызов.

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))
    })
})

Повторяющиеся интервалы

Чтобы использовать эту новую функцию getDateTime и продемонстрировать тестирование вызова setInterval, вы можете создать компонент часов, который обновляется каждую секунду для отображения текущего времени.

Как и раньше, компонент просто принимает время, которое будет показано, и отображает его, тогда как соответствующий контейнер контролирует регулярное обновление этого времени. После монтирования контейнер запускает периодический обратный вызов, который извлекает текущую дату / время и устанавливает их для базового компонента. Убедитесь, что вы сбрасываете таймер при отключении компонента, чтобы избежать утечки памяти.

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)

Тестирование аналогично примеру setTimeout. Однако вы имитируете вызов getDateTime, чтобы получить контроль над значениями, которые он возвращает (использование jest. Mock автоматически возвращается в конце теста). Хотя вы управляете таймером с помощью вызова advanceTimersByTime, он не обновляет фактические системные часы, поэтому вам нужно увеличивать текущее время параллельно. Чтобы облегчить этот процесс, вы имитируете вызов getDateTime, чтобы вернуть переменную, содержащую заданную Date, а затем при необходимости манипулируете этим значением.

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))
    })
})

Как и в первом примере, если вас не интересует фактический период времени, вы можете запустить все таймеры и проверить их результаты. Однако, поскольку вы используете setInterval, каждое истечение срока действия таймера фактически создает новый таймер для следующего периода. Таким образом, вы не можете вызвать функцию jest.runAllTimers, используемую ранее, поскольку она будет работать вечно. Вместо этого вы должны вызвать jest. RunOnlyPendingTimers, который просто запускает те таймеры, которые уже были вызваны, но не новые, которые они могут создать.

    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))
    })

Кадры анимации

Если вы выполняете обновления пользовательского интерфейса для прокрутки или анимации, вы можете использовать requestAnimationFrame для планирования этих изменений между перерисовками. Это отлично подходит для производительности внешнего интерфейса, но сложнее для тестирования, поскольку обновления происходят асинхронно.

Самый простой способ справиться с этими вызовами - сделать их синхронными для целей ваших тестов. Вы имитируете requestAnimationFrame, чтобы немедленно вызвать переданную ему функцию обратного вызова. Не забудьте восстановить его первоначальную функциональность по окончании тестирования.

beforeAll(() => {
    jest.spyOn(window, 'requestAnimationFrame').
        mockImplementation(cb => cb())
})

afterAll(() => {
    window.requestAnimationFrame.mockRestore()
})

Заключение

Написание тестов для зависящего от времени кода, которые были бы быстрыми и предсказуемыми, может быть проблемой. К счастью, Jest предоставляет инструменты для работы с этими вызовами - вы берете на себя таймер и точно контролируете, как он тикает. Наряду со способами инкапсуляции Date для лучшего контроля тестирования вы теперь хозяин времени.