Визуальное регрессионное тестирование с Chromatic, Storybook и Mock Service Workers

В этой статье вы узнаете, как настроить Chromatic и организовать визуальное регрессионное тестирование в вашем приложении. Вот итоговая база кода на GitHub.

  • Chromatic — это облачный набор инструментов для сборника рассказов, который позволяет публиковать сборник рассказов, тестировать и просматривать пользовательский интерфейс. Мы автоматизируем визуальное регрессионное тестирование с помощью Chromatic и GitHub Actions.
  • MSW — библиотека, которая имитирует запросы API, перехватывая их на сетевом уровне. MSW позволяет вашему приложению работать без имитации определенного кода в вашей кодовой базе. Мы будем использовать сервер MSW для Storybook и модульных тестов.

Зависимости

Вот зависимости, которые нужно захватить:

  • следующий@13.0.6
  • реагировать@18.2.0
  • аполлон/клиент@3.7.2
  • graphql-codegen/[email protected]
  • мсв@0.49.2
  • сборник рассказов@v6

Обзор

Предположим, у нас есть приложение, созданное Next.js и сервером GraphQL API. Для начала я настроил приложение, которое установило GraphQL, apollo-client и graphql-codegen. Подробности можно посмотреть в этом PR.

Наше приложение делает запрос API на сервер countries.revorblades.com и получает список стран, как показано ниже. Мы проведем визуальный регрессионный тест для этой страницы.

Вот обзор реализации.

  • Настройка MSW
  • Установить сборник рассказов
  • Установите плагин storybook-addon-next-router
  • Установите плагин msw-storybook-addon.
  • Создавайте истории
  • Настройка хроматического
  • Автоматизируйте проверку пользовательского интерфейса с помощью GitHub Actions
  • Модульные тесты

Настройка MSW

Начнем с установки пакета msw в наш проект.

yarn add -D msw

Мы также установим пакеты deepmerge и utility-types для гибкого расширения фиктивных данных.

yarn add -D deepmerge utility-types

Чтобы модули, связанные с имитацией API, находились в одном каталоге, мы создадим каталог src/mocks с помощью следующей команды:

mkdir src/mocks

Чтобы определить макет для запросов GraphQL, мы создадим каталог src/mocks/queries:

mkdir src/mocks/queries

В нашем приложении у нас есть такой запрос Countries:

query Countries($filer: CountryFilterInput) {
  countries(filter: $filer) {
    name
    native
    capital
    emoji
    currency
    languages {
      code
      name
    }
  }
}

Чтобы обработать запрос GraphQL, нам нужен распознаватель, который возвращает фиктивный ответ и создает фиктивные данные. Чтобы сгруппировать модули в одну папку, мы будем структурировать файлы следующим образом:

src/mocks/
└── queries
    ├── countries
    │   ├── countriesQuery.ts
    │   ├── data.ts
    │   ├── index.ts
    │   └── type.ts
    └── handlers.ts

Создать src/mocks/queries/countries/countriesQuery.ts:

import { graphql } from 'msw'
import { data } from './data'
import { Options, Query } from './type'

export const countriesQuery = (options?: Options) => {
  return graphql.query<Query>('Countries', (_, res, ctx) => {
    if (options?.networkError) {
      return res(
        ctx.errors([
          {
            message: 'Network request failed',
            graphQLErrors: [],
            networkError: new Error('error'),
            errorMessage: 'error',
            extraInfo: {},
          },
        ]),
      )
    }

    return res(ctx.data(data(options?.res, options?.deepMergeOptions)))
  })
}

countriesQuery возвращает преобразователь ответа на запрос Countries, который возвращает имитированные данные через функцию res.

Создайте src/mocks/queries/countries/type.ts:

import deepmerge from 'deepmerge'
import { CountriesQuery as Query } from 'src/graphql/types/index.mock'
import { DeepPartial } from 'utility-types'

export type Response = DeepPartial<Query>
export type { CountriesQuery as Query } from 'src/graphql/types/index.mock'

export type Options = {
  res?: Response
  networkError?: boolean
  deepMergeOptions?: deepmerge.Options
}

И создайте src/mocks/queries/countries/data.ts:

import deepmerge from 'deepmerge'
import { Response, Query } from './type'

