Е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)
})

Результат

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

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

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

Обработка ошибок в асинхронных функциях

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

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

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

Промисы

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

Если всё хорошо, получаем данные с помощью .json, который тоже возвращает промис. Если что‑то пошло не так, отловим ошибку в .catch.

const loadPost = (postId) =>
  fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`)
    .then(response => response.json())
    .then(data => console.log(data.title))
    .catch(e => console.log(`Error! ${e}`))

loadPost(1)

Асинхронные функции

Попробуем переписать эту функцию, используя async/await. Делаем её асинхронной с помощью ключевого слова async, без этого бы не сможем использовать await внутри.

Вторая строка совершает запрос, await «разворачивает» промис и возвращает значение, которое записывается в переменную response. Третья строка получает джейсон и записывает значение в переменную data.

const loadPost = async (postId) => {
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`)
  const data = await response.json()
  console.log(data.title)
}

loadPost(1)

Пока запрос проходит без ошибок у нас всё хорошо. Но если что‑то пойдёт не так, выскочит исключение:

Uncaught (in promise) TypeError: Failed to fetch

Обработка ошибок

Окей, используем try‑catch, чтобы отловить ошибку:

const loadPost = async (postId) => {
  try {
    const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`)
    const data = await response.json()
    console.log(data.title)
  }
  catch(e) {
    console.log(`Error! ${e}`)
  }
}

loadPost(1)

Вроде нормально, но функция стала больше, да и запросы могут быть разными, а каждый раз писать try‑catch запарно.

Вспоминаем, что асинхронная функция возвращает промис, поэтому можно использовать .catch, чтобы отловить ошибку:

const loadPost = async (postId) => {
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`)
  const data = await response.json()
  console.log(data.title)
}

loadPost(1)
  .catch(e => console.log(`Error! ${e}`))

Это решает проблему с повторами try‑catch, но не решает проблему с дублированием кода. Здесь может помочь функция высшего порядка.

