В JavaScript, когда вы передаете объект в качестве аргумента функции, функция получает ссылку на исходный объект в памяти, а не на копию объекта. Это означает, что любые изменения, внесенные в объект внутри функции, повлияют и на исходный объект вне функции.

Если вас интересует только решение, один из вариантов описан в конце этой статьи.

Такое поведение иногда может приводить к неожиданным результатам при использовании функций имитации Jest или Vitest, особенно при проверке массива mock.calls. В этом сообщении блога мы рассмотрим, почему mock.calls содержит ссылки на фактические значения, когда функция вызывается дважды с аргументом объекта.

Давайте начнем с примера, чтобы продемонстрировать это поведение. Предположим, у нас есть функция с именем getUserByID, которая принимает объект, представляющий пользователя, и получает некоторые данные из базы данных. Мы хотим протестировать эту функцию, используя функции имитации Jest, чтобы убедиться, что она работает должным образом. Вот наш первоначальный тест:

class UserList {
    constructor(getUserByID) {
        this.getUserByID = getUserByID
    }
    getAll() {
        let indexPara = { id: undefined }
        this.getUserByID(indexPara)
    }
}


it('mockCalls', async () => {

    const getUserByID = vi.fn()

    const ul = new UserList(getUserByID)
    getUserByID.mockImplementation((props) => {
        Promise.resolve(`User ${props.id}`)
    })

    ul.getAll()

    expect(getUserByID).toHaveBeenCalledTimes(1)
    // equal to: expect(getUserByID.mock.calls).toEqual([[{id:0}]])
    expect(getUserByID).toHaveBeenNthCalledWith(1, { id: 0 })
 
})

В этом тесте мы создаем макет функции, используя метод Jest jest.fn(), и передаем его конструктору класса UserList. Затем мы используем сопоставители Jest toHaveBeenNthCalledWith() и toHaveBeenCalledTimes(), чтобы гарантировать, что функция getUserByID была вызвана один раз с правильным объектом с идентификатором пользователя.

Теперь предположим, что мы хотим изменить этот тест, чтобы дважды вызывать getUserByID с другим идентификатором пользователя. Можно было бы ожидать, что массив mock.calls будет содержать ссылки на два отдельных пользовательских объекта, по одному для каждого вызова. Однако давайте посмотрим, что на самом деле происходит, когда мы запускаем модифицированный тест:

class UserList {
    constructor(getUserByID) {
        this.getUserByID = getUserByID
    }
    getAll() {
        let indexPara = { id: 1 }
        this.getUserByID(indexPara)
        indexPara.id = 2
        this.getUserByID(indexPara)
    }
}


it('mockCalls', async () => {

    const getUserByID = vi.fn()

    const ul = new UserList(getUserByID)
    getUserByID.mockImplementation((props) => {
        Promise.resolve(`User ${props.id}`)
    })

    ul.getAll()

    expect(getUserByID).toHaveBeenCalledTimes(2)
    // equal to: expect(getUserByID.mock.calls).toEqual([[{id:0}],[{id:1}]])
    expect(getUserByID).toHaveBeenNthCalledWith(1, { id: 1 }) // AssertionError: expected 1st "spy" call to have been called with \[ { id: +1 } \]
    expect(getUserByID).toHaveBeenNthCalledWith(2, { id: 2 })
 
})

В этом тесте мы дважды вызываем getUserByID с двумя разными пользовательскими объектами, {id:1} и {id:2}. Затем мы используем сопоставители Jest toHaveBeenNthCalledWith() и toHaveBeenCalledTimes(), чтобы убедиться, что updateUser вызывается дважды с правильными аргументами. Наконец, мы исследуем массив mock.calls, чтобы сравнить пользовательские объекты, передаваемые при каждом вызове.

AssertionError: expected 1st "spy" call to have been called with \[ { id: +1 } \]

Ожидается: [ [ {id: 0} ], [ {id: 1} ] ] Получено:[ [ {id: 1} ], [ {id: 1} ] ]

Мы обнаруживаем, что массив mock.calls содержит ссылки на фактические пользовательские объекты, переданные в функцию, а не копии объектов. Это означает, что два элемента в массиве mock.calls являются ссылками на один и тот же объект в памяти, а не на два отдельных объекта с одинаковыми значениями. Что во всех случаях приводит к результату, который возвращает объект, переданный последнему вызову для всех вызовов.

Поначалу такое поведение может удивить, особенно если вы привыкли работать с примитивными типами, такими как строки или числа, которые передаются по значению, а не по ссылке. Тем не менее, важно помнить об этом при работе с фиктивными функциями Jest.

Чтобы обойти это поведение и убедиться, что mock.calls содержит отдельные объекты для каждого вызова, вы можете создать копии объектов перед их передачей в функцию, например так:

Есть два способа исправить проблему:

  1. избегать вызова функции со ссылками на объекты
  2. измените свой тестовый код, чтобы захватить вызовы функций

Для ясности — код, который мы тестировали, работает и никоим образом не сломан! Это наш тест, который не может захватить биты, необходимые для тестирования кода.

Изменить код приложения

Часть кода, которая нарушает тест, находится здесь:

getAll() {
        let indexPara = { id: 1 }
        this.getUserByID(indexPara) // passing a reference to the object
        indexPara.id = 2 // modifying
        this.getUserByID(indexPara) // passing the same reference 
    }

В коде мы передаем ссылку на объект нашей фиктивной функции.

Одним из многих решений является копирование объекта для создания нового объекта:

getAll() {
        let indexPara = { id: 1 }
        this.getUserByID({...indexPara}) // create new object reference on every call to getUserByID
        indexPara.id = 2 // modifying
        this.getUserByID({...indexPara}) // create new object reference for second call
    }

Много раз мы не хотим менять наш код, чтобы исправить проблемы в тестах, и в некоторых случаях часть, которую вы имитируете, вызывается внешней библиотекой.

Исправьте ошибку в тестах

it('mockCalls', async () => {

    const getUserByID = vi.fn()

    const ul = new UserList(getUserByID)
    let mockCalls=[] // 1. create a store for the call history
    getUserByID.mockImplementation((props) => {
        mockCalls.push(props) // 2. push the props to the history
        Promise.resolve(`User ${props.id}`)
    })

    ul.getAll()

    expect(getUserByID).toHaveBeenCalledTimes(2)
    // replace with your own call history
    expect(mockCalls).toEqual([{ id: 1 },{ id: 2 }])
    //expect(getUserByID).toHaveBeenNthCalledWith(1, { id: 1 }) 
    //expect(getUserByID).toHaveBeenNthCalledWith(2, { id: 2 })
 
})
  1. let mockCalls=[] - создать хранилище для истории звонков
  2. mockCalls.push(props) - отправить реквизит в историю
  3. expect(mockCalls).toEqual([{ id: 1 },{ id: 2 }]) - проверить историю звонков

Использованная литература:

Первоначально опубликовано на https://www.heissenberger.at.