Привет! Меня зовут Данила, я фронтенд-тимлид в KTS.

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

Оглавление:

Немного о проекте

Это server-side render приложение, написанное на Next.js 12 версии. Сайт состоит из множества контентных страниц, модуля тестирования, а также административной панели с визуальным конструктором контента. Проект стартовал с очень сжатыми сроками, а сейчас продолжает жить и активно развивается уже несколько лет.

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

Какие тесты будем писать?

Существует несколько видов тестов (unit, интеграционные, e2e). Несомненно, в идеале надо использовать их все, но это очень затратно по времени. На этом проекте мы решили писать именно интеграционные тесты.

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

На backend у нас уже писались тесты, и поэтому полноценные e2e тоже были для нас не так привлекательны. Их выполнение занимает много времени, а писать их сложнее. Мы хотели прогонять тесты при каждом merge request, и поэтому проходить они должны быстро. Для ускорения мы решили просто мокать запросы к backend и сосредоточиться на тестировании именно frontend-части.

Наши основные критерии перед внедрением автотестов:

  • Нам важно понимать, что что-то сломалось, а не получать указание на конкретную функцию

  • Быстрое выполнение тестов

  • Выполнение тестов в pipeline перед каждым merge-request

  • Не тратить много времени на поддержание тестов

Почему Playwright?

На других проектах для подобных задач мы использовали Cypress либо React Testing Library, но, проведя небольшой ресерч, решили попробовать решение от Microsoft Playwright и не пожалели. Этот инструмент предлагает широкий функционал из коробки. Главными фичами Playwright на мой взгляд являются:

Сетап

Установка

Для установки Playwright достаточно выполнить одну строку:

npm init playwright@latest

Дальше потребуется выбрать необходимые настройки, и у вас в проекте создастся примерно такая структура:

playwright.config.ts
package.json
package-lock.json
tests/
  example.spec.ts
tests-examples/
  demo-todo-app.spec.ts

В целом всё, теперь с помощью команд npx playwright test или npx playwright test --ui можно запускать тесты.

Моки ответов запросов

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

Single Page Application

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

