Как структурировать большой проект в Cypress

Как структурировать большой проект в Cypress

Перевод статьи «How to structure a big project in Cypress».

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

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

Содержание:

Подпишитесь на наш ТЕЛЕГРАМ КАНАЛ ПО АВТОМАТИЗАЦИИ ТЕСТИРОВАНИЯ

Основы и принципы

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

Итак, вот эти принципы:

Автоматизация тестирования должна быть частью репозитория с исходным кодом. Недавно я проводил опрос на LinkedIn и выяснил, что 45% тех, кто прошел опрос, не размещают свои тесты в одном и том же репозитории с исходным кодом. На мой взгляд, код автоматизации тестирования (и особенно при использовании Cypress) не должен быть отделен от исходного кода тестируемого приложения. Это позволяет синхронизировать все тесты и все ветки с разработкой, облегчает непрерывную доставку.

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

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

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

BDD без Cucumber

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

Мы так и не решились перейти на синтаксис Gherkin или фреймворк Cucumber. Простота команд Cypress была для нас достаточно хорошим решением и позволяла понять, что делает тест.

Я нашел этот твит от Kent C. Dodds:

The more your tests resemble the way your software is used, the more confidence they can give you.- Кент К. Доддс ? (@kentcdodds) 23 марта 2018 года.

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

cypress/
|-- e2e/
|    |-- board/
|    `-- list/
|-- fixtures/
`-- support/

Папки представляют определенную функцию приложения, а файлы спецификаций внутри этих папок – поведение или пользовательскую историю (user story), о которой пойдет речь в тестовом сценарии. Как уже было сказано, эти сценарии представляют реальное поведение пользователя, но они не написаны на синтаксисе Gherkin (Given, When, Then) или с использованием фреймворка Cucumber. Хотя сообщество разработчиков Cypress создало preprocessor Cucumber, который позволяет писать тесты подобным образом, я предпочитаю избегать этого решения.

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

Arrange, Act, Assert

Вместо подхода “Given-When-Then” я предпочитаю использовать подход “Arrange-Act-Assert“. Они очень похожи по своей сути, но мне кажется, что второй подход чётче определяет цель тестирования. Ключевое слово “When” в синтаксисе Gherkin кажется несколько двусмысленным, поскольку не всегда понятно, относится оно к действию или к состоянию. В шаблоне “Arrange-Act-Assert” это выражено более четко.

before( () => {
  // arrange
  cy.request('POST', '/api/lists', { name: 'new list' })
})
it('creates an item', () => {
  // act
  cy.visit('/')
  cy.get('#create').type('list item{enter}')
  // assert
  cy.get('[data-cy=item]').should('be.visible')
})

Обычно часть “Arrange” происходит через вызовы API, настройку базы данных и редко через пользовательский интерфейс. Чаще всего этот шаг выполняется в before() или beforeEach().

Когда сложно решить, следует выполнять какую-то часть теста через пользовательский интерфейс (UI) или через API, паттерн “Arrange-Act-Assert” помогает принять решение. Все, что выполняется через UI, относится к шагу “Act”. Все, что делается до этого, входит в часть “Arrange” и не делается через UI.

Шаги “Act” и “Assert” могут повторяться несколько раз в процессе сквозного тестирования.

Аннотации к тестам

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

Bad practice:

it('board is visible', () => {})
it('works in edge cases', () => {})
it('handles input', () => {})

Best practice:

it('creates a board and navigates to board detail', () => {})
it('throws error when trying to access private board', () => {})
it('shows a warning message when input is empty', () => {})

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

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

Не усложняйте ситуацию, если нужно нарушить какое-то правило, нарушьте его.

Еще один полезный способ сделать тесты более читабельными – добавить свои собственные логи.

 Я создал плагин который делает следующее:

  • Каждая команда cy.step() описывает шаг в тесте.
  • Каждая команда cy.step() автоматически нумеруется.
  • При неудачном выполнении теста к сообщению об ошибке добавляется пронумерованный список.
  • В терминал выводится сообщение об ошибке и скриншот со сбоем.
Скриншот сообщения об ошибке в Cypress


Spec-файлы

Каждый spec-файл должен содержать всего несколько тестов. End to end тесты, как правило, длиннее, и поэтому требуется работа с большим количеством строк кода. Если в spec-файле появляется несколько длинных сценариев, в нем становится трудно ориентироваться.

