Статическое тестирование vs модульное тестирование vs интеграционное тестирование vs E2E тестирование для фронтенд-приложений


Это перевод оригинальной статьи Static vs Unit vs Integration vs E2E Testing for Frontend Apps, написанной Кентом К. Доддсом.

В своем интервью «Практика тестирования с J.B. Rainsberger», которое доступно на TestingJavascript.com, он рассказал мне метафору, которая мне очень понравилась. Он сказал:

«Вы можете бросить краску на стену и, возможно, достигнете большей ее части, но пока вы не пройдетесь по стене кистью, вы никогда не достигнете углов. 🖌️»

Мне нравится эта метафора в применении к тестированию, потому что в ней говорится, что выбор правильной стратегии для тестирования — это тот же выбор, который вы делаете, выбирая кисть для покраски стены. Используете ли вы тонкую кисть для всей стены? Конечно, нет. Это займет слишком много времени, а конечный результат будет не очень хорошим. Стали бы вы использовать валик, чтобы покрасить все, включая мебель, которую ваша прапрабабушка привезла из-за океана 200 лет назад? Ни за что. Существуют разные щетки для разных целей, и то же самое относится к тестированию.

Именно поэтому я создал трофей «Доказательство». С тех пор Мэгги Эпплтон (автор великолепного дизайна egghead.io) создала его для TestingJavascript.com:

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

  • Конечное тестирование или тестирование от конца до конца: Робот-помощник, который ведет себя как пользователь, чтобы использовать приложение и проверить, что оно работает правильно. Иногда называется «функциональным тестированием» или e2e.
  • Интеграция: Проверяет, что различные устройства работают слаженно.
  • Блок: Проверяет, что отдельные, раздельные части работают так, как ожидается.
  • Статический: Выявляет опечатки и ошибки при написании кода.

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

Виды тестирования

Давайте рассмотрим примеры этих видов тестирования сверху вниз:

От конца до конца

Эти тесты будут запускать все приложение (frontend и backend), и ваши тесты будут взаимодействовать с приложением так же, как и любой пользователь. Эти тесты написаны с помощью кипариса.

import {generate} from 'todo-test-utils'

describe('todo app', () => {
  it('should work for a typical user', () => {
    const user = generate.user()
    const todo = generate.todo()
    // here we're going through the registration process.
    // I'll typically only have one e2e test that does this.
    // the rest of the tests will hit the same endpoint
    // that the app does so we can skip navigating through that experience.
    cy.visitApp()

    cy.findByText(/register/i).click()

    cy.findByLabelText(/username/i).type(user.username)

    cy.findByLabelText(/password/i).type(user.password)

    cy.findByText(/login/i).click()

    cy.findByLabelText(/add todo/i)
      .type(todo.description)
      .type('{enter}')

    cy.findByTestId('todo-0').should('have.value', todo.description)

    cy.findByLabelText('complete').click()

    cy.findByTestId('todo-0').should('have.class', 'complete')
    // etc...
    // My E2E tests typically behave similar to how a user would.
    // They can sometimes be quite long.
  })
})
Войдите в полноэкранный режим Выход из полноэкранного режима

Интеграция

Тест отображает все приложение. Это НЕ является требованием для интеграционных тестов, и большинство моих интеграционных тестов не отображают все приложение. Однако они будут отображаться со всеми провайдерами, используемыми в моем приложении (это то, что делает метод ‘render’ воображаемого модуля ‘test/app-test-utils’). Идея интеграционных тестов заключается в том, чтобы дремать как можно меньше. Я обычно дремлю:

  1. Сетевые запросы (с использованием MSW)
  2. Компоненты, отвечающие за анимацию (потому что кто захочет ждать этого в ваших тестах?)
import * as React from 'react'
import {render, screen, waitForElementToBeRemoved} from 'test/app-test-utils'
import userEvent from '@testing-library/user-event'
import {build, fake} from '@jackfranklin/test-data-bot'
import {rest} from 'msw'
import {setupServer} from 'msw/node'
import {handlers} from 'test/server-handlers'
import App from '../app'

const buildLoginForm = build({
  fields: {
    username: fake(f => f.internet.userName()),
    password: fake(f => f.internet.password()),
  },
})

// integration tests typically only mock HTTP requests via MSW
const server = setupServer(...handlers)

beforeAll(() => server.listen())
afterAll(() => server.close())
afterEach(() => server.resetHandlers())

test(`logging in displays the user's username`, async () => {
  // The custom render returns a promise that resolves when the app has
  //   finished loading (if you're server rendering, you may not need this).
  // The custom render also allows you to specify your initial route
  await render(<App />, {route: '/login'})
  const {username, password} = buildLoginForm()

  userEvent.type(screen.getByLabelText(/username/i), username)
  userEvent.type(screen.getByLabelText(/password/i), password)
  userEvent.click(screen.getByRole('button', {name: /submit/i}))

  await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i))

  // assert whatever you need to verify the user is logged in
  expect(screen.getByText(username)).toBeInTheDocument()
})
Войдите в полноэкранный режим Выход из полноэкранного режима

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

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

Унитарный

import '@testing-library/jest-dom/extend-expect'
import * as React from 'react'
// if you have a test utils module like in the integration test example above
// then use that instead of @testing-library/react
import {render, screen} from '@testing-library/react'
import ItemList from '../item-list'

