Есть ли способ правильно издеваться над селекторами Reselect для модульного тестирования?

У меня довольно сложная структура селекторов в моем проекте (некоторые селекторы могут иметь до 5 уровней вложенности), поэтому некоторые из них очень сложно протестировать с передачей состояния ввода, и вместо этого я хотел бы издеваться над селекторами ввода. Однако я обнаружил, что это на самом деле невозможно.

Вот самый простой пример:

// selectors1.js
export const baseSelector = createSelector(...);

-

// selectors2.js
export const targetSelector = createSelector([selectors1.baseSelector], () => {...});

Что я хотел бы иметь в своем наборе тестов:

beforeEach(() => {
  jest.spyOn(selectors1, 'baseSelector').mockReturnValue('some value');
});

test('My test', () => {
  expect(selectors2.targetSelector()).toEqual('some value');
});

Но этот подход не будет работать, так как targetSelector получает ссылку на selectors1.baseSelector во время инициализации selectors2.js, а mock присваивается selectors1.baseSelector после этого.

Сейчас я вижу 2 рабочих решения:

  1. Смоделируйте весь модуль selectors1.js с помощью jest.mock, однако это не сработает, если мне нужно будет изменить вывод selectors1.baseSelector для некоторых конкретных случаев.
  2. Оберните все селекторы зависимостей следующим образом:

export const targetSelector = createSelector([(state) => selectors1.baseSelector(state)], () => {...});

Но мне такой подход не очень нравится по понятным причинам.

Итак, вопрос следующий: есть ли шанс правильно смоделировать селекторы Reselect для модульного тестирования?


person Eugene Tsakh    schedule 15.04.2019    source источник
comment
Если я правильно понимаю, selectors2 создает новый экземпляр selectors1, поэтому это не тот экземпляр, который вы издевались с помощью шпионского метода, но вы хотели бы, чтобы он был? Одним из вариантов может быть использование внедрения зависимостей, чтобы вы могли инициализировать его с помощью издевательского экземпляра. Другой может заключаться в том, чтобы издеваться над целевым селектором selector2, чтобы вернуть издевательский экземпляр instance1, но похоже, что вы пытаетесь избежать этого по причинам масштабируемости. Мне кажется, что я, возможно, не понимаю всей области проблемы или, может быть, именно того, что делает createSelector . Вы уже коснулись использования макета модуля.   -  person CTS_AE    schedule 17.04.2019


Ответы (4)


Дело в том, что Reselect основан на концепции композиции. Таким образом, вы создаете один селектор из множества других. На самом деле вам нужно протестировать не весь селектор, а последнюю функцию, которая выполняет эту работу. Если нет, то тесты будут дублировать друг друга, так как если у вас есть тесты для селектора1, а селектор1 используется в селекторе2, то автоматически вы тестируете их оба в тестах селектор2.

Чтобы достичь:

  • меньше издевательств
  • нет необходимости специально издеваться над результатом составных селекторов
  • нет дублирования теста

проверить только функцию результата селектора. Он доступен selector.resultFunc.

Так, например:

const selector2 = createSelector(selector1, (data) => ...);

// tests

const actual = selector2.resultFunc([returnOfSelector1Mock]);
const expected = [what we expect];
expect(actual).toEqual(expected)

Резюме

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

person Maciej Sikora    schedule 17.04.2019
comment
Да, это именно то, чего я хотел добиться. Спасибо за помощь! - person Eugene Tsakh; 18.04.2019
comment
Кажется, что вы должны передавать resultFunc столько аргументов, сколько селекторов ввода в вашем селекторе, а не коллекцию, как один аргумент. Так что вместо selector2.resultFunc([returnOfSelector1Mock]); должно быть selector2.resultFunc(returnOfSelector1Mock); - person classicalConditioning; 19.05.2021

Вы можете добиться этого, издеваясь над всем модулем selectors1.js, но также импортируя его внутри теста, чтобы иметь доступ к издевательской функциональности.

Предполагая, что ваш selectors1.js выглядит как

import { createSelector } from 'reselect';

// selector
const getFoo = state => state.foo;

// reselect function
export const baseSelector = createSelector(
  [getFoo],
  foo => foo
);

и selectors2.js выглядит так

import { createSelector } from 'reselect';
import selectors1 from './selectors1';

export const targetSelector = createSelector(
  [selectors1.baseSelector],
  foo => {
    return foo.a;
  }
);

Затем вы можете написать какой-нибудь тест, например

import { baseSelector } from './selectors1';
import { targetSelector } from './selectors2';

// This mocking call will be hoisted to the top (before import)
jest.mock('./selectors1', () => ({
  baseSelector: jest.fn()
}));

describe('selectors', () => {
  test('foo.a = 1', () => {
    const state = {
      foo: {
        a: 1
      }
    };
    baseSelector.mockReturnValue({ a: 1 });
    expect(targetSelector(state)).toBe(1);
  });

  test('foo.a = 2', () => {
    const state = {
      foo: {
        a: 1
      }
    };
    baseSelector.mockReturnValue({ a: 2 });
    expect(targetSelector(state)).toBe(2);
  });
});

Вызов функции jest.mock будет поднят в верхнюю часть модуля, чтобы имитировать модуль selectors1.js. Когда вы import/require selectors1.js, вы получите фиктивную версию baseSelector, поведение которой вы можете контролировать перед запуском теста.

