Что я узнал о модульном тестировании, работая в Volvo Group

После нескольких лет работы над крупномасштабной веб-разработкой с использованием React в Volvo Group я многое понял о модульном тестировании. Я понял, что многие тесты бесполезны и отнимают много времени, а другие очень важны. В этой статье я расскажу вам о том, чему я научился.

Недавно я уволился из Volvo Group Connected Solutions AB (VGCS). Это было отличное место работы, они разрабатывают системы наблюдения для парков грузовиков и автобусов. Компания настолько масштабная, насколько это вообще возможно. Они проводят множество различных видов тестов на разных уровнях, а также имеют множество различных ролей для нанятых тестировщиков. Несмотря на все тесты, горстка ошибок иногда проникает в продукт. Продукт не может быть протестирован достаточно. Однако вы можете потратить слишком много времени на тестирование, и в этой статье мы расскажем, почему это происходит и как этого избежать.

У всех команд на VGCS есть свои правила. В команде, в которой я работал, мы стремились к 100-процентному покрытию юнит-тестами. Только в нашей команде было несколько тысяч юнит-тестов для кода, которым мы управляли. Другие команды были больше увлечены интеграционными тестами и тратили меньше времени на юнит-тесты. Сегодня я выскажу свои соображения по поводу модульного тестирования.

Юнит-тесты требуют времени, стоит ли оно того?

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

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


Не лучший способ поймать ошибки (источник изображения: GIPHY)

Большинство модульных тестов бесполезны

Если вы стремитесь к 100 %-ному покрытию, есть шанс, что большинство из них бесполезны. Среди всего кода, который я обновлял, я очень редко проваливал модульные тесты из-за ошибок, которые я внес в код. Это не значит, что я не вносил ошибок, конечно, вносил.

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

import React from 'react'
import { shallow } from 'enzyme'
import TodoList, { Todo } from '../TodoList'

it('should pass title to Todo component', () => {
  const todos = [
    { id: 1, title: 't1' },
    { id: 2, title: 't2' },
  ]
  const wrapper = shallow(<TodoList todos={todos} />)
  const firstTodo = wrapper.find(Todo).at(0)
  expect(firstTodo.prop('title')).toEqual('t1')
})
Вход в полноэкранный режим Выход из полноэкранного режима

Ферментный тест для списка дел. Код доступен на CodeSandbox или GitHub.

Выше представлен типичный Jest-тест для React, написанный с помощью Enzyme. Он выполняет рендеринг компонента TodoList и гарантирует, что первому компоненту Todo будет передан правильный заголовок.

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

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

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

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


Определенно, это цитата Эйнштейна

Стоит ли пропускать модульные тесты?

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

Когда модульные тесты полезны?

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

Математические функции, функции utils, хуки и различные чистые функции, такие как редукторы, — все это идеальные примеры того, когда вам следует писать модульные тесты. Иногда они содержат сложную логику, которую обязательно нужно тестировать. Другие функции могут иметь множество граничных случаев для тестирования. Возможно, одной из самых распространенных причин ошибок в Javascript является работа с мутациями. С помощью модульных тестов это очень быстро и легко проверить.

Подобные функции следует тестировать, даже если вы планируете написать их только один раз и затем никогда не обновлять. В код, перегруженный логикой, до смешного легко внести ошибки, и вы не всегда можете протестировать его графически, чтобы убедиться, что он работает. Я бы настоятельно рекомендовал использовать Test Driven Development, TDD, при написании такого кода. TDD заставляет вас заранее продумывать крайние случаи, что часто может сэкономить вам время уже при написании кода. Без этого вы можете переписывать код несколько раз только потому, что при каждом новом решении вы будете находить новые крайние случаи.

Как писать хорошие модульные тесты

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