Блоки describe() помогают сгруппировать тесты, имеющие что-то общее. Чаще всего это хуки before() или beforeEach(). Таким образом, в случаях, когда мне нужно разделить тесты на группы, я обычно делаю это с помощью новой спецификации (spec) вместо нового блока describe().

Однако при попытке “Run all specs” в открытом режиме (open mode) в Cypress возникает проблема. В этом режиме, по сути, создается одна спецификация из нескольких файлов, что означает, что все ваши before() и beforeEach() хуки будут конкатенированы, что приведет к неожиданным результатам. Об этом следует помнить. Однако при работе над большим проектом я практически никогда не запускаю все спецификации в открытом режиме.

Селекторы

В прошлом я пробовал разные подходы, но в итоге остановился на рекомендации Cypress и добавил селекторы data-cy в приложение. Такой подход оказался наиболее стабильным. Полагаться на имена классов часто приводит к случайным сбоям. Это особенно актуально в наши дни, поскольку разработчики часто используют такие библиотеки пользовательского интерфейса, как Material Design или Bootstrap. Обновления этих библиотек могут часто приводить к изменению классов, что ломает наши тесты.

Добавление собственных атрибутов

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

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

<button disabled>
  <span>Click me!</span>
</button>

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

// this will pass, but click will do nothing
cy.get('span').click()

// this will fail, because button is disabled
cy.get('button').click()

Поэтому выбор правильного селектора очень важен.

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

Устранение дублирования, улучшение читабельности

Еще одним преимуществом добавления собственных атрибутов в исходный код является устранение дублирования, возникающего при использовании объектов страницы. То же самое можно сказать и о хранении элементов в отдельном файле. Это ещё один аспект, о котором нужно заботиться, так как легко может возникнуть расхождение между реальным состоянием приложения и тестами.

Добавление data-* селекторов также может помочь вам улучшить читабельность ваших тестов. Некоторые люди, с которыми я общаюсь, беспокоятся по поводу naming convention, но я бы не стал переживать по этому поводу. Наличие двух одинаковых data-* атрибутов не является проблемой, пока они не встречаются в одном и том же тесте или на одном и том же экране. Я бы определенно посоветовал ориентироваться на реальный интерфейс и визуальное взаимодействие пользователя с приложением при создании селекторов.

// ❌ too complicated in my opinion
cy.get('[data-cy=account-screen-sidemenu-settings-modal]')
// ✅ much better
cy.get('[data-cy=settings-modal]')

Пользовательские команды

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

  • служебные команды
  • Вызовы API
  • последовательности действий

Вызовы API

Используя Cypress, вы часто будете обращаться к API для настройки данных или выполнения какого-либо действия в вашем приложении. Поскольку не всегда хочется вызывать cy.request() и предоставлять необходимую авторизацию, заголовки или тело запроса, создание пользовательской команды – отличное решение. Можно создать функцию, которая будет принимать значения по дефолту или же можно передавать различные аргументы. Данные, полученные в результате вызова API, могут быть использованы в дальнейшем в тесте.

Служебные команды

Если вы используете data-* селекторы, то создание cy.getByDataCy() команды может оказаться полезным. Служебные команды обычно применяются для решения каких-то конкретных задач в приложении, например, команды cy.getClipboard()cy.getTooltip() и т.д.

Последовательность действий

Последовательность действий (Action sequences) – это сценарии или наборы шагов, которые выполняются в тестировании веб-приложений.

Важно отметить, что action sequences представляют собой реальные пользовательские действия и взаимодействие с приложением, их нельзя полностью заменить вызовами API (программным интерфейсом приложения).

Например:

Cypress.Commands.add('pickSidebarItem', (item: 'Settings' | 'Account' | 'My profile' | 'Log out') => {

  cy.get('[data-cy=hamburger-menu]')
    .click()

  cy.contains('[data-cy=side-menu]', item)
    .click()

})

Организация пользовательских команд

Я следую такому правилу, которое заключается в том, чтобы поместить каждую пользовательскую команду в отдельный файл и добавить их все в отдельную папку в проекте Cypress:

big-project/
|-- cypress/
|   |-- commands/
|   |-- e2e/
|   |-- fixtures/
|   `-- support/
|-- .gitignore
`-- cypress.config.ts

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

Каждая команда находится в отдельном файле. Пример такой команды выглядит следующим образом:

declare global {
  namespace Cypress {
    interface Chainable {
      addBoardApi: typeof addBoardApi;
    }
  }
}