const loadPost = async (postId) => {
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`)
  const data = await response.json()
  console.log(data.title)
}

// функция высшего порядка по очереди «запоминает»
// обработчик ошибок, сам запрос, аргументы для запроса
const tryCatchWrapper = (handleError) => (reqFn) => (...args) => 
  reqFn(...args).catch(handleError)

// обработчик ошибок
const handleError = e => 
  console.log(`Error! ${e}`)

// запомнили функцию для обработки ошибок
const errorHandlerWrapper = tryCatchWrapper(handleError)

// запомнили, какой запрос хотим выполнить
const safelyLoadPost = errorHandlerWrapper(loadPost)

// выполняем этот запрос
safelyLoadPost(1)

Запрос остался без изменений, но добавилась функция tryCatchWrapper. Она принимает как аргумент функцию handleError, которая будет обрабатывать исключения, и возвращает новую функцию.

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

По‑умному это ещё называют каррированием: когда мы из одной функции с несколькими аргументами делаем несколько функций, которые принимают по одному аргументу. Так мы можем «запоминать» аргументы, не вызывая функцию сразу, а вызывать её потом.

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

// первый обработчик
const handleError = e =>  console.log(`Error! ${e}`)
const errorHandlerWrapper = tryCatchWrapper(handleError)

// другой
const handleErrorDifferently = e => console.log(`Wow! It is all different now`)
const otherErrorHandlerWrapper = tryCatchWrapper(handleErrorDifferently)

И при этом не будет дублирования кода, потому что вся обработка находится внутри tryCatchWrapper.

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

«Эффективная работа с легаси‑кодом», часть 3

В прошлый раз мы поговорили о добавлении фич, ТДД и зависимостях. Сегодня — об изменении непонятного кода, гигантских классах и методах.

Глава 16. Я не понимаю этот код. Как мне его менять

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

Глава 17. У приложения нет структуры. Что делать

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

При работе с легаси может помочь приём «описание системы». Задайте себе вопрос «что из себя представляет структура системы?» и отвечайте на него так, как будто вам заранее ничего о ней не известно. Когда вы начнёте себе объяснять, как система функционирует, вы будете упрощать логику её работы. Это поможет выделить по‑настоящему важные элементы и построить идеальную схему взаимодействий внутри системы.

Глава 18. Тестовый код мне мешает

Помечайте тестовые классы или функции суффиксами .test, .spec, фиктивные объекты — .fake, .mock, .stub. Тесты также можно вынести в отдельную папку в проекте. Удобно когда структура файлов тестов повторяет структуру файлов проекта.

Глава 19. У меня не ООП. Как безопасно рефакторить код

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

Глава 20. Класс уже огромный. Не хочу, чтобы он стал больше

Если в классе 50 методов, то приходится долго вникать в его работу перед тем, как внести какие‑то изменения. Не увеличивать размер класса помогут почкование метода или класса, но это временное решение. Настоящее решение — рефакторинг.

Рефакторить большие классы нужно по принципу единой ответственности: когда одна сущность отвечает за одну конкретную задачу. Если причина для изменения какого‑то метода класса всего лишь одна — вы сделали всё правильно.

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

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

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

Глава 21. Я изменяю одинаковый код снова и снова

Один и тот же повторяющийся код можно переструктурировать по‑разному:

const func = () => {
  a(); a(); b(); a(); b(); b();
}

// может превратиться как в 
const func = () => {
  aa(); b(); a(); bb();
}

// так и в
const func = () => {
  a(); ab(); ab(); b();
}

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

class AddEmployeeCmd extends Command { 
  constructor() {
    this.name = ''
    this.address = ''

    this.header = ['some', 'sophisticated', 'data', 'structure']
    this.commandCharIndex = 42
    this.footer = ['some', 'sophisticated', 'data', 'structure']
  }
}

class LoginCommand extends Command { 
  constructor() {
    this.userName = ''
    this.password = ''

    this.header = ['some', 'sophisticated', 'data', 'structure']
    this.commandCharIndex = 44
    this.footer = ['some', 'sophisticated', 'data', 'structure']
  }
}

// мы можем вынести header и footer прямо в Command,
// потому что они одинаковые

class Command {
  constructor() {
    this.header = ['some', 'sophisticated', 'data', 'structure']
    this.footer = ['some', 'sophisticated', 'data', 'structure'] 
    // ...
  }
}

class AddEmployeeCmd extends Command { 
  constructor() {
    this.name = ''
    this.address = ''
  }
  // ...
}

class LoginCommand extends Command { 
  constructor() {
    this.userName = ''
    this.password = ''
  }
  // ...
}

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

Глава 22. Мне надо изменить гигантский метод, и я не могу написать тесты к нему

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

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

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

// этот класс строит ДОМ-дерево,
// мы хотим отрефакторить условие добавления узла в корень дерева
// но не знаем, в какой момент это происходит

class DomBuilder {
  processNode(node) {
    // ...
    if (node.type() === 'TYPE1' 
        || node.type() === 'TYPE2' 
        || node.type() === 'TYPE3' && node.isVisible()) {
      this.root.appendChild(node)
    }
  }
}

// вводим переменную, которая поможет выявить этот момент

class DomBuilder {
  constructor() {
    this._nodeAdded = false
  }

  processNode(node) {
    // ...запутанная логика
    if (this.isBaseChild(node)) {
      this.root.appendChild(node)
      this._nodeAdded = true
    }
  }

  isBaseChild(node) {
    return node.type() === 'TYPE1' 
        || node.type() === 'TYPE2' 
        || node.type() === 'TYPE3' && node.isVisible()
  }
}

// тест на случай, когда узел должен был добавиться
// и на случай, когда не должен был

it('tests if node is base child', () => {
  const node = new Node('TYPE1')
  const builder = new DomBuilder()

  builder.processNode(node)

  expect(builder._nodeAdded).toEqual(true)
})

it('tests if node is not base child', () => {
  const node = new Node('TYPE5')
  const builder = new DomBuilder()

  builder.processNode(node)

  expect(builder._nodeAdded).toEqual(false)
})

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

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

Глава 23. Как мне узнать, что я ничего не сломал

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

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

Что дальше

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

«Эффективная работа с легаси‑кодом», часть 2

В первой части мы рассмотрели введение, причины изменения кода, швы и инструменты автоматизированного рефакторинга. Сегодня говорим об изменении кода, когда не хватает времени, добавлении фич, ТДД и зависимостях.

Глава 6. У меня нет времени, но мне нужно что‑то поменять

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

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

class UsersList {
  // метод отправляет уведомления об акции 
  // указанным пользователям
  notifyOfAction(users) {
    const message = 'some message to send'
    users.forEach(user => {
      this.sendEmail(user, message)
    })
  }

  // ...
}

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

class UsersList {
  notifyOfAction(users) {
    const message = 'some message to send'
    users = users.filter(user => user.age >= 18)
    users.forEach(user => {
      this.sendEmail(user, message)
    })
  }

  // ...
}

Но таким образом мы не поймём, где заканчивается старый код, где начинается новый, и как это тестировать. Лучше вынести фильтрацию в новый метод:

class UsersList {
  notifyOfAction(users) {
    const message = 'some message to send'
    users = this.filterInAdults(users)
    users.forEach(user => {
      this.sendEmail(user, message)
    })
  }

  filterInAdults(users) {
    return users.filter(user => user.age >= 18)
  }

  // ...
}

Теперь новый метод filterInAdults можно покрыть тестами.

Алгоритм метода:

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

Минусы:

  • вы на какое‑то время оставляете исходный метод без внимания, не собираясь его тестировать или менять;
  • иногда становится непонятно, почему действия происходят в другом методе.

Плюсы:

  • новый код полностью отделён от старого;
  • возможно протестировать;
  • можно наблюдать за всеми переменными, которые меняют поведение метода.

Почкование можно применить для целого класса, а не только метода. Например, если нужно поменять формат вывода результата со строки на ХТМЛ.

Оборачивание метода (в переводе охват, прим. автора статьи). Ещё один способ добавить поведение к уже существующему коду. Метод pay класса Employee вызывает paymentDispatcher, чтобы выплатить деньги сотруднику.

class Employee { 
  pay(amount) {
    const date = // ...
    paymentDispatcher(date, amount)
  }
}

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

class Employee { 
  // новый метод со старым кодом
  dispatchPayment(amount) {
    const date = // ...
    paymentDispatcher(date, amount)
  }

  // старый метод с вызовом выделенного и добавленным логированием
  pay(amount) {
    this.logPayment(amount)
    this.dispatchPayment(amount)
  }

  // добавочный метод
  logPayment(amount) {
    // ...
  }
}

Алгоритм:

  • определить метод, который надо изменить;
  • создать новый метод с такой же сигнатурой;
  • вызвать новый метод внутри старого;
  • создать метод для нового поведения через ТДД и вызывать его в старом методе.

Плюсы:

  • старые методы не меняются, только лишь переименовываются;
  • новые функции явно не зависимы от других.

Минусы:

  • могут появиться неудачные названия методов.

Можно так же обернуть не только метод, но и класс. Для примера выше можно создать класс LoggingEmployee, который будет наследоваться от Employee, и перегрузить метод pay, добавив нужное новое поведение. По‑другому это ещё называется декоратором.

Глава 7. Изменения до бесконечности

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

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

Глава 8. Как добавить фичу

Алгоритм работы по ТДД:

  • написать отказной тест;
  • написать функцию, чтобы тест перестал падать;
  • убрать дублирование кода;
  • повторить.

На примерах, первый шаг:

// пишем тест, который заведомо упадёт
it('Возвращает сумму двух однозначных чисел', () => {
  expect(sum(1, 1)).toEqual(1 + 1)
})

// функция пока пустая
const sum = (a, b) => {}

Убеждаемся, что тест падает с той причиной, которую мы ожидаем. На втором шаге:

// меняем реализацию функции или метода так, чтобы тест проходил
const sum = (a, b) => a + b

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

Глава 9. Я не могу протестировать этот класс

Некоторые классы трудно тестировать: дорого создавать объекты этого класса, при создании появляются сайд‑эффекты, куча параллельной работы выполняется. Самый простой способ узнать, сколько проблем доставит создание экземпляра такого класса — создать его. Попробуйте создать объект, отладчик скажет, чего не хватает для создания.

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

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

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

Глава 10. Я не могу выполнить этот метод в средствах тестирования

Иногда методы класса тоже трудно протестировать:

  • он приватный;
  • трудно создать аргументы для вызова этого метода;
  • метод имеет сайд‑эффекты.

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

При наличии сайд‑эффектов стоит попробовать извлечь метод. Класс ниже создаёт элементы интерфейса и решает, какую информацию в них отображать:

class AccountDetails {
  // ...

  handlePerformedAction(event) {
    const {type} = event
    if (type === 'project activity') {
      const modal = this.createModal()
      const display = this.createElement('input')
      modal.setDescription('modal text')
      modal.show()

      let accountDescription = this.modal.getSymbol()
      accountDescription += ': '
      // ...
      display.value = accountDescription
    }
  }
}

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

class AccountDetails {
  // ...

  handlePerformedAction(event) {
    const {type} = event
    this.performCommand(type)
  }

  performCommand(type) { 
    if (type === 'project activity') {
      this.setDescription('modal text')
      this.updateModalValue()
    }
  }

  setDescription(text) {
    const modal = this.createModal()
    modal.setDescription(text)
    modal.show()
  }

  updateModalValue() {
    const display = this.createElement('input')
    let accountDescription = this.modal.getSymbol()
    accountDescription += ': '
    // ...
    display.value = accountDescription
  }
}

Разделение команд и запросов. Метод может быть либо командным, либо запросным, но не тем и не другим одновременно. Командный — который видоизменяет состояние объекта, но не возвращает значение. Запросный возвращает значение, но не видоизменяющий объект.

Глава 11. Надо изменить код, но непонятно, какие методы тестировать

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

Воздействия распространяются в коде тремя основными путями.

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

Алгоритм распознавания воздействий:

  • определить метод, который надо поменять;
  • если метод возвращает значение, проверить места, где этот метод вызывается;
  • если метод меняет какие‑то параметры, проверить места, которые используют эти параметры;
  • проверить суперклассы и подклассы объекта с этим методом;
  • найти глобальные переменные, которые этот метод меняет в ходе работы.

Главы 12–13. Какие тесты писать, чтобы изменить код

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

Алгоритм создания характеристических тестов:

  • начать тестировать небольшой фрагмент кода;
  • написать заведомо отказной тест;
  • выявить поведение кода;
  • поправить тест так, чтобы в нём предполагалось это поведение.

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

Глава 14. Зависимости от библиотек меня убивают

Избегайте беспорядочного расположения прямых вызовов библиотек в своем коде.

В следующий раз

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

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

«Эффективная работа с легаси‑кодом» Майкла Физерса

Приёмы из этой книги мне помогают в работе, поэтому я решил составить по ней конспект. Примеры в ней написаны на Джаве и С++, поэтому не всё получилось перевести на Джаваскрипт. Я постарался вытянуть наиболее важное, но советую прочесть книгу и самим. Теперь к делу.

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

Глава 1. Изменение софта

Менять код нужно, чтобы:

  • добавить фичу;
  • пофиксить баг;
  • улучшить дизайн кода;
  • оптимизировать использование ресурсов.

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

// до изменения
class Player {
  addPlaylist(name, tracks) {
    ...
  }
}

// после
class Player {
  addPlaylist(name, tracks) {
    ...
  }

  deletePlaylist(name) {
    ...
  }
}

Пока новый метод нигде не вызывается, поведение не меняется. Чтобы это поведение добавить, мы выводим кнопку на экране. Из‑за этого интерфейс отрисовывается на долю секунды дольше. Это незаметно, но поведение поменялось.

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

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

Сохранить поведение неизменным трудно. Каждое изменение кода несёт риск изменения поведения. Чтобы смягчить риск, перед изменениями задавать себе три вопроса:

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

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

Глава 2. Работать на обратную связь

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

Хороший юнит‑тест:

  • быстрый;
  • помогает найти проблему быстро;
  • не имеет внешних зависимостей.

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

Алгоритм изменения легаси‑кода:

  • найти точки изменения;
  • найти точки тестирования;
  • разорвать зависимости;
  • написать тесты;
  • сделать изменения и отрефакторить.

Глава 3. Распознавание и разделение

Для тестирования нужно разрывать зависимости в коде по двум причинам:

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

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

class NetworkBridge {
  constructor(endpoints) {
    ...
  }

  formRouting(sourceId, destId) { 
    ...
  }
}

Как его тестировать? Если он связан с железом, можем ли мы себе позволить на каждый тест нагружать оборудование? Можем ли создать тестовый кластер? Есть ли на это ресурсы и время? Такие проблемы возникают, когда мы не понимаем, как выделить нужную часть и тестировать её изолированно. Здесь могут помочь фиктивные объекты.

Фиктивные объекты олицетворяют какой‑либо класс во время тестирования. Например, у нас есть класс Sale, который сканирует штрих‑коды, и выводит сообщения на экран устройства через класс Display:

class Sale {
  constructor(display) {
    this._display = display
  }

  scan(barcode) {
    // сканирует
    ...
    // выводит сообщение
    this._display.showMessage('hello world')
  }
}

class Display {
  showMessage(msg) {
    ...
  }
}

const display = new Display()
const sale = new Sale(display)

Чтобы не зависеть от конкретного оборудования, мы можем написать поддельный класс FakeDisplay:

class FakeDisplay {
  // вместо вывода на экран, будем запоминать сообщение
  // это метод, который имитирует настоящий метод класса Display
  showMessage(msg) {
    this.lastLine = msg
  }

  // и потом выводить его по требованию
  // это доп. метод, который нужен именно в тестах
  getLastLine() {
    return this.lastLine
  }
}

В тесте мы можем подменить класс, работающий с конкретным оборудованием на поддельный:

it('Выводит название товара на экран', () => {
  const fakeDisplay = new FakeDisplay()
  const saleTest = new Sale(fakeDisplay)

  saleTest.scan('1')

  expect(fakeDisplay.getLastLine).toEqual('Молоко')
})

Этот тест не упадёт, если не работает какая‑то часть в настоящем классе Display. Но мы тестируем класс Sale, а не Display, поэтому конкретно в этом тесте это не важно.

Глава 4. Швы

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

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

Удобнее всего в объектно‑ориентированных языках использовать объектные швы, когда действие какого‑то метода подменяется на другое. Например, при создании экземпляра класса в конструкторе.

Глава 5. Инструменты автоматизированного рефакторинга

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

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

// класс до рефакторинга
class Example {
  alpha = 0

  getValue() {
    this.alpha++
    return 42
  }

  doSomething() {
    let total = 0
    const val = this.getValue()
    for (let i = 0; i < 5; i++) {
      total += val
    }
  }
}

// после
class Example {
  alpha = 0

  getValue() {
    this.alpha++
    return 42
  }

  doSomething() {
    let total = 0
    for (let i = 0; i < 5; i++) {
      total += this.getValue()
    }
  }
}

Лишняя переменная исчезла, но вместе с этим alpha++ вызвалось 5 раз вместо 1. Юнит‑тесты помогут выявить это изменение.

В следующий раз

Поговорим об изменении кода, когда не хватает времени, добавлении фич, ТДД и зависимостях.

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

Раньше ↓