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


Но с чего же начинать писать тесты для новых проектов? Лично для меня, как для программиста, самый интуитивный ответ — это Unit-тесты. Однако опрометчиво накидываться на сочинение Unit-тестов может не только оказаться бесполезым занятием, но даже нанести вред в будущей разработке проекта.


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


А можно всех посмотреть?


Я думаю, многие из вас видели ту или иную версию вот этой картинки:



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


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


Unit-тесты


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



Несколько особенностей Unit-тестов:


  1. Подразумевают принцип "белого ящика" — то есть эти тесты требуют наличия и понимания исходных кодов программы.
  2. Дёшево стоят, можно наклепать тысячи, если не десятки тысяч Unit-тестов.
  3. Быстро прогоняются.
  4. По своей природе — автоматизированы.
  5. Позволяют очень точно локализовывать ошибки — можно узнать конкретную функцию/класс, которая работает неправильно.
  6. Плохо подходят для комплексных проверок.

Интеграционные тесты


На следующем уровне пирамиды обычно указываются интеграционные тесты. Интеграционные тесты направлены на тестирование больших обособленных блоков программы и их взаимодействие между собой (опционально). Под блоком может пониматься отдельная библиотека с частью функционала программы, или же просто большой обособленный участок кода, который слишком велик для покрытия обычными Unit-тестами. Если же программа не бьётся на обособленные блоки и имеет монолитную архитектуру, то интеграционные тесты могут прогоняться для всей программы целиком, но при этом программа помещается в "вакуум" — никак не общается и не взаимодействует с "внешним миром".



Несколько особенностей интеграционных тестов:


  1. Всё ещё требуют принцип "белого ящика" — тестирование блоков подразумевает понимание интерфейса этих блоков, как минимум. Внутреннее устройство модулей в интеграционном тестировании уже не участвует.
  2. Несколько сложнее Unit-тестов, т.к. зачастую блоки требуют много подготовительной работы, прежде чем они смогут "нормально" функционировать.
  3. Чуть сложнее поддаются автоматизации — всё зависит от конкретного блока и его устройства/назначения.

API-тесты


Также в пирамиде иногда выделяют API-тесты. Иногда их относят к интеграционным, иногда — как отдельный уровень, но сути это не меняет. Если какая-то часть программы имеет чётко выраженный и оформленный API, то можно выстроить тесты, "дергая" этот API и сверяя фактический результат с ожидаемым. Часто такое можно встретить в распределённых приложениях.


Сейчас такой вид тестов получил просто огромное распространение за счёт очень большой популярности REST API. Это очень удобный способ связывать клиент-серверные приложения между собой, и поэтому, конечно, создаётся очень заманчивое желание абстрагироваться от клиента и протестировать только серверную составляющую, дергая за "ниточки" API.


Несколько свойств этого вида тестирования:


  1. Может рассматриваться как подвид интеграционного тестирования.
  2. Требует чётко оформленного и (желательно) документированного API.
  3. Чаще всего нормально поддаётся автоматизации. Если речь идёт о популярном виде API (например, REST) — то к вашим услугам большое количество готовых открытых и коммерческих инструментов, которые позволяют автоматизировать такие тесты на раз-два. Если же API нетипичное, то может потребоваться разработать собственную утилиту по вызову этого API и проверке результатов. В любом случае, стоимость автоматизации выше, чем у Unit-тестов.
  4. Позволяет протестировать очень большие самостоятельные компоненты программы и иногда — всю программу в целом.

Тем не менее, нужно заметить, что этот вид тестирования возможен только при наличии у программы (или у её компонентов) чётко сформированного API. Что бывает далеко не всегда (например, по соображениям безопасности, или просто наличие API не предусмотрено архитектурой приложения).


Системные тесты


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



Системные тесты направлены на тестирование всей программы в целом, с учётом окружения, в которой программе предстоит работать. Это уточнение очень важное, ведь программа редко когда может работать "сама по себе". Программа работает на одном или нескольких (в случае распределенных приложений) конкретных компьютерах. И на разных компьютерах (в разных условиях) тестируемая программа может повести себя по-разному. Например, клиент-серверное приложение может работать замечательно в обычной сети, но при этом может оказаться неработоспособным при наличии между клиентом и сервером маршрутизатора с настроенным NAT.


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


