Разделы портала

Онлайн-тренинги

.
Плохие тесты: кто виноват и что делать?
13.07.2023 00:00

Автор: OSS contributor

Тестирование — один из самых больных, если не самый больной вопрос в современной разработке программного обеспечения. Поговаривают, что разработчики не любят писать тесты, что написать правильные тесты зачастую сложнее, чем сам код, что «зеленое — не значит работает», а типовые экстремисты даже утверждают, что АДТ с лихвой эти самые тесты заменяют.

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

Что тесты тестируют?

Принято различать юнит-тесты, интеграционные тесты, acceptance testing (занимательный факт: статья про них в википедии на русский язык даже не переведена). Юнит-тест хорошо тестирует поведение чистой, без особенностей поведения в пограничных случаях, функции — с немногочисленными, простыми для понимания и желательно ограниченными аргументами. Например, функция умножения двух положительных целых чисел может быть очень хорошо протестирована с использованием только лишь юнит-тестов. Уже с делением — все заметно хуже, но там благоразумный программист может не забыть проверить пограничный случай нуля в знаменателе. Если взять что-то хоть немного посложнее, какую-нибудь простейшую в своем роде кубическую сплайн-интерполяцию, то юнит-тесты помогут выявить только совсем уж очевидные проблемы.

Совершенно бесполезная метрика «покрытие» (нет, разумеется, она имеет свою область применения: от 0% до примерно 60%; дальше — никакого соответствия между качеством тестирования кода и покрытием просто нет) — была изобретена людьми с тем же менталитетом, которые измеряют качество и сложность программы — количеством строк кода. В сети можно найти миллиард примеров того, как стопроцентное покрытие не помогает найти даже самые тривиальные проблемы.

Интеграционные тесты, в принципе, неплохи, но ужасно громоздки и капризны. Чтобы провести правильное интеграционное тестирование, нужно развернуть такое количество стендов, что зачастую бывает просто дешевле возместить клиентам понесенные из-за багов убытки (шучу, конечно, но на практике это не так уж и далеко от реальности). При этом, в интеграционном тесте в тепличных условиях (проведение одного заказа через чистую базу при нулевой нагрузке) — примерно столько же смысла, сколько в стопроцентном покрытии юнит-тестами кода калькулятора. Нет, он, конечно, выявит опечатку в запросе к API (которую, кстати, пропустит юнит, потому что эндпоинт для мока никто не станет набирать руками, а просто скопирует из вызова). Но действительно трудоемкие для починки проблемы — интеграционный тест выявит только в сходных с боевыми условиями (нагрузка, наполненность базы, network latency, наконец).

Acceptance тесты — вообще не понятно, для чего нужны. Для ситуации, когда продуктовый отдел и разработка разговаривают через слепоглухонемого сурдопереводчика, наверное. Но мы их делаем, конечно, ведь во-первых, в 2023 году их все делают, а во-вторых, ну вот же, прикольно: попросили систему оформить заказ, и она его оформила!

Есть еще тестирование свойств, известное как property-based testing, или QuickCheck, которое в сто раз лучше всего вышеперечисленного. Но оно, почему-то, страдает от нехватки популярности (видимо, плохая аура языка оригинальной версии: впервые фреймворк для тестирования свойств был написан на хаскеле).

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

Что же с нами не так?

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

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

Пожалуй, самая популярная библиотека руби вне рельсовых примочек — sidekiq Майка Перама. Сама библиотека совершенно заслуженно занимает свое почетное место: она принесла в мир руби внятную асинхронность, она прекрасно отлажена, волшебно масштабируется и вообще на сам код — очень приятно смотреть. Но знаете, что? — интеграцию с тестами (rspec-sidekiq) внезапно сделало сообщество. Сам Майк об этом не подумал, или поленился.

«Ну, это нормально, ведь сообщество для того и нужно», — слышу я напрашивающееся возражение. Так, да не так. Давайте взглянем, что официальная страничка помощи библиотеки, которой больше десяти лет (судя по дате первого коммита), предлагает для облегчения жизни грешников, решивших ей воспользоваться:

Matchers:
be_delayed
be_processed_in
be_retryable
be_unique
have_enqueued_sidekiq_job

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

Вот пример из документации:

describe AwesomeJob do
  it { is_expected.to be_processed_in :my_queue }
  it { is_expected.to be_retryable 5 }
  it { is_expected.to be_unique }
  it { is_expected.to be_expired_in 1.hour }

  it 'enqueues another awesome job' do
    subject.perform

    expect(AnotherAwesomeJob).to have_enqueued_sidekiq_job('Awesome', true)
  end
end

Что бы мне хотелось увидеть среди экспортируемых матчеров библиотеки, помогающей с тестированием высоконагруженного обработчика задач? Ну что-то вот такое:

describe OrderJob do
  before do
    let(:order) { … }
    let(:client) { order.client }
    let(:action) { job_launched_by { order.process(…) } }
  end

  it { action.to be_succeeded }
  it { action.to validate(client.amount).be_gte order.amount }
  it { action.to change { order.state }.to { :processed } }
  it { action.to change { client.amount }.by { order.amount } }
  it { action.to spawn(EmailJob) }
end

На первый взгляд, разница может быть не так заметна, но она существенна: объектом тестирования должен выступать наш бизнес-объект, а не служанка из чужой библиотеки. Тогда такой тест можно будет и продакту показать, и вообще, написать с удовольствием: ведь он действительно тестирует то, что мы ждем от нашего кода, а не то, что (как мы подразумеваем) должно работать и так в сторонней библиотеке.

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

Еще замечу, что при почти ультимативной выразительности руби, синтаксис rspec навевает грусть, печаль и тоску своими бесконечными потугами походить на gherkinish. Не лучше ли было предоставить свой DSL, который сам как-нибудь под капотом запустит тесты?

describe OrderJob do
  before do
    let(:order) { … }
    let(:client) { order.client }
    let(:action) { job_launched_by { order.process(…) } }
  end

  expect do
    state action, :success
    client do |initial, final|
      initial.amount <= order.amount
      initial.amount - final.amount == order.amount
    end
    order do |_initial, final|
      order.final == :processed
    end
  end

  side_effect do
    spawned EmailJob
  end
end

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

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

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

А что там с моками?

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

Нельзя просто замо́чить (мо́кнуть? замокну́ть?) какой-то метод объекта и думать, что все остальное будет работать в точности так, как прежде. Точнее, можно: если этот метод абсолютно чист. Самый завалящий сайд-эффектишко — превратит любой мок метода — в чудовище, которое сожрет ваш код в продакшене. Более того, мок пары методов — непрактичен, потому что в результате получается тестирование оборотня: вот тут и тут у нас мок, а там — кишки самого объекта.

Правильный подход — создавать моки на объекты целиком. Во-первых, сразу станет видно, где нужны инъекции зависимостей. Если у нас есть объект «заказ» и на нем есть метод «отправить письмо», который раньше (матеря́ того идиота, который такой код написал) можно было просто мокнуть — с подходом «мок — это двойник объекта» такой вариант не взлетит. Придется выделить отправителя писем в свою сущность, и вот его уже мокнуть.

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

Так что же делать?

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

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

А если вы, как и я, просто проходили мимо — порадуйтесь, что тесты могут стать желанными гостями в процессе написания нового кода. «М-м-м-м, завтра и послезавтра мне нужно будет обложить вон тот код тестами, здорово-то как!».

Обсудить в форуме