Е2Е тестирование Койна

Койн — приложение для учёта расходов. Мы делаем его в открытом режиме и рассказываем о процессе на стримах. В этом посте я покажу, как тестирую в Койне пользовательские сценарии.

Сейчас в нём три важных сценария: вход в приложение, создание бюджета и запись траты или дохода. Логика покрыта юнит‑тестами, но этого мало. Хочется быть уверенным, что если сценарии где‑то сломаются, то я об этом узнаю сразу. Поэтому для Койна я пишу ещё и Е2Е тесты.

Инструменты

End‑to‑end (Е2Е) тесты — это интеграционные тесты, которые взаимодействуют с интерфейсом так, как это делал бы пользователь. Для них я попробовал несколько инструментов, но больше всего мне понравился Сайпрес.

После его установки и запуска в корне проекта появляется папка cypress/. Внутри неё: integration/ — там находятся сами тесты, и support/ — там вспомогательные функции (об этом подробнее дальше).

Вход в приложение

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

Исхода у сценария два: успешный и неуспешный вход. Пишем тест на первый случай.

describe('Login window', () => {
  it('Valid code passes login', () => {
    // здесь будет логика теста
  })
})

Нам надо зайти в приложение и попасть на страницу логина. На страницу мы зайдём с помощью команды visit, передав аргументом адрес:

// к примеру проверяем приложение локально
cy.visit('localhost:8081')

Проверим, существует ли форма логина, и пустое ли поле ввода. Проверяем наличие нужных блоков и то, что поле пустое:

cy.get('.login').should('have.length', 1)
cy.get('.login-code').should('be.empty')

Выборка элементов в Сайпресе работает похоже на Джейквери. Например, здесь мы выбираем элементы по классам. Метод should проверит, что на странице только 1 элемент с классом login, а элемент с классом login‑code пустой.

Набор текста в настоящих полях ввода в Сайпресе делается через метод type. Но в Койне клавиатура ненативная и настоящих полей ввода там тоже нет. Вместо них — блоки, в которых отображается «набранная» последовательность. Чтобы набрать какой‑то код на нашей клавиатуре, надо «нажать» клавишу с нужной цифрой. Мы будем разбивать код на символы и нажимать на клавиши с указанными символами.

Метод contains ищет элемент, который содержит переданный в аргументе текст, в нашем случае — символ. Метод closest находит ближайшего родителя с указанным селектором, в нашем случае — классом button.

const chars = code.toString().split('')

chars.forEach(char => {
  cy.get('.keyboard')
    .contains(char)
    .closest('.button')
    .click()
})

Когда код набран, можно нажать на красную кнопку, чтобы «отправить» код.

cy.get('.button.is-enter').click()

Код теста целиком будет выглядеть так:

describe('Login window', () => {
  it('Valid code passes login', () => {
    // зайти в приложение
    cy.visit('localhost:8081')

    // проверить форму
    cy.get('.login').should('have.length', 1)
    cy.get('.login-code').should('be.empty')

    // ввести код
    const chars = validCode.toString().split('')

    chars.forEach(char => {
      cy.get('.keyboard')
        .contains(char)
        .closest('.button')
        .click()
    })

    // нажать энтер
    cy.get('.button.is-enter').click()
  })
})

После запуска Сайпрес запустит браузер, прогонит сценарий и покажет, прошёл тест или нет. Выглядит это так: Результат выполнения теста на вход в приложение

Рефакторинг и второй сценарий

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

Команды похожи на плагины. Вы описываете функцию‑команду, и она становится доступной глобально через cy. Команды хранятся в папке support/, их можно как угодно разделять по файлам. Главное — импортировать их в support/index.js, чтобы Сайпрес их увидел.

Адрес страницы‑приложения меняться не будет, поэтому вход в приложение вынесем в команду enterApp, а сам адрес запишем в fixtures/common.json:

import {baseUrl} from '../fixtures/common.json'

Cypress.Commands.add('enterApp', () =>
  cy.visit(baseUrl))

Проверка формы тоже будет повторяться, поэтому вынесем её в команду appContainsEmptyLoginForm.

Cypress.Commands.add('appContainsEmptyLoginForm', () => {
  cy.get('.login').should('have.length', 1)
  cy.get('.login-code').should('be.empty')
})

Я предпочитаю называть команды либо:

  • глаголом с действием, которое надо выполнить: enterApp;
  • предикатом для проверок: appContainsEmptyLoginForm.

Первые ничего не проверяют, а лишь выполняют какое‑то побочное действие. Вторые проверяют то, что описано в названии.