/**
 * Creates a new board using the API
 * @param name name of the board
 * @example
 * cy.addBoardApi('new board')
 *
 */
export const addBoardApi = function(this: any, name: string): Cypress.Chainable<any> {

  return cy
    .request('POST', '/api/boards', { name })
    .its('body', { log: false }).as('board');
    
};

Документация Cypress рекомендует создавать центральный index.d.ts файл, содержащий определения типов для всех команд. Лично я больше склоняюсь к приведенному выше варианту, поскольку так создается меньше путаницы.

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

Вместо того, чтобы использовать Cypress.Commands API, каждая команда записывается в виде функции, а затем импортируется в cypress/support/e2e.ts файл.

import { addBoardApi } from '../commands/addBoardApi'

Cypress.Commands.addAll({ addBoardApi })

Другой подход, который я обычно использую, заключается в том, чтобы создать index.ts файл, который добавляет все imports из папки cypress/commands в cypress/support/e2e.ts. Это удобно, если вы решили перенести свое приложение в монорепозиторий и добавить пользовательские команды в отдельную библиотеку, чтобы их можно было повторно использовать во всех проектах.

monorepo-project/
|-- node_modules/
|-- packages/
|   |-- commands/       // library
|   |-- trelloapp/      // app
|   `-- trelloapp-e2e/  // tests
|-- tools/
|-- .editorconfig
|-- .eslintrc.json
|-- .gitignore
|-- .prettierignore
|-- .prettierrc
|-- nx.json
|-- package-lock.json
`-- package.jso

TypeScript

Во всех моих проектах используется TypeScript. Внедрить TypeScript в существующий JS-проект очень просто.

TypeScript очень хорошо работает с пользовательскими командами. Одним из способов использования TypeScript является повторное использование типов (интерфейсы, классы или пользовательские типы данных) из исходного кода в тестах:

import Board from '@/src/models'

cy.request<Board>('POST', '/api/boards', { name: 'new board' })

В приведенном примере кода показана команда cy.request(), которая будет возвращать типы данных из Board интерфейса, импортированного из исходного кода. Это означает, что если у вас есть интерфейс, подобный этому:

interface Board {
  id: number;
  starred: boolean;
  name: string;
  created: string;
  user: number;
}

export default Board;

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

import Board from '@/src/models'

cy.request<Board>('POST', '/api/boards', { name: 'new board' })
  .then(({ body }) => {
    // the "key" will be underlined in editor
    expect(body.key).to.be.a('number')
  })

Помимо проверки кода в редакторе, можно настроить проверку lint, которая позволит убедиться в отсутствии ошибок в коде:

"scripts": {
  "lint": "tsc --noEmit"
}

Запуск команды npm run lint гарантирует, что любые ошибки в TypeScript, внесенные последними изменениями, будут обнаружены на ранних этапах. Вы можете настроить этот шаг проверки как pre-commit hook. Проверка занимает всего несколько секунд.

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

Приятным бонусом в TypeScript является возможность определения путей. Это устраняет проблему относительных путей в вашем проекте. Допустим, у вас есть путь, определенный в вашем файле tsconfig.json:

{
  "compilerOptions": {
    "target": "es5",
    "lib": ["es5", "dom"],
    "types": ["cypress","node"],
    "baseUrl": "./",
    "paths": {
      "@fixtures/*": [
        "cypress/fixtures/*"
      ]
    },
    "resolveJsonModule": true,
  }  
}

Импортировать fixture file в тест можно следующим образом:

import boardSchema from '@fixtures/boardSchema.json'

it('board returns proper JSON schema', () => {

  cy.api({
    url: `/api/boards/1`
  }).its('body')
  .should('jsonSchema', boardSchema)
  
})

Покрытие кода

Зачастую можно встретить команды тестирования, которые стремятся к 100%-ному покрытию кода, но я не считаю это особенно полезным. Отчет о покрытии кода может выступать в роли карты, показывая структуру приложения и указывая на места, которые еще не были протестированы.

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

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

Утилиты

Каждый проект специфичен и имеет ряд общих проблем, которые необходимо решить. Чтобы не решать одну и ту же проблему несколько раз, я помещаю все свои утилиты в папку cypress/utils. Это могут быть такие утилиты, как generateRandomUser()getAuthorization() и другие. Обычно я импортирую их прямо в тест, вместо того, чтобы включать в support file. В общем их не так много, поскольку Cypress включает в себя библиотеку lodash, в которой много полезных утилит.