// Some people don't call these a unit test because we're rendering to the DOM with React.
// They'd tell you to use shallow rendering instead.
// When they tell you this, send them to https://kcd.im/shallow
test('renders "no items" when the item list is empty', () => {
  render(<ItemList items={[]} />)
  expect(screen.getByText(/no items/i)).toBeInTheDocument()
})

test('renders the items in a list', () => {
  render(<ItemList items={['apple', 'orange', 'pear']} />)
  // note: with something so simple I might consider using a snapshot instead, but only if:
  // 1. the snapshot is small
  // 2. we use toMatchInlineSnapshot()
  // Read more: https://kcd.im/snapshots
  expect(screen.getByText(/apple/i)).toBeInTheDocument()
  expect(screen.getByText(/orange/i)).toBeInTheDocument()
  expect(screen.getByText(/pear/i)).toBeInTheDocument()
  expect(screen.queryByText(/no items/i)).not.toBeInTheDocument()
})
Войдите в полноэкранный режим Выход из полноэкранного режима

Все называют это юнит-тестом, и это нормально:

// pure functions are the BEST for unit testing and I LOVE using jest-in-case for them!
import cases from 'jest-in-case'
import fizzbuzz from '../fizzbuzz'

cases(
  'fizzbuzz',
  ({input, output}) => expect(fizzbuzz(input)).toBe(output),
  [
    [1, '1'],
    [2, '2'],
    [3, 'Fizz'],
    [5, 'Buzz'],
    [9, 'Fizz'],
    [15, 'FizzBuzz'],
    [16, '16'],
  ].map(([input, output]) => ({title: `${input} => ${output}`, input, output})),
)
Войдите в полноэкранный режим Выход из полноэкранного режима

Статический

// can you spot the bug?
// I'll bet ESLint's for-direction rule could
// catch it faster than you in a code review 😉
for (var i = 0; i < 10; i--) {
  console.log(i)
}

const two = '2'
// ok, this one's contrived a bit,
// but TypeScript will tell you this is bad:
const result = add(1, two)
Войдите в полноэкранный режим Выход из полноэкранного режима

Опять же, зачем мы проводим тесты?

Я думаю, что важно помнить, почему мы пишем тесты в первую очередь. Зачем вы пишете тесты? Это потому что я тебе так сказал? Это потому, что ваш PR будет отклонен, если вы не включите их? Это потому, что пробные экземпляры улучшают ваш рабочий процесс?

Самая большая и важная причина, по которой я пишу тесты, — это УВЕРЕННОСТЬ. Я хочу быть уверен, что код, который я пишу для будущего, не сломает приложение, работающее в производстве сегодня. Поэтому, что бы я ни делал, я хочу быть уверен, что типы тестов, которые я пишу, дают мне как можно больше уверенности, и я должен знать о компромиссах при добавлении тестов.

Давайте поговорим о компромиссах

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

Стрелки на рисунке обозначают компромиссы, на которые вы идете при написании автоматизированных тестов:

Стоимость ➡ 💰🤑💰🤑💰💰

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

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

Скорость: 🏎💨 🏎💨 ➡ 🐢

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

Уверенность: Маленькие проблемы 👌 ➡ Большие проблемы 😖

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

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

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

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

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

Ранее я сказал вам помнить две вещи:

  • Чем выше по карьерной лестнице вы поднимаетесь, тем больше точек отказа и тем чаще тесты будут давать сбой.
  • Юнит-тесты обычно тестируют что-то небольшое, не имеющее зависимостей или зависимостей, которые будут отменены (фактически это может изменить тысячи строк кода на несколько).

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

В частности, инструменты статического анализа не способны дать вам уверенность в бизнес-логике. Юнит-тесты не могут гарантировать, что когда вы делаете вызов зависимости, вы вызываете ее правильно (хотя вы можете делать утверждения о том, как она вызывается, вы не можете гарантировать, что она вызывается правильно с помощью юнит-тестов). Интеграционные тесты UI (User Interface) не могут гарантировать, что вы передаете правильные данные бэкенду и что ваша реакция и реакция на ошибки является адекватной. End-to-End тесты вполне способны, но обычно вы запускаете их в непроизводственной среде (как в производственной, но не в производственной), чтобы обменять уверенность на выполнимость.

Теперь давайте впадем в другую крайность. Если вы попытаетесь использовать E2E-тесты для проверки типов в определенном поле и нажатия кнопки отправки на крайний случай в интеграции между формой и генератором URL, то вы будете делать много настроек для запуска приложения (включая бэкенд). Это больше подходит для интеграционного тестирования. Если вы пытаетесь использовать интеграционный тест для достижения граничного случая для калькулятора купонов, то вы выполняете достаточно большой объем работы по настройке, чтобы убедиться, что компоненты, использующие калькулятор купонов, отображаются, и вы могли бы лучше покрыть этот граничный случай модульным тестом. Если вы пытаетесь с помощью модульного теста проверить, что произойдет, если вы вызовете вашу функцию add со строкой вместо числа, вам гораздо лучше будет использовать инструмент статической проверки типов, например TypeScript.

Заключение

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

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

Удачи!

Оцените статью
devanswers.ru
Добавить комментарий