Я не буду вдаваться в подробности. Вместо этого я хочу снова вернуться к тестированию компонентов пользовательского интерфейса — именно этот вид модульного тестирования, как я утверждал, во многих случаях бесполезен. В деталях мы обсудим концепции неглубокого и монтажного тестирования с помощью Enzyme, а также интерактивное модульное тестирование с помощью Testing Library. Библиотека тестирования может быть использована со многими библиотеками, включая React.

Юнит-тестирование с помощью Enzyme

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

Enzyme против библиотеки тестирования React

Что касается различий между Enzyme и библиотекой тестирования React, то в трендах npm можно увидеть, что в настоящее время библиотека тестирования используется чаще. Тем временем Enzyme медленно умирает, поскольку его не поддерживают и у него нет неофициальной поддержки для React 17.


Тенденции npm за все время — Enzyme vs React Testing Library

Неглубокие тесты

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

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

Монтажные тесты

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

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

Интерактивные тесты

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

Интерактивные тесты в React Testing Library будут вести себя более похоже на тесты Enzyme’s mount, чем на неглубокие тесты, поскольку они будут рендерить и дочерние компоненты. Конечно, вы вольны высмеивать любой компонент, который хотите высмеять, поэтому вполне возможно тестировать все компоненты неглубоко, если вы предпочитаете именно это, просто высмеивайте все дочерние компоненты.

Еще не убедились? Давайте продолжим, я уже подхожу к этому. Огромное преимущество интерактивного модульного тестирования, которое мне нравится, заключается в том, что вы часто сможете сохранить свои модульные тесты нетронутыми даже при рефакторинге компонентов или даже нескольких компонентов. Точно так же, как если бы вы тестировали свой код с помощью инструмента интеграционного тестирования, такого как Cypress или Selenium.

Давайте снова рассмотрим пример с Todo. На этот раз с использованием библиотеки тестирования React.

import React from "react"
import { render } from "@testing-library/react"
import TodoList from "../TodoList"

test("it should pass title to Todo component", () => {
  const todos = [
    { id: 1, title: "t1" },
    { id: 2, title: "t2" }
  ]
  const { getAllByRole } = render(<TodoList todos={todos} />)
  const todoItems = getAllByRole("listitem")
  expect(todoItems[0]).toHaveTextContent("t1")
})
Вход в полноэкранный режим Выход из полноэкранного режима

Тест React Testing Library для списка Todo. Код доступен на CodeSandbox или GitHub.

С помощью приведенного выше кода мы можем обновлять компонент TodoList и компонент Todo любым способом без необходимости обновлять тест, пока мы продолжаем использовать элементы списка для элементов todo. Если вы считаете, что зависимость от элементов списка раздражает, мы можем убрать и эту зависимость. Библиотека тестирования позволяет просматривать data-test-id:s или чистые тексты. О поддерживаемых запросах читайте здесь. Вот несколько примеров того, что вы можете сделать.

// Checking presence of text using a regex.
getByText(/t1/i)
// Checking for data-test-id with the text.
expect(getByTestId('todo-item-1')).toHaveTextContent('t1')
// Checking for a button with the text "Press me".
expect(getByRole('button')).toHaveTextContent('Press me')
Войти в полноэкранный режим Выход из полноэкранного режима

Код доступен на CodeSandbox или GitHub.

Заключение

Юнит-тесты и интеграционные тесты необходимы. Поддерживать 100-процентное покрытие юнит-тестов — это неплохо. Но если вы не будете тестировать код эффективным образом, это будет стоить вам огромного количества времени. Будьте умны при разработке модульных тестов и выбирайте для этого правильные инструменты.

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

Когда речь идет о тестировании UI и компонентов React, вам следует дважды подумать о том, как писать тесты. Использование библиотеки React Testing Library вместо Enzyme — отличное начало. Не только потому, что Enzyme плохо поддерживается, но скорее потому, что библиотека тестирования подходит к модульному тестированию более эффективно. Библиотека тестирования фокусируется на тестировании элементов DOM и элементов, видимых пользователю. Такой вид интерактивного модульного тестирования можно написать и с помощью Enzyme, но Enzyme написан не для этого.

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

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

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