// imports lodash from Cypress
const { _ } = Cypress

// generates number between 0 and 10
const randomNumber = _.random(10)

Глобальные хуки

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

beforeEach(() => {
  cy.setCookie('user_consents', '{"marketing":false,"essential":true}')
})

Вы всегда можете использовать команду cy.clearCookies() для удаления cookies в тесте, проверяющем сообщение о согласии на использование cookies.

Теги для тестов

Как только проект начинает расти, становится практически невозможным запускать все тесты при каждом коммите. Разделить тесты на категории можно с помощью плагина @cypress/grep. Он позволяет запускать тесты на основе их названия или тега.

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

it('creates a new board', { tags: ['@smoke'] }, () => {
  // test
})

Один тест может содержать несколько тегов, что позволяет запускать его на основе определенной цели тестирования. Например,  тег @email – для запуска всех тестов, использующих валидацию электронной почты,  @mobile – для всех мобильных тестов, или @visual – для всех тестов, содержащих визуальные валидации.

В CLI (Command Line Interface) эти тесты могут быть запущены следующей командой:

npx cypress run --env grepTags='@smoke'

Переключение конфигурации

Важно, чтобы тест работал в различных окружениях. Чтобы упростить эту задачу, я обычно создаю папку config, которая содержит .json файлы со всеми переменными, специфичными для окружения, такими как baseUrl, url API или другой информацией, которая может использоваться во время тестирования. Они передаются в объект env из .json файла и могут быть легко доступны с помощью Cypress.env().

Следующая настройка гарантирует, что необходимая информация будет добавлена в проект:

import { defineConfig } from 'cypress'

export default defineConfig({
  // other config attributes
  setupNodeEvents(on, config) {
    // if version not defined, use local
    const version = config.env.version || 'local'
    // load env from json
    config.env = require(`./cypress/config/${version}.json`);
    // change baseUrl
    config.baseUrl = config.env.baseUrl

    return config
  }
})

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

npx cypress open --env version="production"

и Cypress загрузит все необходимые переменные.

Помимо того, что конфигурация задается в отдельном .json файле, существует информация, которую не следует коммитить в репозитории, например, пароли, api ключи и т.д. .

Для облегчения работы я использую пакет dotenv который берет на себя управление переменными env с помощью .env файла.

ADMIN_KEY="1234-5678-abcd-efgh"

⚠️ Всегда следите за тем, чтобы .env файл был добавлен в .gitignore иначе вы рискуете выложить конфиденциальную информацию в открытый доступ.

Для загрузки ключей необходимо импортировать пакет dotenv в cypress.config.ts, чтобы переменные env были загружены в Cypress и могли быть использованы во время тестирования:

import { defineConfig } from 'cypress'
import 'dotenv/config'

export default defineConfig({
  // other config attributes
  setupNodeEvents(on, config) {
    // read ADMIN_KEY from .env file
    config.env.ADMIN_KEY = process.env.ADMIN_KEY
    return config
  }
})

Node scripts

Файл cypress.config.ts может становиться слишком большим, особенно при настройке задач или решении вопросов с конфигурацией. Поэтому я начал разбивать их на отдельные файлы и добавлять в папку scripts.

scripts/
|-- codeCoverage.ts
`-- resolveGoogleVars.ts

Таким образом, основной файл конфигурации остается чистым и легко читаемым. Это также облегчает поддержку нескольких cy.task() команд.

Документация

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

Обычно документация состоит из трех важных частей:

  1. установка проекта
  2. пояснения, рекомендации, примеры
  3. правила для pull requests

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

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

Я также считаю полезным установить некоторые основные правила для пул-реквестов.

cypress/
|-- commands/
|-- config/
`-- docs/
    |-- best-practices.md
    `-- installation.md

Вывод

Большие проекты редко сводятся к простому использованию команд Cypress, они имеют гораздо большее отношение к проектированию самих тестов и общей структуре проекта. Большинство моих проектов – это живые организмы, которые меняются и развиваются по мере того, как меняются время и потребности. Моя текущая структура проекта выглядит примерно так:

big-project/
|-- cypress/
|   |-- commands/
|   |-- config/
|   |-- docs/
|   |-- downloads/
|   |-- e2e/
|   |-- fixtures/
|   |-- screenshots/
|   |-- scripts/
|   |-- support/
|   |-- utils/
|   `-- videos/
`-- cypress.config.ts

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *