Руководство по тестированию React

React.js

Пирамида фронтенд-тестирования

Для веб-приложения идеальный набор тестов должен включать большое количество модульных тестов (модульных тестов), несколько снэпшот-тестов (моментальных тестов) и небольшое количество сквозных тестов (e2e-тестов). Ссылаясь на тестовую пирамиду, мы построили тестовую пирамиду фронтенд-приложения.

image.png

модульный тест

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

снимок теста

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

E2E-тестирование
Эквивалент тестирования черного ящика. Тестировщику не нужно знать, как программа реализована внутри, а нужно только смоделировать сценарии реального использования пользователя для тестирования в соответствии с бизнес-требованиями.

Технический отбор

Тип теста Технический отбор
модульный тест Jest + Enzyme
снимок теста Jest
E2E-тестирование jest-puppeteer

Jest— это среда тестирования Facebook с открытым исходным кодом. Он очень мощный и включает в себя исполнителей тестов, библиотеки утверждений, шпионов, макеты, снимки и отчеты о покрытии тестами.


EnzymeAirbnb — это инструмент модульного тестирования React с открытым исходным кодом. Он расширяет официальные TestUtils React, обрабатывает DOM через jQuery-подобный API, сокращает много повторяющегося кода и может легко подтверждать обработанные результаты.


jest-puppeteerэто инструмент, который включает в себя как Jest, так и Puppeteer.PuppeteerЭто API-интерфейс Headless Chrome Node, официально предоставленный Google, который обеспечиваетDevTools ProtocolИнтерфейс API верхнего уровня, используемый для управления Chrome или Chromium. С помощью Puppeteer мы можем легко выполнять сквозное тестирование.

Стратегия тестирования React

Тестирование — это, по сути, защита кода для обеспечения правильной работы проекта в процессе итерации. Конечно, написание тестов тоже имеет свою стоимость, особенно для сложной логики, написание тестов может занять не меньше времени, чем написание кода. Поэтому мы должны сформулировать разумную стратегию тестирования и написать целевые тесты. Что касается того, какие коды следует тестировать, а какие нет, обычно следуйте принципу:Низкие инвестиции, высокая доходность. «Низкие инвестиции» означают, что тест легко написать, а «высокий доход» означает, что ценность теста высока. Другими словами, это означает, что тестирование должно отдавать приоритет основной логике кода, такой как основной бизнес, базовые модули, базовые компоненты и т. д. В то же время стоимость написания и поддержки тестов не должна быть слишком высокой. Конечно, это идеальная ситуация, и в реальном процессе разработки приходится идти на компромиссы.

модульный тест

Основываясь на характеристиках проектов React и Redux, мы разработали следующие стратегии тестирования:

Классификация Что измерять? Какие сюрпризы?
компоненты * Компоненты с условным отображением (такие как ветки if-else, компоненты связи, компоненты управления разрешениями и т. д.)
* Компоненты взаимодействия с пользователем (такие как Click, форма отправки и т. д.)
* Логические компоненты (такие как компоненты более высокого порядка и компоненты Children Render)
* Компоненты контейнера, сгенерированные при подключении
* Компонент страницы, который просто объединяет дочерние компоненты
* Чистые компоненты дисплея
* Стиль компонента
Reducer Логический редуктор. Например, слияние, удаление состояния. Редьюсеры только для ценности не тестируются. Например
(_, action) => action.payload.data
Middleware Полный тест без
Action Creator без Все неожиданно
метод * validators
* formatters
* другие общедоступные методы
частный метод
общий модуль Полный тест.比如处理 API 请求的模块。 без

Примечание. Если используется TypeScript, ограничения типов могут заменить проверку типов некоторых аргументов функций и возвращаемых значений.

снимок теста

Хотя тест моментальных снимков Jest выполняется быстро, он также может сыграть роль в защите пользовательского интерфейса. Но его сложно поддерживать (в значительной степени зависит от ручного сравнения), а иногда он нестабилен (без изменений пользовательского интерфейса, но изменения className по-прежнему приводят к сбою тестов). Поэтому не рекомендуется для использования в проектах лично. Но для того, чтобы справиться с тестовым покрытием и «придать себе уверенности», вы также можете добавить снэпшот-тесты к следующим частям:

  • Компонент страницы: страница соответствует снимку.
  • Общедоступные компоненты пользовательского интерфейса только для отображения.

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