export const data = (
  options?: Response,
  deepMergeOptions?: deepmerge.Options,
): Query =>
  deepmerge<Query>(
    {
      __typename: 'Query',
      countries: [
        {
          name: 'Andorra',
          native: 'Andorra',
          capital: 'Andorra la Vella',
          emoji: '🇦🇩',
          currency: 'EUR',
          languages: [{ code: 'ca', name: 'Catalan', __typename: 'Language' }],
          __typename: 'Country',
        },
        {
          name: 'United Arab Emirates',
          native: 'دولة الإمارات العربية المتحدة',
          capital: 'Abu Dhabi',
          emoji: '🇦🇪',
          currency: 'AED',
          languages: [{ code: 'ar', name: 'Arabic', __typename: 'Language' }],
          __typename: 'Country',
        },
        {
          name: 'Afghanistan',
          native: 'افغانستان',
          capital: 'Kabul',
          emoji: '🇦🇫',
          currency: 'AFN',
          languages: [
            { code: 'ps', name: 'Pashto', __typename: 'Language' },
            { code: 'uz', name: 'Uzbek', __typename: 'Language' },
            { code: 'tk', name: 'Turkmen', __typename: 'Language' },
          ],
          __typename: 'Country',
        },
        {
          name: 'Antigua and Barbuda',
          native: 'Antigua and Barbuda',
          capital: "Saint John's",
          emoji: '🇦🇬',
          currency: 'XCD',
          languages: [{ code: 'en', name: 'English', __typename: 'Language' }],
          __typename: 'Country',
        },
        {
          name: 'Anguilla',
          native: 'Anguilla',
          capital: 'The Valley',
          emoji: '🇦🇮',
          currency: 'XCD',
          languages: [{ code: 'en', name: 'English', __typename: 'Language' }],
          __typename: 'Country',
        },
        {
          name: 'Albania',
          native: 'Shqipëria',
          capital: 'Tirana',
          emoji: '🇦🇱',
          currency: 'ALL',
          languages: [{ code: 'sq', name: 'Albanian', __typename: 'Language' }],
          __typename: 'Country',
        },
        {
          name: 'Armenia',
          native: 'Հայաստան',
          capital: 'Yerevan',
          emoji: '🇦🇲',
          currency: 'AMD',
          languages: [
            { code: 'hy', name: 'Armenian', __typename: 'Language' },
            { code: 'ru', name: 'Russian', __typename: 'Language' },
          ],
          __typename: 'Country',
        },
        {
          name: 'Angola',
          native: 'Angola',
          capital: 'Luanda',
          emoji: '🇦🇴',
          currency: 'AOA',
          languages: [
            { code: 'pt', name: 'Portuguese', __typename: 'Language' },
          ],
          __typename: 'Country',
        },
        {
          name: 'Antarctica',
          native: 'Antarctica',
          capital: null,
          emoji: '🇦🇶',
          currency: null,
          languages: [],
          __typename: 'Country',
        },
        {
          name: 'Argentina',
          native: 'Argentina',
          capital: 'Buenos Aires',
          emoji: '🇦🇷',
          currency: 'ARS',
          languages: [
            { code: 'es', name: 'Spanish', __typename: 'Language' },
            { code: 'gn', name: 'Guarani', __typename: 'Language' },
          ],
          __typename: 'Country',
        },
      ],
    },
    (options || {}) as Query,
    {
      arrayMerge(target: any[], source: any[]): any[] {
        if (!source.length) return source

        return [...target, ...source]
      },
      ...deepMergeOptions,
    },
  )

Это фиктивные данные, на которые сервер MSW ответит на запрос Countries. Мы хотим использовать эти фиктивные данные для Storybook и модульных тестов, поэтому мы используем deepmerge, чтобы мы могли легко расширять данные.

Наконец, мы создадим src/mocks/queries/handlers.ts:

import { countriesQuery } from './countries'

export const handlers = [countriesQuery()]

Установить сборник рассказов

Далее давайте установим Storybook.

Для быстрой настройки запустим CLI Storybook:

npx storybook init

Как только он закончит каталог .storybook, он должен быть создан следующим образом:

.storybook/
├── main.js
└── preview.js

Поскольку мы используем TypeScript в нашем приложении, нам нужно добавить записи alias, соответствующие paths в нашем tsconfig.ts для загрузки модулей. Для этого установим плагин tsconfig-paths-webpack-plugin:

yarn add -D tsconfig-paths-webpack-plugin

Затем откройте .storybook и измените конфигурацию Webpack:

const { TsconfigPathsPlugin } = require('tsconfig-paths-webpack-plugin');