Несколько заметок о системных тестах:


  1. Предполагают тестирование по методу "черного ящика". Никаких знаний об исходных кодах или особенностях работы программы — только UI-тесты.
  2. Наиболее комплексный вид тестирования — даже распределенные приложения тестируются вместе, а не по отдельности.
  3. Даёт наибольшую степень уверенности, что протестированные фичи действительно будут работать у конечных пользователей.
  4. Самый дорогой вид тестов.
  5. Долго или очень долго прогоняются.

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


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


Не спешите с Unit-тестами


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


Представим ситуацию, что вы разрабатываете совершенно новое приложение. Неважно, как именно вы его разрабатываете — на основе четко сформированных требований или же "по наитию". В какой-то момент времени вам (надеюсь) захочется написать тесты. Это может случиться как только у вас получится что-то работающее, или же тесты окажутся вообще вашим самым первым шагом — если вы придерживаетесь концепции TDD (Test Driven Development). Неважно когда, но этот момент настанет. И о каких же тестах вы подумаете в первую очередь?


Лично я, как разработчик, всегда думаю в первую очередь о Unit-тестах! А почему бы и нет? Ведь для меня программа — это, в первую очередь, исходный код. Если я сам пишу код и знаю, как он работает/должен работать, то для меня самый логичный первый шаг — покрыть его тестами. Больше того, я уже знаю, как это делается, я делал это тысячу раз, это зона моего комфорта. Поэтому я с чувством выполнения великой миссии (тесты это же хорошо!) начинаю стандартную возню с Unit-тестами: скачиваю свой любимый фреймворк, продумываю абстрактные классы вместе с интерфейсами, занимаюсь mock-ированием объектов… И радуюсь, какой я молодец.


И тут я загоняю себя в две потенциально-опасные ситуации. Посмотрим на первую проблему.


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


Обидно? Ну что ж, с кем не бывает, ничего страшного. Это ведь не повод отказываться от Unit-тестов, верно? Сделаем заметку и рассмотрим вторую опасную ситуацию.


Отловив все очевидные участки кода, которые можно покрыть Unit-тестами, вы вдруг понимаете, что вы покрыли всего 10% кода (ну нет у вас сейчас четких обособленных модулей с внятными интерфейсами на такой стадии проекта). Тогда вы начинаете беспощадно рефакторить свой код — выделяете абстрактные классы, выстраиваете зону ответственности между модулями, инкапсулируете, инкапсулируете, инкапсулируете… занимаетесь, в общем-то, полезными делами, честно говоря. Ну и каждый свой успех отмечаете очередной порцией Unit-тестов, ведь ради них, родимых, всё и затевается!


Спустя пару недель работы вы получаете 60% покрытого тестами кода. У вас появилась сложная иерархия классов, mock-объекты, а общее количество Unit-тестов перевалило за 100500. Всё хорошо, так ведь?


Всё хорошо ровно до тех пор, пока вы не увидите, что вашу архитектуру можно было бы улучшить (а с развитием проектов, особенно новых, такие наблюдения случаются регулярно). И вместо того, чтобы смело ринуться в дебри рефакторинга, вы начинаете грустно думать о 100500 Unit-тестах и о том, как теперь вам придётся их переделывать. Вы становитесь заложником своих собственных Unit-тестов, которые начинают сковывать вашу гибкость в принятии архитектурных решений. И выбор у вас получается… так себе. Либо решиться на рефакторинг и спустить в унитаз всё время, потраченное на Unit-тесты, либо (ещё хуже) — оставить всё как есть, и затем бороться с не самой удачной (как вы теперь понимаете) архитектурой.


Звучит так, как будто я просто персонально терпеть не могу Unit-тесты и пытаюсь отговорить вас от их использования, так?


Вовсе нет. Просто для любого нового проекта это абсолютно нормальная ситуация — в какой-то момент понять, что изначальная архитектура или затея вышла не слишком удачной — и что нужно всё (или практически всё) переделывать. Вам всё равно с высокой долей вероятности придётся через это пройти. И наличие Unit-тестов вас не только не спасёт — оно даже сыграет с вами злую шутку: вместо того, чтобы взвешенно и объективно принять решение о реструктуризации кода, вы будете только грустно вспоминать о потраченных часах и даже днях на составление сотен, тысяч юнит-тестов. Мне было бы жалко времени, потраченного на Unit-тесты, которые теперь надо выкинуть в помойку и начинать писать заново.


