Использование фейкового сервера для тестирования UI (и не только)

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

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

Какой подход мы выбрали?

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

Чем больше ваши тесты отражают те способы, которыми используется софт, тем больше уверенности они могут дать. — Кент С. Доддс.

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

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

Читайте книгу Testing Implementation Details, если вам интересно узнать об этом подробнее. 

Пишите тесты, но не слишком много. По большей части — интеграционные. — Guillermo Rauch.

Как и со многими вещами в жизни, написание тестов это искусство нахождения компромисса. Давайте рассмотрим набор end-to-end тестов, который поднимает целое приложение с бэкендом: они будут нажимать разные кнопки, вводить значения в поля, как если бы приложением пользовался юзер. Они дают уверенность, но вместе с ней приходит и большая… цена. 

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

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

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

Возвращаясь к истории

При разработке нашего UI мы использовали React. Выбор @testing-library/react и их преимущества были для нас очевидны. Однако, мы провели целое обсуждение на тему, как бы мы хотели разобраться с API вызовами во время тестов. Мы знали, что моки клиента не лучшее решение, особенно, если это будет тесно связано с деталями реализации поверх поверх Амплифай, от которых мы хотели избавиться.

Мы знали о msw и о том, как мы можем применить его для имитации нашего GraphQL API. Он использует сервис воркер, который можно настроить для перехвата запросов и ответов на них заготовленными ответами. Все это действительно работает безупречно, без необходимости полагаться на детали реализации, такие как библиотека, используемая для получения данных. 

Мы могли закодировать ответы API, сконфигурировать msw ответами и закончить на этом, но с другой стороны, мы хотели добавить еще несколько интеграционных тестов для других частей приложения, которые используют те же ресурсы, хранящиеся на бэкенде. Но может, мы могли бы сделать сделать это чуточку умнее?

Как-то на одном проекте я с работал с одним умным парнем, который рассказал нам про Mirage.JS, дал возможность перехватывать вызовы API, имитировать поведение API с помощью малого количества кода и хранить данные в легковесной базе данных в памяти. 

Я стал размышлять, может ли подобная вещь быть возможной с msw и после недолгих раздумий, другой умный парень указал мне на @mswjs/data, что и оказалось недостающим кусочком пазла. Это позволило нам определить модули используемых ресурсов в приложении, которые могут дальше храниться и извлекаться из in-memory базы данных, а также аккуратно интегрироваться с обработчиками msw. Благодаря этому мы сможем определить тестовые дублеры нашего бэкенда, которые могут быть гибко переиспользованы во множестве  интеграционных тестов нашего приложения.

Как это работает под капотом

Если вы заинтересованы в более глубоком изучении, я могу порекомендовать видео с выступлением «За рамками имитации API» Артема Захаренко, который создал и поддерживает Mock Service Worker.

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

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

Ниже представлено описание последовательности шагов, необходимых для приготовления такой настройки.

Настройка фейковых баз данных

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

import { primaryKey } from "@mswjs/data";
import { v4 } from "uuid";
import faker from "faker";

export const author = {
  id: primaryKey(() => v4()),
  name: () => faker.name.findName(),
};
import { oneOf, primaryKey } from "@mswjs/data";
import faker from "faker";

export const book = {
  isbn: primaryKey(() => String(faker.datatype.number(9999999999999))),
  title: () => faker.random.words(3),
  author: oneOf("author"),
};

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

import { factory } from "@mswjs/data";
import { author } from "./factories/authors.factory";
import { book } from "./factories/books.factory";

export const db = factory({
  author,
  book,
});

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

Настройка обработчиков

Подобная база данных может дальше использоваться для реализации обработчиков для нашего фейкового сервера. В методах, предоставляемые @mswjs/data, чувствуется вдохновение Prisma API, которую действительно удобно использовать (особенно для тех, кто использовал Prisma ранее).

import { graphql } from "msw";
import { Book, BookInput } from "../../graphql/generated-types";
import { db } from "../db";

export const handlers = [
  graphql.query("Book", (req, res, ctx) => {
    const { isbn } = req.variables;

    return res(
      ctx.data({
        book: db.book.findFirst({ where: { isbn: { equals: isbn } } }),
      })
    );
  }),

  graphql.query("Books", (req, res, ctx) => {
    return res(
      ctx.data({
        books: db.book.getAll(),
      })
    );
  }),

  graphql.mutation<{ createBook: Book }, { input: BookInput }>(
    "CreateBook",
    (req, res, ctx) => {
      const { isbn, title, authorId } = req.variables.input;

      const author = db.author.findFirst({ where: { id: { equals: authorId } } })!;
      const newBook = db.book.create({ isbn, title, author });

      return res(
        ctx.data({
          createBook: { ...newBook, author },
        })
      );
    }
  ),
];