module.exports = {
  "stories": [
    "../src/**/*.stories.mdx",
    "../src/**/*.stories.@(js|jsx|ts|tsx)"
  ],
  addons: [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    "@storybook/addon-interactions",
    "storybook-addon-next-router",
  ],
  framework: "@storybook/react",
  core: {
    "builder": "@storybook/builder-webpack5"
  },
  features: {
    emotionAlias: false,
  },
  webpackFinal: async (config, { configType }) => {
    // Add plugins
    config.resolve.plugins = [
      ...(config.resolve.plugins || []),
      new TsconfigPathsPlugin({
        configFile: './tsconfig.json'
      }),
    ];

    // Return the altered config
    return config;
  },
}

Теперь, когда мы можем загружать модули из src/** в файлы Storybook.

Установить провайдера

Так как мы использовали Apollo Client, нам нужно настроить провайдера в историях.

Создайте src/storybook/provider.ts и определите поставщика с помощью следующего кода:

import 'src/styles/globals.css'
import { ApolloProvider as ApolloProviderLibs } from '@apollo/client'
import React, { useMemo } from 'react'
import { createApolloClient } from 'src/shared/apollo/client'

export const Provider: React.FCWithChildren = (props) => {
  return <ApolloProvider>{props.children}</ApolloProvider>
}

const ApolloProvider: React.FCWithChildren = (props) => {
  const client = useMemo(
    () => createApolloClient({ idToken: 'token' }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [],
  )

  return (
    <ApolloProviderLibs client={client}>{props.children}</ApolloProviderLibs>
  )
}

Этот поставщик должен быть таким же, как используемый в pages/_app.tsx:

import 'src/styles/globals.css'
import type { AppProps } from 'next/app'
import { ApolloProvider } from 'src/shared/apollo/ApolloProvider'

export default function App({ Component, pageProps }: AppProps) {
  return (
    <ApolloProvider>
      <Component {...pageProps} />
    </ApolloProvider>
  )
}

Затем откройте .storybook/preview.js и оберните компонент истории провайдером:

import {Provider} from "../src/storybook/Provider";

export const parameters = {
  actions: { argTypesRegex: "^on[A-Z].*" },
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/,
    },
  },
}

export const decorators = [
  // Add provider here.
  (Story) => (
    <Provider>
      <Story />
    </Provider>
  ),
];

Теперь, когда мы можем настроить провайдера apollo, компонент истории может использовать предоставленные данные.

Установите плагин storybook-addon-next-router.

Поскольку мы используем Next.js, нам нужно настроить маршрутизатор Next.js в наших историях из сборника рассказов.

Давайте установим плагин storybook-addon-next-router.

yarn add -D storybook-addon-next-router

Откройте .storybook/preview.js и добавьте RouterContext.Provider к параметрам:

import { RouterContext } from "next/dist/shared/lib/router-context";

export const parameters = {
  actions: { argTypesRegex: "^on[A-Z].*" },
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/,
    },
  },
  // Add here
  nextRouter: {
    Provider: RouterContext.Provider,
  },
}

Это позволяет нам настроить путь маршрутизатора в таких историях:

export const Example = () => <MyComponentThatHasANextLink />;

Example.parameters = {
  nextRouter: {
    path: "/profile/[id]",
    asPath: "/profile/lifeiscontent",
    query: {
      id: "lifeiscontent",
    },
  },
};

Установите плагин msw-storybook-addon.

Для имитации запросов GraphQL внутри Storybook мы устанавливаем плагин msw-storybook-addon.

Установите msw-storybook-addon с помощью этой команды:

yarn add -D msw-storybook-addon

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

npx msw init public/

Он создаст mockServiceWorker.js в общедоступном каталоге, который выглядит следующим образом:

public/
├── favicon.ico
├── mockServiceWorker.js // Added
└── vercel.svg

Откройте .storybook/main.js и поместите staticDirs и запасной вариант в конфигурацию Webpack:

const { TsconfigPathsPlugin } = require('tsconfig-paths-webpack-plugin');

module.exports = {
  "stories": [
    "../src/**/*.stories.mdx",
    "../src/**/*.stories.@(js|jsx|ts|tsx)"
  ],
  staticDirs: ['../public'], // Add staticDirs here.
  addons: [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    "@storybook/addon-interactions",
    "storybook-addon-next-router",
  ],
  framework: "@storybook/react",
  core: {
    "builder": "@storybook/builder-webpack5"
  },
  features: {
    emotionAlias: false,
  },
  webpackFinal: async (config, { configType }) => {
    config.resolve.plugins = [
      ...(config.resolve.plugins || []),
      new TsconfigPathsPlugin({
        configFile: './tsconfig.json'
      }),
    ];

    // Add fallback here.
    config.resolve = {
      ...config.resolve,
      fallback: {
        timers: false,
        tty: false,
        os: false,
        http: false,
        https: false,
        zlib: false,
        util: false,
        stream: false,
        ...config.resolve.fallback,
      }
    }

    // Return the altered config
    return config;
  },
}