Лучше спешите с системными тестами!


Так что же я предлагаю? А предлагаю я взглянуть на старый рисунок по-новому:



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


Как я уже упоминал выше, системные тесты — это тесты программы в целом. При этом программа представляется именно в том виде, в котором её увидит пользователь. Фактически, системные тесты заставляют нас взглянуть на программу как на законченный продукт (хоть он таким сейчас и не является). Если другими словами, то вместо подхода от "от частного к общему" мы начинаем писать тесты "от общего к частному". А это позволяет нам убить сразу двух зайцев:


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

Звучит неплохо, не правда ли? Причём, системные тесты обладают ещё рядом очень приятных бонусов:


  1. В условиях зафиксированных требований к программе системные тесты не могут сильно меняться. Это возможно благодаря самой природе системных тестов: они рассматривают программу как "черный ящик" — без какой-либо привязки к архитектуре и особенностям реализации. А это значит, что вы можете свободно менять архитектуру своей программы по первому требованию, и никакие тесты менять не придется! Ну, разве что, немного подкорректировать — если у вас добавилась новая зависимость или что-то в таком духе.
  2. Системные тесты могут писать аналитики или даже тестировщики — не отнимая, таким образом, ценное время программистов. Также это позволяет аналитикам дать программистам более четкое понимание, что именно они хотят увидеть в программе. Программистам лишь останется добиться того, чтобы тесты проходили, не ломая голову над вопросом "чего же от меня хотят".
  3. Системные тесты — это очень комплексный вид тестов. Если ваша программа проходит сложный комплексный тест — то вы уже с довольно высокой долей вероятности можете быть уверены, что "в целом, наверное, все компоненты нормально работают". В Unit-тестах наоборот — уверенность в одном классе не дает абсолютно никакой уверенности в работоспособности программы в целом.

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


Впрочем, сейчас на этом фронте не всё так уж и плохо — существует очень много коммерческих решений, которые позволяют в том или ином виде решить эту проблему: Testcomplete, Squish, Ranorex, Eggplant — это лишь самые известные примеры таких систем. У всех у них есть свои достоинства и недостатки, но в целом со своей задачей по автоматизации системных тестов они справляются (хоть и стоят очень немалых денег).


А вот среди бесплатных решений выбора особо нет. По крайней мере не было.


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


А что же другие тесты?


Заметьте, что я сосредоточился на том, что стоит начинать с системных тестов, потому что это даёт определенные неплохие бонусы. Но это отнюдь не означает, что стоит пренебрегать другими видами тестов.


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


В развитием проекта вы обнаружите, что вас уже не устраивает только проверка "в целом всё работает". Со временем архитектура вашего нового приложения неизбежно "устаканится", крупных изменений будет всё меньше и меньше. В какой-то момент вы поймёте, что вы хотите навесить побольше проверок на какой-нибудь компонент (потому что он уже точно никуда из проекта не денется, и его интерфейс точно проработан), или даже на конкретный класс… А для особо важных участков кода и вовсе желательно проработать все возможные ветвления. Или же вам очень интересно, как поведёт себя программа при непосредственном обращении к API. Чувствуете, да? Вот и другие тесты снова в деле!


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


  1. В самом начале, когда проект активного развивается с нулевого состояния, необходимо писать только системные тесты. Тестов нужно написать столько, чтобы у вас была четкая уверенность, что "в целом моя программа работает нормально". Это будет отличный базис для дальнейшей работы.
  2. По мере развития проекта и "устаканивания" его архитектуры и основных компонентов можно добавлять интеграционные тесты. Если у проекта появилось чётко выраженное и стабильное API — нужно начинать писать API-тесты.
  3. Наконец, для особо важных изолированных участков кода, от правильной работы которых зависит очень многое, можно написать Unit-тесты.
  4. Помнить, что из любого правила есть исключения. При возникновении достаточно веских объективных причин — повышайте приоритет интеграционных и Unit-тестов. Не нужно откладывать разработку тестов более низкого уровня, если вы в них действительно нуждаетесь здесь и сейчас.

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


Итоги


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


Но по мере развития инструментов для автоматизации системных тестов перспектива сходу автоматизировать end-to-end тесты уже не кажется такой уж пугающей. Возможно, вам понравится разрабатывать тесты "сверху вниз" — ведь так их можно разрабатывать ровно по мере их надобности.