В фрагменте кода выше представлены примеры обработчиков для операций GraphQL, подготовленных для ресурса book.  graphql.query("Book", ( ... ) => { ... }) регистрирует обработчик запроса Book и извлекает книгу с заданным isbn номером. Свойства запроса могут быть извлечены из параметра req, req возвращает ответ, в то время как ctx включает набор хелперных функций.

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

Такой набор обработчиков можно дальше собрать и передать функции setupServer от msw, чтобы раскрыть функциональность фейкового сервера.

import { handlers as authorHandlers } from "./handlers/authors.handlers";
import { handlers as bookHandlers } from "./handlers/books.handlers";

export const handlers = [...authorHandlers, ...bookHandlers];
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);

В дальнейшем сервер можно использовать в тестах

import "@testing-library/jest-dom/extend-expect";
import { drop } from "@mswjs/data";
import { client } from "./ApolloClient";
import { server } from "./mockServer/server";
import { db } from "./mockServer/db";

beforeAll(() => {
  server.listen();
});

beforeEach(() => {
  return client.clearStore();
});

afterEach(() => {
  drop(db);
  server.resetHandlers();
});

afterAll(() => {
  server.close();
});

В фрагменте кода выше представлен setupTests.ts, который используется для настройки тестов. beforeAll и afterAll отвечают за запуск и завершение фейкового сервера. afterEach сбрасывает состояние обработчиков и фейковых баз данных между тестами, в то время как beforeEach специфичен для @apollo/client и очищает его кэш.

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

Использование сервера в тестах

Ниже представлено два фрагмента кода с двумя тестовыми случаями, покрывающими happy path и случай, в котором книга с заданным isbn уже существует. Дополнительные комментарии добавлены для объяснения определенных шагов теста.

import * as React from "react";
import { screen, waitForElementToBeRemoved } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { waitFor } from "@testing-library/dom";
import { graphql } from "msw";
import { renderWithProviders } from "../../testUtils/render";
import { db } from "../../mockServer/db";
import { server } from "../../mockServer/server";
import Books from "./index";

function seedData() {
  const authors = [
    db.author.create({ name: "James Clear" }),
    db.author.create({ name: "Greg McKeown" }),
  ];

  const books = [
    db.book.create({ title: "Atomic Habits", author: authors[0] }),
    db.book.create({ title: "Essentialism", author: authors[1] }),
  ];

  return { authors, books };
}

test("should create a new book when form is submitted with valid data", async () => {
  // seed some fake data...
  seedData();

  // ... and add some specific for the test
  const authorName = "Andrzej Pilipiuk";
  db.author.create({ name: authorName });

  // render component
  renderWithProviders(<Books />);

  // wait for books to be loaded
  await waitForElementToBeRemoved(() => screen.getByText(/Loading/));

  // go to create book view
  userEvent.click(screen.getByRole("link", { name: "Create new book" }));

  // fill in the form
  const isbn = "1234567891011";
  const isbnInput = screen.getByRole("textbox", { name: "ISBN" });
  userEvent.type(isbnInput, isbn);

  const title = "Chronicles of Jakub Wędrowycz";
  const titleInput = screen.getByRole("textbox", { name: "Title" });
  userEvent.type(titleInput, title);

  const authorSelect = screen.getByRole("combobox", { name: "Author Id" });
  // wait for select to be enabled - options are loaded
  await waitFor(() => expect(authorSelect).toBeEnabled());
  userEvent.selectOptions(authorSelect, authorName);

  userEvent.click(screen.getByRole("button", { name: "Create book" }));

  // wait for the book to be shown - queries are invalidated which leads to refetching
  await waitFor(() => expect(screen.getByText(title)).toBeInTheDocument());

  // assert that the results are stored in fake database
  expect(db.book.findFirst({ where: { isbn: { equals: isbn } } })).toEqual(
    expect.objectContaining({
      isbn,
      title,
      author: expect.objectContaining({ name: authorName }),
    })
  );
});

Давайте подытожим и прокомментируем некоторые ключевые моменты первого теста.

Я создал функцию seedData, чтобы обеспечить наличие фейковых данных и использовать их повторно в обоих тестах. После добавления некоторых конкретных данных к первому тесту я отобразил дерево компонентов и выполнил действие, которое выполнил бы пользователь — вход по форме, заполнение данных и их отправка. После этого действия я жду добавления книги в список, из-за того, что кэш Apollo очищен, что приводит к повторному получению данных. Наконец, я проверяю, корректно ли хранится значение в фейковой базе данных. В качестве альтернативы я мог бы открыть просмотр только что созданной записи и проверить, что данные, возвращаемые с фейкового сервера, корректны (что было бы даже лучше, потому что пользователь таким образом будет взаимодействовать с приложением).

Теперь давайте покроем второй тестовый случай с ошибкой, возвращаемой с фейкового сервера.