E2E-тестирование

Охватывает основной бизнес-поток.

Что должен иметь хороший модульный тест?

Безопасный рефакторинг существующего кода

Очень важной ценностью модульного тестирования является сопровождение рефакторинга. Когда вход неизменен, тогда и только тогда, когда "Изменена функция тестируемого бизнес-кода", тест должен провалиться. То есть независимо от того, как вы проводите рефакторинг, ваши тесты никогда не должны давать сбоев.

При написании компонентных тестов мы часто сталкиваемся с такой ситуацией: используем селектор класса css для выбора узла и затем его assert, тогда даже если бизнес-логика не изменилась, переименование класса приведет к зависанию теста. Теоретически такой тест не считается «хорошим тестом», но учитывая его ценность для бизнеса, мы все же напишем несколько таких тестов, но при написании тестов нужно быть внимательным: использовать некоторые тесты, которые непросто изменить. , такие как имя компонента, метка arial и т. д.

Сохранить бизнес-контекст

Мы часто говорим, что тесты — это документы, и да, хороший тест часто может быть очень четким в отношении значения бизнес-формы или кода.

быстрое возвращение

Быстрая регрессия означает, что тест выполняется быстро и стабильно. Чтобы работать быстро, важно имитировать внешние зависимости. Что касается того, как имитировать внешние зависимости, мы подробно объясним позже.

Как писать модульные тесты?

Определенное имя теста

Рекомендуется использоватьBDDметод, то есть тест должен быть приближен к естественному языку, чтобы его было удобно читать каждому члену команды. При написании тестовых случаев вы можете обратиться к AC и попытаться преобразовать AC Give-When-Then в тестовые примеры.

ДАННО: Подготовьте условия тестирования, такие как компоненты рендеринга.
КОГДА: в определенном сценарии, например при нажатии кнопки.
ТОГДА: утверждать

describe("add user", () => {
  it("when I tap add user button, expected dialog opened with 3 form fields", () => {
    // Given: in profile page. 
    // Prepare test env, like render component etc.
    
    // When: button click. 
    // Simulate button click
    
    // Then: display `add user` form, which contains username, age and phone number.
    // Assert form fields length to equal 3
  });
});

Макет внешних зависимостей

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

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

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

spies

Шпионы — это, по сути, функция, которая может записывать информацию о вызовах целевой функции, такую ​​как количество вызовов, переданные параметры, возвращаемое значение и т. д., но не меняет поведение исходной функции. в шуткуmock functionЭто шпионы, такие как наши обычно используемыеjest.fn().

// Example:
onSubmit() {
  // some other logic here
  this.props.dispatch("xxx_action");
}

// Example Test:
it("when form submit, expected dispatch function to be called", () => {
  const mockDispatch = jest.fn();
  
  mount(<SomeComp dispatch={mockDispatch}/>);
  // simlate submit event here 
  expect(mockDispatch).toBeCalledWith("xxx_action");
  expect(mockDispatch).toBeCalledTimes(1);
});

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

// Example:
const video = {
  play() {
    return true;
  },
};

// Example Test:
test('plays video', () => {
  const spy = jest.spyOn(video, 'play');
  const isPlaying = video.play();

  expect(spy).toHaveBeenCalled();
  expect(isPlaying).toBe(true);

  spy.mockRestore();
});

stubs

Заглушки похожи на шпионов, но в отличие от шпионов заглушки заменяют целевую функцию. То есть, если используются шпионы, исходная функция все равно будет вызываться, но с заглушками исходная функция выполняться не будет. Заглушки обеспечивают четкие границы теста. Его можно использовать в следующих сценариях:

  • Замените внешние функции, такие как ajax, которые усложняют или замедляют тесты.
  • Проверка исключительных условий, таких как создание исключения.

Аналогичный API также доступен в Jest. jest.spyOn().mockImplementation(),следующим образом:

const spy = jest.fn();
const payload = [1, 2, 3];

jest
  .spyOn(jQuery, "ajax")
  .mockImplementation(({ success }) => success(payload));