Затем откройте .storybook/preview.js включите MSW:

import { RouterContext } from "next/dist/shared/lib/router-context";
import * as msw from 'msw-storybook-addon';
import {handlers as queryHandlers} from "../src/mocks/queries/handlers";

// Initialize the msw
msw.initialize()

export const parameters = {
  actions: { argTypesRegex: "^on[A-Z].*" },
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/,
    },
  },
  nextRouter: {
    Provider: RouterContext.Provider,
  },
  // Add the MSW handlers.
  // This will be applied globally.
  msw: {
    handlers: [...queryHandlers]
  }
}

// Provide the MSW addon decorator globally
export const decorators = [
  msw.mswDecorator,
  (Story) => (
    <Provider>
      <Story />
    </Provider>
  ),
];

Мы инициализируем MSW и предоставляем обработчики, которые мы определили в src/mocks/queries/handlers.ts, чтобы мы могли имитировать данные в историях.

Создавайте истории

Теперь, когда мы настроили Storybook и сервер MSW, давайте создадим историю и посмотрим, как она работает.

Создайте src/pages/Countries/Countries.stories.tsx:

import { ComponentStory, ComponentMeta } from '@storybook/react'
import React from 'react'
import { Countries as Page } from './Countries'

export default {
  title: 'Pages/Countries',
  component: Page,
  parameters: {
    layout: 'fullscreen',
    nextRouter: {
      asPath: '/countries',
      path: '/countries',
    },
  },
} as ComponentMeta<typeof Page>

const Template: ComponentStory<typeof Page> = (args) => <Page {...args} />

export const Default = Template.bind({})

Запустите сборник рассказов с помощью следующего кода:

yarn storybook

Перейдите к https://localhost:6006, и вы увидите, что страница стран успешно получает имитированные данные и отображает список.

Настройка хроматического

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

После регистрации вы увидите страницу настройки проекта, подобную этой:

Мы будем следовать инструкциям. Установите хроматический пакет с помощью этой команды:

yarn add -D chromatic

Опубликуйте наш сборник рассказов, используя следующий код:

npx chromatic --project-token=<your token>

По завершении вы увидите страницу успеха. Это выглядит так:

Автоматизируйте проверку пользовательского интерфейса с помощью GitHub Actions

Теперь, когда мы опубликовали Storybook to Chromatic, давайте посмотрим, как автоматизировать тесты пользовательского интерфейса для выявления ошибок с помощью CI. Chromatic предоставляет действие GitHub, которое поможет вам автоматизировать визуальный регрессионный тест.

Настройка секретов

Чтобы защитить наше приложение, мы будем хранить токен проекта в GitHub Secrets.

Перейдите к SettingsSecretsactions, затем нажмите New repository secret:

Затем добавьте CHROMATIC_PROJECT_TOKEN и замените значение сгенерированным токеном проекта выше:

Добавить рабочий процесс разработки

Мы создадим рабочий процесс, который публикует Storybook в Chromatic и создает комментарий со ссылкой на Storybook и Chromatic. Посмотрим, как это работает.

Создайте .github/workflows/chromatic_dev.yml:

name: Chromatic development
on:
  pull_request:
    branches: [main]