test("should show an error when book with given ISBN already exists", async () => {
  // seed some fake data to use it later
  const {
    books: [book],
    authors: [author],
  } = seedData();

  // overwrite handler to give us specific error
  const errorMessage = "Book with given ISBN already exists";
  server.use(
    graphql.mutation("CreateBook", (req, res, ctx) =>
      res(
        ctx.errors([
          {
            message: errorMessage,
            path: ["input", "isbn"],
          },
        ])
      )
    )
  );

  // render component
  renderWithProviders(<Books />);

  // wait for books to be loaded
  await waitForElementToBeRemoved(() => screen.getByText(/Loading/));

  // go to create book view
  userEvent.click(screen.getByRole("link", { name: "Create new book" }));

  // fill in the form
  const isbnInput = screen.getByRole("textbox", { name: "ISBN" });
  userEvent.type(isbnInput, book.isbn);

  const title = "Chronicles of Jakub Wędrowycz";
  const titleInput = screen.getByRole("textbox", { name: "Title" });
  userEvent.type(titleInput, title);

  const authorSelect = screen.getByRole("combobox", { name: "Author Id" });
  // wait for select to be enabled - options are loaded
  await waitFor(() => expect(authorSelect).toBeEnabled());
  userEvent.selectOptions(authorSelect, author.name);

  userEvent.click(screen.getByRole("button", { name: "Create book" }));

  // wait for the error message from the backend to be rendered
  await waitFor(() =>
    expect(screen.getByText(errorMessage)).toBeInTheDocument()
  );
});

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

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

Использование сервера в приложении

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

import { setupWorker } from "msw";
import { handlers } from "./handlers";

export const worker = setupWorker(...handlers);

Выше фрагмент кода, который похож на setupServer для тестов, однако setupWorker создает экземпляр воркера на стороне клиента, который может дальше быть активирован для перехвата запросов во время работы над пользовательским интерфейсом. 

// Start the mocking conditionally.
if (process.env.NODE_ENV === "development") {
  const { worker } = require("./mockServer/browser");

  db.book.create({
    title: "Atomic Habits",
    author: db.author.create({ name: "James Clear" }),
  });
  db.book.create({
    title: "Essentialism",
    author: db.author.create({ name: "Greg McKeown" }),
  });
  db.book.create({
    title: "Chronicles of Jakub Wędrowycz",
    author: db.author.create({ name: "Andrzej Pilipuik" }),
  });

  worker.start();
}

ReactDOM.render(
  <App />
  document.getElementById("root")
);

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

Стоит ли оно того?

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

Плюсы:

Возможность имитировать поведение реального сервера: если приложение выполняет какие-то операции, включая чтение, создание и обновление ресурсов, мы можем подготовить обработчики и использовать in-memory хранилище из @mswjs/data для имитации поведения реального сервера и для более тщательного тестирования приложения. Мы можем заполнить и отправить какие-либо формы или выполнить какое-то другое действие в результате вызова API, и позже проверить, возвращаются ли данные в другом месте. Это похоже на способ, которым пользователи взаимодействуют с приложением, без тестирования деталей реализации, что должно нам дать спокойно спать ночью и успешно выпускать продукт.

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

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

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

Минусы:

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

Заключение

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

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

Как разработчик, я счастлив, что могу использовать такие инструменты, как @testing-library/react, mirage.js, msw и Testing Playground, которые так помогают в написании тестов. Если вы не знакомы с чем-то из списка, я очень рекомендую уделить этому какое-то время, но предупреждаю — пути назад нет.


Скоро в OTUS состоится открытый урок, на котором участники познакомятся с тем, как очень быстро запустить автоматизацию UI/E2E на проекте и запустят CI/CD для автотестов с отчетом в telegram. Присоединяйтесь, если интересно.

Источник: https://habr.com/ru/company/otus/blog/651953/


Интересные статьи

Интересные статьи

Для будущих студентов курса «Infrastructure as a code in Ansible» и всех интересующихся подготовили перевод полезного материала. Также приглашаем записаться на открытый урок по тем...
Использование HAProxy в качестве Data Plane Service Mesh в архитектуре микросервисов HAProxy Data Plane API – это программа, которая работает вместе с HAProxy, чтобы вы могли полност...
Как быстро определить, что на отдельно взятый сайт забили, и им никто не занимается? Если в подвале главной страницы в копирайте стоит не текущий год, а старый, то именно в этом году опека над са...
Гештальт — это немецкое слово, означающее понятие «форма». Гештальтпсихология была основана немецкими психологами Максом Вертхаймером, Вольфгангом Колером и Куртом Коффкой и ориентирована на то, ...
Это исчерпывающее руководство по обеспечению надёжности в JavaScript и Node.js. Здесь собраны десятки лучших постов, книг и инструментов. Сначала разберитесь с общепринятыми методиками тести...