jQuery.ajax({
  url: "https://example.api",
  success: data => spy(data)
});

expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(payload);

mocks

Моки относятся к замене целевых объектов пользовательскими объектами. Мы можем не только имитировать возвраты API и пользовательские классы, но также можем имитировать модули npm и многое другое.

// mock middleware api
const mockMiddlewareAPI = {
  dispatch: jest.fn(),
  getState: jest.fn(),
};

// mock npm module `config`
jest.mock("config", () => {
  return {
    API_BASE_URL: "http://base_url",
  };
});

При использовании моков помните о:

  • Если вы имитируете зависимости модуля, вам нужно дождаться завершения макета, прежде чем запрашивать модуль.

Есть следующий код:

// counter.ts
let count = 0;

export const get = () => count;
export const inc = () => count++;
export const dec = () => count--;

Неправильный способ:

// counter.test.ts
import * as counter from "../counter";

describe("counter", () => {
  it("get", () => {
    jest.mock("../counter", () => ({
      get: () => "mock count",
    }));
    expect(counter.get()).toEqual("mock count"); // 测试失败,此时的 counter 模块并非 mock 之后的模块。
  });
});

Правильный путь:

describe("counter", () => {
  it("get", () => {
    jest.mock("../counter", () => ({
      get: () => "mock count",
    }));
    const counter = require("../counter"); // 这里的 counter 是 mock 之后的 counter
    expect(counter.get()).toEqual("mock count"); // 测试成功
  });
});
  • Когда несколько тестов имеют общее состояние, модуль необходимо сбрасывать после завершения каждого теста. jest.resetModules(). Это очистит кеш всех необходимых модулей, чтобы обеспечить изоляцию между модулями.

Неправильный способ:

describe("counter", () => {
  it("inc", () => {
    const counter = require("../counter");
    counter.inc();
    expect(counter.get()).toEqual(1);
  });

  it("get", () => {
    const counter = require("../counter"); // 这里的 counter 和上一个测试中的 counter 是同一份拷贝
    expect(counter.get()).toEqual(0); // 测试失败
    console.log(counter.get()); // ? 输出: 1
  });
});

Правильный путь:

describe("counter", () => {
  afterEach(() => {
    jest.resetModules(); // 清空 required modules 的缓存
  });
  
  it("inc", () => {
    const counter = require("../counter");
    counter.inc();
    expect(counter.get()).toEqual(1);
  });

  it("get", () => {
    const counter = require("../counter"); // 这里的 counter 和上一个测试中的 counter 是不同的拷贝
    expect(counter.get()).toEqual(0); // 测试成功
    console.log(counter.get()); // ? 输出: 0
  });
});

Измените код, чтобы получить значение count по умолчанию из внешнего модуля defaultCount.

// defaultCount.ts
export const defaultCount = 0;

// counter.ts
import {defaultCount} from "./defaultCount";

let count = defaultCount;

export const inc = () => count++;
export const dec = () => count--;
export const get = () => count;

Тестовый код:

import * as counter from "../counter"; // 首次导入 counter 模块
console.log(counter); 

describe("counter", () => {
  it("inc", () => {
    jest.mock("../defaultCount", () => ({
      defaultCount: 10,
    }));
    const counter1 = require("../counter"); // 再次导入 counter 模块
    
    counter1.inc();
    
    expect(counter1.get()).toEqual(11); // 测试失败
    console.log(counter1.get()); // 输出: 1
  });
});

Когда вы снова требуете counter, обнаруживается, что модуль был запрошен, и он получен непосредственно из кеша, поэтому counter1 по-прежнему использует контекст counter, то есть defaultCount = 0. А вызов resetModules() очистит кеш и снова вызовет функцию модуля.

В приведенном выше коде закомментируйте строки 1 и 2, и тест также завершится успешно. Вы можете подумать, почему?

писать тесты

тестирование компонентов

визуализировать компонент

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

Поверхностный рендеринг

shallowМетод будет отображать компонент в объект Virtual DOM, будет отображаться только первый слой в компоненте, а его подкомпоненты не будут отображаться, поэтому нет необходимости заботиться о DOM и среде выполнения, и тест выполняется очень быстро. быстро.

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