person Mohamed Shaaban    schedule 17.04.2019
comment
Хм. Это интересное решение, однако я считаю ответ Мацея Сикоры лучшим. Единственное, что я бы добавил - поместите запись jest.mock в beforeEach, иначе ваш mock не будет сбрасываться перед каждым тестом. - person Eugene Tsakh; 18.04.2019
comment
Я рад, что вы нашли ответ, который вы ищете. Однако вы не можете поместить запись jest.mock в beforeEach, потому что, как я уже говорил, шутка будет поднять этот вызов в верхнюю часть модуля. Чтобы сбросить макет перед каждым тестом, вы просто делаете baseSelector.mockClear или baseSelector.mockReset в зависимости от того, чего хотите достичь. - person Mohamed Shaaban; 18.04.2019
comment
Хм. Это тоже не очень хорошее решение, потому что некоторые файлы могут иметь десятки селекторов. Однако jest.resetModules() в beforeEach должно помочь - person Eugene Tsakh; 18.04.2019
comment
Вот почему мне не нравится шутливо-насмешливая функциональность. Мне больше нравится sinon с proxyquire - person Mohamed Shaaban; 18.04.2019
comment
Я согласен, что для мокинга могут быть решения получше, но в данном конкретном случае, как оказалось, есть возможность тестировать селекторы без мокинга. Кроме того, Jest не так уж и плох. Это быстрый и довольно гибкий фреймворк, но да, насмешки можно было бы сделать лучше. :) - person Eugene Tsakh; 18.04.2019
comment
Я не мог не согласиться. Знаешь, всегда есть что улучшить ;) - person Mohamed Shaaban; 18.04.2019

Для тех, кто пытается решить эту проблему с помощью Typescript, этот пост — это то, что наконец сработало для меня: https://dev.to/terabaud/testing-with-jest-and-typescript-the-tricky-parts-1gnc

Моя проблема заключалась в том, что я тестировал модуль, который вызывал несколько разных селекторов в процессе создания запроса, и созданное мной состояние redux-mock-store не было видно селекторам при выполнении тестов. В итоге я вообще пропустил фиктивное хранилище и вместо этого смоделировал возвращаемые данные для конкретных вызываемых селекторов.

Процесс таков:

  • Импортируйте селекторы и зарегистрируйте их как шуточные функции:
    import { inputsSelector, outputsSelector } from "../store/selectors";         
    import { mockInputsData, mockOutputsData } from "../utils/test-data";

    jest.mock("../store/selectors", () => ({
      inputsSelector: jest.fn(),
      outputsSelector: jest.fn(),
    }));
  • Затем используйте .mockImplementation вместе с помощником mocked из ts-jest\utils, который позволяет обернуть каждый селектор и дать каждому настраиваемые возвращаемые данные.
    beforeEach(() => {
        mocked(inputsSelector).mockImplementation(() => {
            return mockInputsData;
        });
        mocked(outputsSelector).mockImplementation(() => {
            return mockOutputsData;
        });
    });
  • Если вам нужно переопределить возвращаемое значение по умолчанию для селектора в конкретном тесте, вы можете сделать это внутри определения test() следующим образом:
    test("returns empty list when output data is missing", () => {
        mocked(outputsSelector).mockClear();
        mocked(outputsSelector).mockImplementationOnce(() => {
            return [];
        });
        // ... rest of your test code follows ...
    });
person Nate Peters    schedule 11.06.2020

Я столкнулся с той же проблемой. Я закончил тем, что издевался над reselect createSelector с jest.mock, чтобы игнорировать все, кроме последнего аргумента (который является основной функцией, которую вы хотите протестировать), когда дело доходит до тестирования. В целом этот подход сослужил мне хорошую службу.

Проблема у меня была

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

Почему я использовал этот подход?

В нашей кодовой базе много циклических зависимостей в селекторах. И пытаться переписать и реорганизовать их, чтобы не было циклических зависимостей, слишком много работы. Поэтому я решил смоделировать createSelector, чтобы не тратить время на рефакторинг.

Если ваша кодовая база для селекторов чиста и свободна от зависимостей, обязательно рассмотрите возможность использования reselect resultFunc. Дополнительная документация здесь: https://github.com/reduxjs/reselect#createselectorinputselectors--inputselectors-resultfunc

Код, который я использовал для насмешки над createSelector

// Mock the reselect
// This mocking call will be hoisted to the top (before import)
jest.mock('reselect', () => ({
  createSelector: (...params) => params[params.length - 1]
}));

Затем для доступа к созданному селектору у меня было что-то вроде этого

const myFunc = TargetSelector.IsCurrentPhaseDraft;

Весь код набора тестов в действии

// Mock the reselect
// This mocking call will be hoisted to the top (before import)
jest.mock('reselect', () => ({
  createSelector: (...params) => params[params.length - 1]
}));


import * as TargetSelector from './TicketFormStateSelectors';
import { FILTER_VALUES } from '../../AppConstant';

describe('TicketFormStateSelectors.IsCurrentPhaseDraft', () => {
  const myFunc = TargetSelector.IsCurrentPhaseDraft;

  it('Yes Scenario', () => {
    expect(myFunc(FILTER_VALUES.PHASE_DRAFT)).toEqual(true);
  });

  it('No Scenario', () => {
    expect(myFunc(FILTER_VALUES.PHASE_CLOSED)).toEqual(false);
    expect(myFunc('')).toEqual(false);
  });
});
person Sy Le    schedule 16.08.2019
comment
Зачем вам издеваться над createSelector, если вы можете использовать метод selector resultFunc? - person Eugene Tsakh; 17.08.2019
comment
В нашей кодовой базе много циклических зависимостей, что делает импорт null. Использование описанного выше подхода помогает сохранить код как есть и не трогать его. Я не согласен с тем, что использование resultFunc здесь является лучшим подходом. Но если у вас нет выбора, когда дело доходит до рефакторинга, то мой подход имеет смысл. - person Sy Le; 19.08.2019