test("mocks a fruit and doesn't call api", async ({ page }) => {
  await page.route('*/**/api/v1/fruits', async route => {
    const json = [{ name: 'Strawberry', id: 21 }];
    await route.fulfill({ json });
  });

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

Server Side Rendering

В нашем случае приложение с SSR, и это добавляет некоторые проблемы.

  • Необходимо мокать SSR запросы к API

  • Обеспечить независимое окружение для параллельного выполнения тестов

Начнем по порядку. Для того чтобы мокать SSR запросы, нам необходимо поднять свой Mock-сервер и направить все серверные запросы на него через переменные окружения. Тут идеально подходит Custom Server. Перед запуском теста мы будем наполнять Mock-сервер данными, необходимыми для текущего теста, выполнять тест, а затем очищать Mock-сервер и переходить к следующему тесту.

Для того, чтобы тесты можно было выполнять параллельно, нам нужно поднимать несколько инстансов нашего приложения с Mock-сервером. Для этого у Playwright есть встроенный механизм Worker processes.

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

Укажем в конфиге Playwright пути до наших скриптов, которые будут запускаться до и после выполнения тестов:

// playwright.config.ts
export default defineConfig({
  globalSetup: require.resolve('./global.setup'),
  globalTeardown: require.resolve('./global.teardown'),
  ...
});

Рассмотрим скрипт global.setup.ts.

В параметрах функции мы получаем информацию о воркерах. Для каждого генерируем уникальный порт и запускаем функцию startServer. Там создаем и запускаем отдельный процесс Node.js с нашим Custom Server. Через переменные окружение указываем адрес бекенда API, в нашем случае это будет тот же самый адрес приложения. То есть запросы из приложения будут обрабатываться самим же приложением на уровне сервера. Дальше запоминаем pid процесса в глобальную переменную, он будет нужен после выполнения всех тестов для очистки.

Тут же мы подписываемся на вывод из приложения и ждем логирования подстроки «> Ready on», так мы поймем, что наше приложение успешно запущено.

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

Скрытый текст
// global.setup.ts
const HOSTNAME = '127.0.0.1'; // Адрес воркера
const WORKER_START_PORT = 9000; // Порт для первого воркера. Для второго будет 9001 и т.д

function startServer(port: number) {
  return new Promise((resolve) => {
    const name = String(port);
    // Запускаем отдельный процесс и указываем переменные окружения
    const child = spawn('node', ['./server.js', String(port), HOSTNAME], {
      env: {
        ...process.env,
        API_URL: `http://${HOSTNAME}:${port}`,
      },
    });

    // Запоминаем pid процесса, для того чтобы потом можно было его уничтожить
    global[`SERVER_${name.toUpperCase()}_PID`] = child.pid;

    
    child.stdout.on('data', (data) => {
      // Логируем вывод процесса
      console.log(`[${name}]:`, data.toString());

      // Дожидаемся когда приложение запустится
      if (data.toString().indexOf('> Ready on') === 0) {
        resolve(child);
      }
    });

    // Логируем ошибки
    child.stderr.on('data', (data) => {
      console.log(`[${name}]:`, data.toString());
    });
    
    // Логируем закрытие приложения
    child.on('close', (code) => {
      console.log(`[${name}][CLOSED]:`, code?.toString());
    });
  });
}

export default async (params: { workers: number }) => {
  const promises = [];

  for (let i = 0; i < params.workers; i += 1) {
    promises.push(startServer(WORKER_START_PORT + i));
  }

  await Promise.all(promises);

  console.log('All servers started');
};

Теперь рассмотрим сам скрипт server.js. Считываем порт и адрес из аргументов запуска скрипта. Создаем глобальную переменную MockData для хранения моков. А дальше просто оборачиваем наше Next.js приложение через нативный http server. Добавляем endpoint-ы для управления моками:

  • /set-mock - для сохранения мока

  • /clear-mocks - для очистки всех моков

Дальше добавляем перехват всех запросов, которые начинаются с /api/ (это запросы к backend). При получении такого запроса проверяем, есть ли сохраненный мок для него, если есть, возвращаем мок, иначе передаем запрос дальше в Next.js. Таким образом, мы не вносим никаких изменений в код нашего приложения, а действуем уровнем выше.

Скрытый текст
// server.js
const http = require('http');
const next = require('next');

const PORT = parseInt(process.argv[2], 10);
const HOSTNAME = process.argv[3];

// Хранилище моков
const MockData = new Map();

// Вспомогательная функция для получения body из запроса
function getBody(request) {
  return new Promise((resolve) => {
    const bodyParts = [];
    let body;
    request
      .on('data', (chunk) => {
        bodyParts.push(chunk);
      })
      .on('end', () => {
        body = Buffer.concat(bodyParts).toString();
        resolve(JSON.parse(body));
      });
  });
}

async function start() {
  // Стартуем приложение Next.js (предварительно нужно сбилдить)
  const app = next({
    dev: false,
    hostname: HOSTNAME,
    port: PORT,
  });

  const handleNextRequests = app.getRequestHandler();
  await app.prepare();

  // Стартуем http server
  const MockServer = new http.Server(async (req, res) => {
    const route = req.url;

    // Резервируем роут для сохранения моков
    if (route === '/set-mock') {
      const {
        data: { status, body, endpoint },
      } = await getBody(req);

      MockData.set(endpoint, {
        body,
        status,
      });

      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end();
      return;
    } 
    
    // Резервируем роут для очистки моков
    else if (route === '/clear-mocks') {
      MockData.clear();
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end();
      return;
    }

    // Подменяем ответы к backend
    if (route.indexof('/api/') === 0) {
      if (mockData.has(route)) {
        const { status, body } = MockData.get(route);
        res.writeHead(status, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify(body));
        return;
      } else {
        res.writeHead(404, { 'Content-Type': 'application/json' });
        res.end();
        return;
      }
    }

    // Остальные запросы отправляем контроллеру Next.js
    return handleNextRequests(req, res);
  });

  MockServer.listen(PORT, () => {
    console.log(`> Ready on <http://$>{HOSTNAME}:${PORT}`);
  });
}

start();

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

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

Рассмотрим код next-fixture.ts.

Скрытый текст
// next-fixture.ts
import { test as base, Request, Route } from '@playwright/test';
import axios from 'axios';

import { HOSTNAME, WORKER_START_PORT } from '../../config';

export type NextContext = {
  mockApi: {
    setStatus200: (endpoint: string, body: Record<string, any>) => Promise<void>;
  };
  baseUrl: string;
};

const test = base.extend<{
  nextContext: NextContext;
}>({
  nextContext: [
    async ({ page }, use, testInfo) => {
	  // Перед выполнением теста определяем адресс воркера
      const port = WORKER_START_PORT + testInfo.parallelIndex;

      const baseUrl = `http://${HOSTNAME}:${port}`;

      // Выполняем тест и передаем вспомогательную функцию для установки мока
      await use({
        mockApi: {
          setStatus200: async (endpoint: string, body: Record<string, any>): Promise<void> => {
            await axios.post(`${baseUrl}/set-mock`, {
              data: {
                status: 200,
                endpoint,
                body,
              },
            });
          },
        },
        baseUrl,
      });

      // После выполнения теста, очищаем моки
      await axios.post(`${baseUrl}/clear-mocks`);
    },
    {
      scope: 'test',
    },
  ],
});

export default test;

Использование fixture в нашем тесте выглядит так:

import { expect } from '@playwright/test';

import test from 'playwright/fixtures/next-fixture';

import __MOCK_MAIN_SLIDER__ from '../__mocks/slider/success.json';

test.describe('Главная страница', () => {
  test('На странице отображается слайдер', async ({
    page,
    nextContext,
  }) => {
    // Мокаем запрос за слайдером
    await nextContext.mockApi.setStatus200('/api/main.slider', __MOCK_MAIN_SLIDER__);

    await page.goto(`${nextContext.baseUrl}/`);
    ...
  });
});

И, казалось бы, всё, дело закрыто. Страницы с использованием getServerSideProps отлично мокаются, но в Next есть прекрасный функционал с getStaticProps, который позволяет кешировать страницы. Этот метод выполняется на сервере во время билда, а также можно задать время для инвалидации кеша. Так как количество копий приложений у нас ограничено, кеш с прошлых тестов будет мешать. Для решения этой проблемы можно поднимать отдельное приложение для каждого теста, но это занимает больше времени и работает не совсем стабильно, поэтому я начал искать другой способ.

И решение нашлось — Preview Mode. Это встроенный в Next.js функционал. Его суть простая: этот режим нужен для того, чтобы  просматривать страницу без кеширования, то есть заставить getStaticProps выполняться при каждом запросе страницы. Чтобы активировать его, в приложении Next.Js нужно сделать отдельный endpoint. Он будет проставлять куки через специализированный метод setPreviewData. Для защиты endpoint стоит закрыть секретным токеном из переменной окружения либо другим механизмом.

// pages/api/preview.ts
import { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (!process.env.PREVIEW_TOKEN || req.query.secret_preview !== process.env.PREVIEW_TOKEN) {
    return res.status(401).json({ message: 'Invalid data' });
  }

  res.setPreviewData(process.env.PREVIEW_TOKEN);
  return res.json({ preview: true });
}

Так как endpoint имеет префикс /api, добавим его игнорирование в наш Mock-сервер:

// server.js
...
 if (route.indexof('/api/') === 0 && !route.includes('/api/preview')) {
      ...
 }
...

Теперь добавим активацию Preview Mode перед выполнением каждого теста в фикстуру.

// next-fixture.ts 
nextContext: [ 
    async ({ page }, use, testInfo) => { 
      ...

      await page.goto(${baseUrl}/api/preview/?secret_preview=${PREVIEW_TOKEN});

      await use(...); 
      ...

Ура! Теперь все SSR запросы успешно мокаются.

Continuous Integration

Одной из ключевых целей внедрения авто-тестов было обеспечить их выполнение перед каждым merge-request. Это позволяет обнаруживать проблемы на ранних стадиях и своевременно их исправлять. В KTS мы используем собственный GitLab для хранения репозиториев проектов. Документация Playwright содержит примеры интеграции с различными CI-решениями, что значительно упростило процесс внедрения тестов в pipeline.

По мере увеличения кол-ва тестов, время на их выполнение будет расти. На этот случай в Playwright существует возможность Sharding, но на текущий момент время выполнения наших тестов не является критическим, поэтому данный механизм еще не использовали, но приятно осознавать что инструмент такое умеет.

Перейдем к итогам.

Плюсы

  1. Раннее выявление ошибок: Автоматизированные тесты уже не раз помогли обнаруживать проблемы на ранних стадиях разработки и предотвратили попадание багов в продакшн.

  2. Экспертиза: Приобретенные навыки и опыт позволили быстрее внедрять автотестирование на других проектах компании.

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

  4. Выполнение тестов в реальном браузере: Тесты выполняются в условиях, максимально приближенных к реальным, что делает их более точными.

Минусы

  1. Затраты времени: На начальном этапе команде потребовалось время, чтобы освоиться с написанием и поддержкой тестов. Это замедлило процесс разработки в краткосрочной перспективе, пока команда не адаптировалась и не появилось больше примеров для переиспользования.

Если вам интересно узнать больше о паттернах, которые мы применяем при написании тестов и как мы оптимизируем этот процесс — пишите в комментариях. 

Также рекомендую ознакомиться с другими нашими статьями по веб-разработке:

CMS за 0 рублей: как мы начали использовать Strapi

Подключаем библиотеку к проекту с помощью npm/yarn link

Летающий Санта и танцующие снегири: опыт реализации и оптимизации CSS-анимации

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

Комментарии (0)