Ввод чисел на клавиатуре нам тоже понадобится в других тестах приложения. Поэтому его мы превратим в команду keyboardType.

Cypress.Commands.add('keyboardType', (str) => {
  const chars = str.toString().split('')

  chars.forEach(char => {
    cy.get('.keyboard')
      .contains(char)
      .closest('.button')
      .click()
  })
})

Нажатие на «энтер» нам тоже пригодится в других местах:

Cypress.Commands.add('pressEnter', () => {
  cy.get('.button.is-enter').click()
})

В итоге код теста станет таким:

describe('Login', () => {
  it('Valid code passes login', () => {
    cy.enterApp()
    cy.appContainsEmptyLoginForm()
    cy.enterLoginCode(validCode)
    cy.get('.login').should('have.length', 0)
  })
})

Теперь напишем тест на неправильный код:

it('Invalid codes dont pass login', () => {
  cy.enterApp()
  cy.appContainsEmptyLoginForm()
  cy.enterLoginCode(invalidCode)
  cy.get('.login').should('have.length', 1)
})

Как мы видим, первые две строки повторяются, поэтому их можно вынести к сетап теста:

describe('Login', () => {
  beforeEach(() => {
    cy.enterApp()
    cy.appContainsEmptyLoginForm()
  })

  it('Valid code passes login', () => {
    cy.enterLoginCode(validCode)
    cy.get('.login').should('have.length', 0)
  })

  it('Invalid codes dont pass login', () => {
    cy.enterLoginCode(invalidCode)
    cy.get('.login').should('have.length', 1)
  })
})

После запуска увидим такую картину: Результат выполнения отказного теста на вход в приложение

Если какой‑то тест свалится, то в логах будет описано, что не совпало. Сайпрес офигительно описывает ошибку: показывает, какой узел её вызвал, что не совпало, и сохраняет снапшот состояния. То есть можно ткнуть мышью в этот баг, и справа отобразится состояние страницы в это время! Описание ошибки в тестировании

Сценарий создания бюджета

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

Заведём бюджет из 10000 попугаев на 10 дней. Приложение запишет в бюджет только 95% от той суммы, которую вводим, чтобы план не оказался впритык. Значит, после сохранения бюджет будет содержать 9500 попугаев.

describe('Budget creation', () => {
  before(() => {
    // команда для быстрого логина в приложение, минуя форму
    cy.login()
    cy.enterApp()    
    // открывает настройки бюджета
    cy.openBudgetSettings()
  })

  it('Inputs the budget sum and saves it', () => {
    cy.keyboardType('10000')
    cy.pressEnter()

    cy.get('.budget')
      .contains('9500')
  })
})

Дальше выбираем срок. Мы выберем 10 дней, поэтому нам надо выделить 10‑й пункт в крутилке с датами. Проверяем, что даты до выбранной включительно стали красными и что в бюджете появилась строка с суммой на день.

it('Inputs the budget time and saves it', () => {
  cy.get('.datepicker-item')
    // индексы начинаются с нуля, 10-й элемент — eq(9)
    .eq(9)
    .click()

  cy.get('.datepicker-item.has-red-color')
    .should('have.length', 10)

  cy.get('.dialogue-secondary')
    .contains('на 10 дней. 950 в день')

  cy.get('.button.is-fixed-rb')
    .click()
})

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

Cypress.Commands.add('counterContains', (content) => {
  cy.get('.mainContent .dialogue .counter')
    .contains(content)
})

it('Tests todays limit', () => {
  cy.counterContains(950)
})

И что сохранилась запись в истории о создании бюджета:

Cypress.Commands.add('budgetRecordContains', (sum, days) => {
  const $lastRecord = (selector) =>
    cy.get('.timeline')
      .find(selector)
      .last()

  $lastRecord('.record--budget').contains(sum)
  $lastRecord('.record--budget').contains(days)
})

it('Tests history record', () => {
  cy.budgetRecordContains(9500, 10)
})

После запуска увидим такую картину: Результат выполнения теста на создание бюджета

Основной сценарий трат

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

describe('Tests spendings', () => {
  context('When budget is not set', () => {
    beforeEach(() => {
      cy.login()
      cy.enterApp()
    })

    it('Spends 400 parrots for helpful stuff', () => {
      // ...
    })
  })
})

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

// команда для включения и выключения категорий
Cypress.Commands.add('toggleCategory', (type='helpful') => {
  cy.get(`.category.is-${type}`).click()
})