Поверхностный рендеринг также имеет присущие ему недостатки, поскольку он может отображать только узлы первого уровня. Что делать, если вы хотите протестировать дочерние узлы и не хотите их полностью отображать?shallowОн также предоставляет очень полезный интерфейс.dive, через который вы можете получить структуру React DOM дочерних узлов-оболочек.

Образец кода:

export const Demo = () => (
  <CompA>
    <Container><List /></Container>
  </CompA>
);

использоватьshallowПолучается следующая структура:

<CompA>
  <Container />
</CompA>

использовать.dive()После этого получается следующая структура:

<div>
  <Container>
  	<List />
  </Container>
</div>
Полный рендеринг (полный рендеринг DOM)

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

Для полного метода рендеринга требуется среда браузера, но Jest уже предоставляет ее, среду выполнения по умолчанию.jsdom, который представляет собой среду браузера JavaScript. Следует отметить, что если несколько тестов зависят от одного и того же DOM, они могут влиять друг на друга, поэтому после каждого теста лучше всего использовать.unmount()помыть.

Статическая визуализация

Отобразите компонент как статическую строку HTML, затем используйтеCheerioПроанализируйте его и верните объект экземпляра Cheerio, который можно использовать для анализа HTML-структуры компонента.

Тест условного рендеринга

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

import React, { ReactNode } from "react";

const Container = ({ children }: { children: ReactNode }) => <div aria-label="container">{children}</div>;
const CompA = ({ children }: { children: ReactNode }) => <div>{children}</div>;
const List = () => <div>List Component</div>;

interface IDemoListProps {
  list: string[];
}

export const DemoList = ({ list }: IDemoListProps) => (
  <CompA>
    <Container>{list.length > 0 ? <List /> : null}</Container>
  </CompA>
);

Для условного рендеринга есть две идеи:

  • Проверьте, что отображается правильный узел

Общий подход состоит в том, чтобы отобразить компонент DemoList, а затем проверить, отображается ли правильный узел в соответствии с различными условиями.

describe("DemoList", () => {
  it("when list length is more than 0, expected to render List component", () => {
    const wrapper = shallow(<DemoList list={["A", "B", "C"]} />);
    expect(
      wrapper
        .dive()
        .find("List")
        .exists(),
    ).toBe(true);
  });

  it("when list length is more than 0, expected to render null", () => {
    const wrapper = shallow(<DemoList list={[]} />);
    expect(
      wrapper
        .dive()
        .find("[aria-label='container']")
        .children().length,
    ).toBe(0);
  });
});
  • Общие компоненты + только условия тестовой оценки

Мы можем абстрагировать общий компонент<Show/>, используемый для всех условно отображаемых компонентов. Этот компонент принимаетcondition, при удовлетворении этогоconditionОтобразите узел, если он не удовлетворен, и отобразите другой узел, если он не удовлетворен.

<Show condition={}  ifNode={} elseNode={} />

Мы можем добавить тесты к этому компоненту, чтобы убедиться, что правильные узлы отображаются в разных условиях. Поскольку эта логика гарантирована, используйте<Show/>компоненты не нуждаются в повторной проверке. Поэтому нам просто нужно проверить, правильно ли он сгенерирован.conditionВот и все.

export const shouldShowBtn = (a: string, b: string, c: string) => a === b || b === c;
describe("should show button or not", () => {
  it("should show button", () => {
    expect(shouldShowBtn("x", "x", "x")).toBe(true);
  });
  it("should hide button", () => {
    expect(shouldShowBtn("x", "y", "z")).toBe(false);
  });
});

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

Проверка взаимодействия с пользователем

Распространенными являются события кликов, отправка форм, проверка и т. д.

  • Щелчок по событию click.
  • onSubmit. в основном тестированиеonSubmitПроизошло ли правильное поведение, такое как действие отправки, после вызова метода.
  • validate. Главное — проверить, чтобы сообщения об ошибках отображались в правильном порядке.

Тест создателя действий

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

Неправильный способ:

// action.ts
export const getList = createAction("@@list/getList", (reqParams: any) => {
  const params = formatReqParams({
    ...reqParams,
    page: reqParams.page + 1,
    startDate: formatStartDate(reqParams.startDate)
    endDate: formatStartDate(reqParams.endDate)
  });
  
  return {
    url: "/api/list",
    method: "GET",
    params,
  };
});

Правильно:

// action.ts
export const getList = createAction("@@list/getList", (params: any) => {
  return {
    url: "/api/list",
    method: "GET",
    params,
  };
});

// 调用 action creator 时,先把值计算好,再传给 action creator。

// utils.ts
const formatReqParams = (reqParams: any) => {
return formatReqParams({
    ...reqParams,
    page: reqParams.page + 1,
    startDate: formatStartDate(reqParams.startDate)
    endDate: formatStartDate(reqParams.endDate)
  });
};

// page.ts
getFeedbackList(formatReqParams({}));

Тест редуктора

Тест Reducer в основном предназначен для проверки того, «сгенерировано ли правильное состояние в соответствии с действием и состоянием». Поскольку редюсеры — это чистые функции, тесты писать очень легко, поэтому я не буду здесь вдаваться в подробности.

Тест промежуточного ПО

Самая важная вещь для тестирования промежуточного программного обеспечения — это имитировать внешние зависимости, в том числеmiddlewareAPI а такжеnext.

Test Helper:

class MiddlewareTestHelper {
  static of(middleware: any) {
    return new MiddlewareTestHelper(middleware);
  }

  constructor(private middleware: Middleware) {}

  create() {
    const middlewareAPI = {
      dispatch: jest.fn(),
      getState: jest.fn(),
    };
    const next = jest.fn();
    const invoke$ = (action: any) => this.middleware(middlewareAPI)(next)(action);

    return {
      middlewareAPI,
      next,
      invoke$,
    };
  }
}

Example Test:

it("should handle the action", () => {
  const { next, invoke$ } = MiddlewareTestHelper.of(testMiddleware()).create();
  invoke$({
    type: "SOME_ACTION",
    payload: {},
  });
  expect(next).toBeCalled();
});

Тестировать асинхронный код

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

  • функция обратного вызова done()
  • return promise
  • async/await

Неправильный способ:

test('the data is peanut butter', () => {
  function callback(data) {
    expect(data).toBe('peanut butter');
  }

  fetchData(callback);
});

Правильно:

test('the data is peanut butter', done => {
  function callback(data) {
    expect(data).toBe('peanut butter');
    done();
  }

  fetchData(callback);
});
test('the data is peanut butter', () => {
  expect.assertions(1);
  return fetchData().then(data => {
    expect(data).toBe('peanut butter');
  });
});
test("the data is peanut butter", async () => {
  const data = await fetchData();
  expect(data).toBe("peanut butter");
});

выполнить тест

Примите подход «красно-зеленый», то есть пусть тест сначала не пройден, а затем модифицируйте код, чтобы пройти тест, чтобы убедиться, что утверждение выполнено.

Как написать снэпшот-тест?

Через redux-mock-store подготовьте все данные, требуемые компонентом (подготовьте состояние для mock store), а затем протестируйте его.

Переосмысление дизайна приложения с точки зрения тестирования

Предпосылкой «хорошего тестирования» является «хороший код». Таким образом, мы можем подумать о дизайне всего приложения с точки зрения тестирования, чтобы сделать компоненты более «тестируемыми».

  • единая ответственность.Компонент делает только один тип вещей, уменьшая сложность. Пока каждая маленькая часть может быть правильно проверена и объединена для завершения общей функции, при тестировании нужно сосредоточиться только на каждой маленькой части.
  • Хорошее повторное использование.То есть при повторном использовании логики повторно используется и тест.
  • Гарантируйте минимум полезного, а затем постепенно увеличивайте функцию.Это то, что мы обычно называем TDD.
  • ...

Debug

console.log(wrapper.debug());

Справочная статья

原-Введение в Sinon: тестирование javascript с помощью Mocks, Spies и Stubs
Модульное тестирование React с помощью Jest
Модульный тест компонентов React
How to Rethink Your Testing
Тестирование компонентов React (Native) с помощью Enzyme
Исследование принципа модуляризации Node.js
Смысл, практика, опыт модульного тестирования
Стратегия тестирования реагирования и посадки