jobs:
  chromatic:
    name: chromatic development
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v2
        with:
          node-version: 16.18.1
      - name: Cache node_modules
        uses: actions/cache@v2
        id: node_modules_cache_id
        with:
          path: node_modules
          key: v1-yarn-${{ hashFiles(format('{0}{1}', github.workspace, '/yarn.lock')) }}
          restore-keys: |
            v1-yarn-
      - name: Run install
        if: steps.node_modules_cache_id.outputs.cache-hit != 'true'
        run: yarn install --frozen-lockfile --silent
      - name: Run codegen
        run: yarn codegen
      - name: Publish to Chromatic
        uses: chromaui/action@v1
        id: chromatic
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
          exitZeroOnChanges: true
      - name: Remove unnecessary path for Chromatic link
        id: storybook-url
        run: echo "::set-output name=value::${STORYBOOK_URL//\/iframe.html/}"
        env:
          STORYBOOK_URL: ${{ steps.chromatic.outputs.storybookUrl }}
      - name: Find Comment
        uses: peter-evans/find-comment@v2
        id: fc
        with:
          issue-number: ${{ github.event.pull_request.number }}
          comment-author: 'github-actions[bot]'
          body-includes: ':books: Storybook :books:'
      - name: Get datetime for now
        id: datetime
        run: echo "::set-output name=value::$(date)"
        env:
          TZ: Asia/Tokyo
      - name: Create or update comment
        uses: peter-evans/create-or-update-comment@v2
        with:
          comment-id: ${{ steps.fc.outputs.comment-id }}
          issue-number: ${{ github.event.pull_request.number }}
          body: |
            Visit the :books: **Storybook** :books: for this PR (updated for commit ${{ github.event.pull_request.head.sha }}):
            <${{ steps.storybook-url.outputs.value }}>

            <sub>Build URL: ${{ steps.chromatic.outputs.buildUrl }}</sub>
            <sub>(:fire: updated at ${{ steps.datetime.outputs.value }})</sub>
          edit-mode: replace

Зафиксируйте файл и нажмите его, после чего начнется рабочий процесс.

После завершения рабочего процесса вы получите комментарий выглядит так:

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

тесты пользовательского интерфейса

Давайте добавим некоторые изменения и посмотрим, как работают UI-тесты.

Откройте src/pages/Countries/Countries.tsx и удалите код:

            >
              <h2>{c.name}</h2>
              <p>capital: {c.capital}</p>
-             <p>currency: {c.currency}</p>
            </a>
          ))}
        </div>

После публикации Chromatic обнаружил изменения следующим образом:

Посмотреть PR можно здесь.

Добавьте основной рабочий процесс

Chromatic предоставляет ваши собственные постоянные ссылки, соответствующие веткам и коммитам. Формат постоянной ссылки:

https://<branch> — <appid>.chromatic.com

Вы можете получить постоянную ссылку на ManagePermalinks:

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

Создайте .github/workflows/chromatic_main.yml:

name: Chromatic main
on:
  push:
    branches: [main]

jobs:
  chromatic:
    name: chromatic main
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v2
        with:
          node-version: 16.18.1
      - name: Cache node_modules
        uses: actions/cache@v2
        id: node_modules_cache_id
        with:
          path: node_modules
          key: v1-yarn-${{ hashFiles(format('{0}{1}', github.workspace, '/yarn.lock')) }}
          restore-keys: |
            v1-yarn-
      - name: Run install
        if: steps.node_modules_cache_id.outputs.cache-hit != 'true'
        run: yarn install --frozen-lockfile --silent
      - name: Run codegen
        run: yarn codegen
      - name: Publish to Chromatic
        uses: chromaui/action@v1
        id: chromatic
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
          autoAcceptChanges: true

Параметр autoAcceptChanges должен быть верным, потому что мы уже проверяем изменения в PR, поэтому публикуем его.

После завершения последний сборник рассказов будет доступен по адресу https://main — <appid>.chromatic.com.

Модульные тесты

Наконец, мы реализуем модульные тесты с сервером MSW.

Установите пакет @testing-library/react:

yarn add -D @testing-library/react @testing-library/jest-dom @testing-library/user-event

Установите jest и другие пакеты:

yarn add -D jest jest-environment-jsdom @swc/core @swc/jest @types/jest [email protected] empty

Создайте jest.config.js:

module.exports = {
  testEnvironment: 'jsdom',
  globals: {
    __DEV__: true,
  },
  testMatch: ['**/src/**/?(*.)+(spec|test).[jt]s?(x)'],
  testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
  setupFilesAfterEnv: ['<rootDir>/setupTests.js'],
  transform: {
    '^.+\\.(js|jsx|ts|tsx)$': ['@swc/jest'],
  },
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'd.ts'],
  moduleNameMapper: {
    '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|css)$':
      'empty/object',
    '^src/(.*)$': '<rootDir>/src/$1',
  },
}

Создайте setupTests.js:

import '@testing-library/jest-dom/extend-expect'

global.fetch = require('node-fetch')

jest.mock('src/config')
jest.retryTimes(3, { logErrorsBeforeRetry: true })

// @see https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation((query) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(), // deprecated
    removeListener: jest.fn(), // deprecated
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
})

Создайте .swcrc:

{
  "jsc": {
    "target": "es2015",
    "parser": {
      "syntax": "typescript",
      "tsx": true
    },
    "transform": {
      "react": {
        "runtime": "automatic",
        "pragma": "React.createElement",
        "pragmaFrag": "React.Fragment",
        "throwIfNamespace": true,
        "useBuiltins": true
      }
    }
  },
  "sourceMaps": true
}

Создайте src/mocks/server.ts:

import { setupServer } from 'msw/node'
import { handlers as queryHandlers } from './queries/handlers'

export const server = setupServer(...[...queryHandlers])

export const removeAllListeners = () => {
  server.events.removeAllListeners()
}

Мы создадим src/testUtils и структурируем так:

src/testUtils/
├── Provider.tsx
├── index.ts
└── mock
    └── setup.ts

Создайте src/testUtils/Provider.tsx:

import React, { useMemo } from 'react'
import { createApolloClient } from 'src/shared/apollo/client'
import { ApolloProvider as ApolloProviderLibs } from '@apollo/client'

export const Provider: React.FCWithChildren = (props) => {
  return <ApolloProvider>{props.children}</ApolloProvider>
}

const ApolloProvider: React.FCWithChildren = (props) => {
  const client = useMemo(() => createApolloClient({ idToken: 'token' }), [])

  return (
    <ApolloProviderLibs client={client}>{props.children}</ApolloProviderLibs>
  )
}

Создайте src/testUtils/mock/setup.ts:

import { server } from 'src/mocks/server'

type Callback = () => void

export const startServer = (callback?: Callback) => {
  beforeAll(async () => {
    if (callback) await callback()
    server.listen()
  })
}

export const resetServer = (callback?: Callback) => {
  afterAll(async () => {
    if (callback) await callback()
    server.resetHandlers()
  })
}

export const resetHandlers = (callback?: Callback) => {
  afterEach(async () => {
    if (callback) await callback()
    server.resetHandlers()
  })
}

export const closeServer = (callback?: Callback) => {
  afterAll(async () => {
    if (callback) await callback()
    server.close()
  })
}

Мы будем имитировать запрос GraphQL, используя эти функции внутри тестов.

Создадим src/pages/Countries/Countries.test.tsx:

import { render, screen } from '@testing-library/react'
import React from 'react'
import { removeAllListeners } from 'src/mocks/server'
import { Provider } from 'src/testUtils'
import {
  closeServer,
  resetHandlers,
  resetServer,
  startServer,
} from 'src/testUtils/mock/setup'
import { Countries as Component } from '../Countries'

type Props = {}
const propsData = (options?: Partial<Props>): Props => ({
  ...options,
})

describe('pages/Countries', () => {
  startServer()
  closeServer()
  resetServer()
  resetHandlers()

  beforeEach(async () => {
    removeAllListeners()
  })

  describe('Countries', () => {
    test('renders countries list', async () => {
      render(
        <Provider>
          <Component {...propsData()} />
        </Provider>,
      )

      expect(await screen.findByText('Argentina')).toBeInTheDocument()
    })
  })
})

Затем он должен пройти тест. Вот как это выглядит:

 PASS  src/pages/Countries/__tests__/Countries.test.tsx
  pages/Countries
    Countries
      ✓ renders countries list (67 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.247 s, estimated 5 s
Ran all test suites.
✨  Done in 2.48s.

Когда мы захотим изменить ответ, мы смоделируем его внутри теста, используя server.use:

//...
import { removeAllListeners, server } from 'src/mocks/server'
import { countriesQuery } from 'src/mocks/queries/countries'

test('renders Japan', async () => {
  // Change a reponse against Countries query.
  // Countries list will be deeply merged with the new value.
  server.use(
    countriesQuery({
      res: {
        countries: [
          {
            name: 'Japan',
            native: '日本',
            capital: 'Tokyo',
            emoji: '🇯🇵',
            currency: 'JPY',
            languages: [
              {
                code: 'ja',
                name: 'Japanese',
              },
            ],
          },
        ],
      },
    }),
  )

  render(
    <Provider>
      <Component {...propsData()} />
    </Provider>,
  )

  expect(await screen.findByText('Japan')).toBeInTheDocument()
})

Заключение

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

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