const spendMoneyOnce = (amount, category='unknown') => {
  amount = `${amount}`
  cy.keyboardType(amount)
  cy.get('.numberDisplay-value')
      .contains(amount)

  if (category !== 'unknown') {
    cy.toggleCategory(category)
  }

  cy.pressEnter()
}

И функцию, которая будет проверять, сохранилась ли трата:

const spendSaved = (amount, category) => {
  // команда для проверки последней записи в истории
  cy.lastRecordContains(amount, category)
}

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

it('Spends 400 parrots for helpful stuff', () => {
  const [amount, category] = [400, 'helpful']
  spendMoneyOnce(amount, category)
  spendSaved(amount, category)
})

it('Spends 400 parrots for harmful stuff', () => {
  const [amount, category] = [400, 'harmful']
  spendMoneyOnce(amount, category)
  spendSaved(amount, category)
})

Траты из заполненного бюджета

Теперь протестируем трату, когда бюджет задан. У меня описано много сценариев, но здесь я покажу два. В первом трата меньше дневного лимита, и сумма на день остаётся такой же, во втором — трата больше, и сумма на день уменьшается.

context('When budget is set, 950 for today', () => {
  beforeEach(() => {
    cy.login()
    cy.enterApp()
    // команда для быстрого создания бюджета с указанными параметрами
    cy.createBudgetWith(10000, 10)
  })

  it('Spends amount smaller than the limit for today', () => {
    testSpendWithActiveBudget({
      amount: 100,
      forToday: 850
    })
  })

  it('Spends amount bigger than the limit for today', () => {
    testSpendWithActiveBudget({
      amount: 1000,
      forToday: -50,
      newDayLimit: '944,44'
    })
  })
})

Функция testSpendWithActiveBudget берёт на себя алгоритм проверки. Она совершает трату, проверяет, что трата записалась, затем проверяет, должна ли была сумма на день пересчитаться. Если да, то проверяет новую сумму на день. Если нет, то проверяет остаток на сегодня — разницу между дневным лимитом и суммой трат за сегодня.

const testSpendWithActiveBudget = ({
  amount,      // число
  forToday,    // число
  newDayLimit, // форматированная строка
}) => {
  spendMoneyOnce(amount, 'unknown')
  cy.lastRecordContains(amount, 'unknown')

  // трата меньше, чем лимит на сегодня
  if (forToday > 0) {
    cy.counterContains(forToday)
  }
  // трата больше, приложение пересчитает сумму на день
  else {
    cy.counterRowContains('Новая сумма на день', 0)
    cy.counterRowContains(newDayLimit, 0)

    cy.counterRowContains('На сегодня дно пробито', 1)
    cy.counterRowContains(forToday, 1)
  }
}

Меняем даты в браузере

Осталось протестировать, как себя ведёт бюджет и история, если приложение запускают через день или несколько дней.

context('Tests next day settings', () => {
  beforeEach(() => {
    cy.login()
    cy.enterApp()
    cy.createBudgetWith(10000, 10)
  })
})

Сперва проверим, что непотраченные деньги попадают в копилку:

it('Tests next day safe record', () => {
  spendMoneyOnce(400)
  cy.skipDay()
  cy.safeRecordContains(550)
})

Затем, что сумма на день осталась той же, если пользователь не вышел за вчерашний лимит

it('Tests next day limit after spend less than prev limit', () => {
  spendMoneyOnce(400)
  cy.skipDay()
  cy.counterContains(950)
})

И что сумма уменьшится, если пользователь вышел за лимит:

it('Tests next day limit after spending more than prev limit', () => {
  spendMoneyOnce(1000)
  cy.skipDay()
  cy.counterContains('944,44')
})

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

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

Первым агументом ему передаём таймштамп момента, в который надо перевести часы. Вторым — функции и объекты, которые будут изменены во время выполнения. Нам достаточно подменить только объект Date.

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

Метод reload перезагружает страницу — будто пользователь заходит в приложение спустя указанное время.

Cypress.Commands.add('skipDays', (count=1, from=Date.now()) => {
  cy.clock().then(clock => clock.restore())
  cy.clock(from + (count * MSECONDS_IN_DAY), ['Date'])
  cy.reload()
})

// синоним для cy.skipDays(1)
Cypress.Commands.add('skipDay', () => {
  cy.skipDays(1)
})

Результат

Самое кайфовое в таких тестах — смотреть, как они работают. Вот ускоренный видосик со всеми тестами, которые есть в проекте. Самые глазастые смогут там найти настоящий рабочий пароль для входа в приложение :–)

Видео с работой всех тестов в проекте

Ссылки по теме

← Куршская коса, июнь 2018 Как я избавился от иррационального страха потерять работу →