«Тестирование можно использовать для того, чтобы доказать наличие ошибок в программе, и никогда — для того чтобы доказать их отсутствие!»
Эдсгер Дейкстра
Тестирование — это прикладная, стандартизированная, инженерная практика, которая применима в большинстве отраслей человеческой жизни. Тестирование, как философия, метрика или практика, существует намного дольше, чем программирование. К примеру, мы выковали меч. Чтобы проверить достаточно ли острым он получился, его испытывают. В некоторые эпохи даже на живом человеке, скажем, рабе.
Тестирование — это проверка работоспособности программы, предмета или любой промышленной разработки. Как и в любом деле, здесь есть свои тонкости и своя философия. Она, наверное, ближе тестировщикам, которые на произведенные нами вещи смотрят деструктивно — они с самого начала думают о том, как сломать предложенный разработчиками продукт. Это не очень типично для пользователей, которые более предсказуемы и обычно находят ошибки, случайно пытаясь сделать с нашей программой что-то нетипичное. У разработчиков подход к программам в принципе другой, но мы должны помнить: тестировщики должны ломать то, что мы создали — это их хлеб.
Почему тестировать программы так важно?
Разработчики не мыслят через тестирование, мы мыслим созидательно — и это может быть источником проблем. Когда нас просят написать программу, мы прежде всего думаем над концептами, структурами данных, их описанием и взаимодействием. В результате мы представляем решение — готовое, пусть и переполненное багами. Обычно мы слабо представляем, что будет, если изменятся входные данные, если пользователь будет в большом количестве совершать нетипичные операции.
При этом практика показывает, самые дорогостоящие ошибки случаются в результате небольших изменений. Потому что, если вы плохо написали кусок кода, более опытный коллега попросит вас его переделать. Обладая опытом, вы сами будете замечать неудачные фрагменты практически сразу. Но когда ошибки минимальны — отдельный символ, точка, знак финансовой операции, ошибка округления — найти их очень сложно, причем на любом этапе.
Рис. 1. Форма авторизации и количество вариантов ее заполнения.
Давайте посмотрим, почему возникает такая сложность в тестировании. На рисунке мы видим простую форму из трех полей. В первых двух полях можно ввести от 1 до 255 букв, в третьем от 1 до 20 символов. Можно также оставить строки пустыми. Внизу мы видим число возможных комбинаций, заметно превышающее количество элементарных частиц во вселенной. Я думаю, это убедительное доказательство того, что проверить все возможные кейсы нереально. Да и пытаться это сделать, наверное, нецелесообразно.
Типы ошибок в проектах
Рис. 2. Схема распределения ошибок по типам, согласно данным книги Стива Макконнелла «Совершенный код».
Примерно 25% общего объема приходится на структурные ошибки. Они возникают еще на этапе проектирования, когда вы создаете структуры данных и пишете реализации манипуляций с ними, то есть создаете некий «клей», скрепляющий эти структуры. Это огромный пласт фундаментальных ошибок.
Далее идут ошибки данных, связанные с реализацией функциональности и т. д. Интересно, что архитектура находится на последнем месте. Это можно объяснить тем, что, создав неправильную архитектуру, очень сложно в принципе выпустить конечный продукт.
Рис. 3. Распределение ошибок по стадиям разработки. Схема Стива Макконнелла.
На этап конструирования и проектирования приходится основной пласт ошибок и дефектов. Здесь работает известное правило Парето: 80 % дефектов локализованы лишь в 20 % вашего кода. Как правило, это какие-то корнер-кейсы. Если вы пишете операции с математической, например, с финансовой логикой, очень много ошибок может заключаться в пределах пограничных значений, при округлении чисел и т. п. Большинство ваших кейсов будут работать, но основная часть дефектов будет локализована в небольшом участке кода.
Вне зависимости от того, применяете ли вы ручное или юнит-тестирование, нельзя утверждать, что покрыв тестами 50 % кода, вам удалось предотвратить 50 % возможных дефектов. Если вы написали 100 юнит-тестов нельзя сказать, что итоговое качество вашего продукта повысилось, например, вдвое. Потому что разработчик, который не мыслит через призму тестирования, проверяет самые легкие кейсы. Нужные формы заполняются стандартными именем, фамилией и логином — разработчик идет дальше. Он с большой долей вероятности не станет проверять случаи, когда одно из полей остается пустым, или когда в него действительно вбили имя длиной в 255 знаков. Не будет он экспериментировать с нестандартными символами, комбинациями строчных и заглавных букв.
Тестирование — самая популярная методика управления качеством. Если вспомнить наш пример с мечом, то прежде чем использовать его в реальном бою, вы точно попросите испытывать его подольше. Еще лучше если на этапе производства вы договоритесь с кузнецом, чтобы тот испытал еще и металл, из которого он будет ковать оружие. Чтобы повысить качествоа программы, нам, соответственно, желательно увеличить объем тестирования и при этом использовать best practices. Пропорционально усилиям будет возрастать и качество нашего продукта.
Тестировать продукт могут все: разработчик, тестировщик, менеджер, заказчик. Иногда в небольших проектах роль тестировщика может выполнять менеджер или разработчик. Но все же все четыре роли здесь очень важны — каждый участник процессе смотрит на объект со своей точки зрения. Разработчик — через призму того, как работает код. У него с самого начала есть большая часть информации о том, как сломать интерфейс. Для тестировщика найти ошибки и сломать продукт — прямая задача. Заказчик смотрит на тесты совершенно иначе: ему интересно, как написать меньше тестов и заплатить меньше денег, все равно получив в итоге качественный продукт. Но самая интересная роль у менеджера, который обычно может опираться на случаи из собственной практики. Впрочем, как правило, он служит мостом между разработчиками, тестировщиками и заказчиком, а его основная задача сводится к презентации продукта. Но именно менеджер отвечает за качество продукта на выходе.
Тестирование черного и белого ящиков
Классификация тестирования в виде анализа белого, серого и черного ящиков, интересна нам тем, что разработчик смотрит на собственный код как на белый ящик. А тестировщик и заказчик рассматривают программу как черный ящик — они до конца не понимают, как именно работает программа, знают только, какие фичи важны и должны работать обязательно.
Баг на сайте интернет-магазина, из-за которого какой-то товар не попадает в корзину, для разработчика — минорная проблема. Достаточно что-то закоммитить и раскоммитить, поменять переменные в одном месте — и все отлично заработает. Для тестировщика и тем более заказчика такой баг оказывается критическим, поскольку не позволяет клиенту купить нужный товар.
Но самый интересный ящик — серый. С ним работают те, кого я называю умными тестировщиамки, которые глубоко вникают в код и то, как работает продукт. Их интересует, как происходит деплой и коммуникация с базами данных.
Какие тесты пишут разработчики?
Модульные тесты. Большая часть разработчиков использует ручное тестирование. Если вы что-то сделали, прежде всего, нужно убедиться, что это действительно работает, проверить входные данные и переменные и получить какой-то фитбек. Модульные тесты — вид юнит-тестирования, то есть нам нужно проверять, правильно ли работает каждый кусочек кода.
Функциональные тесты. С их помощью мы проверяем отдельные фичи, связки наших модулей и так далее
Интеграционные тесты. Они позволяют проверить, могут ли два наших модуля работать вместе.
End-to-end тестирование. Проверка какой-либо фичи с точки зрения клиента и от начала до конца. Допустим, мы не можем отвечать за работу курьерской службы. Но можете убедиться в том, что клиент может выбрать и оплатить продукт в написанном вами интернет-магазине. И в том, что товар будет готов к отправке.
Рис. 4. Пирамида модульного тестирования.
Основа пирамиды — юнит-тестирование — желательно, чтобы юнит-тестов в проекте было много. Далее следуют интеграционное тестирование наших модулей, Acceptance Tests и непосредственное UI-тестирование конкретных фич.
F.I.R.S.T
F.I.R.S.T. — методология описания требованиий, которым должны отвечать тесты. Прежде всего, модульные, но в принципе эти характеристики можно экстраполировать и на автоматические тесты. Ее создатель — известный дядюшка Боб — автор многих практик программирования.
Быстрота (Fast). Тесты должны быть быстрыми, медленные тесты никто не станет запускать. На их проведение не хватит времени даже у команды из 30 тестировщиков.
Независимость (Independent). Тесты должны быть независимы: ни один элемент не может зависеть от исполнения предыдущего.
Повторяемость (Repeatable). Вы должны иметь возможность запустить любой тест в любой момент, и его поведение должно быть предсказуемо.
Очевидность (Self-Validating). Поведение теста должно быть очевидно, прежде всего для тех, кто создает программу.
Своевременность (Timely). Тесты должны быть написаны тогда, когда они действительно нужны.
Рис. 5. Схема TDD-процесса.
Две базовых методологии юнит-тестирования — похожие концепты TDD и BDD. BDD-подход основывается на TDD и призван устранить небольшие недостатки, которые присутствуют в TDD. Синтаксис BDD-тестов в большей степени ориентирован на бизнес и понятен заказчику и вообще технически менее подкованному человеку. TDD — о том, как делать вещи правильно. BDD — о том, как делать правильные вещи.
Как выглядит TDD-процесс?
Первым делом вы пишете тест для кода, который еще не написан. Соответственно, видите, что он не работает. Чтобы он заработал, код ещё нужно написать. Потом вы все-таки пишете код, запускаете тест и убеждаетесь в том, что тест работает. Далее вы модифицируете свой тест (уточняете требования, добавлете проверку граничных условий и т. п.). После этого ваш тест перестает работать, и вы снова приходите к необходимости модифицировать ваш код. Этот процесс зациклен до того момента, пока ваш тест не будет совершенно четко отвечать конечным критериям и соответствовать задачам, которые перед вами стоят. Предположим, если вы решаете квадратное уравнение, ваша задача — написать функцию, которая находит корни квадратного уравнения. Предположим, вы написали тест, и он хорошо работает на базе действительных чисел. Но если пользователь захочет найти комплексное решение, то ничего не получится, тест завалится и потребует модификации. Поэтому эта схема должна быть в крови у разработчиков: нужно действовать по ней, пока ваша фича не будет соответствовать всем требованиям.
Как выглядит TDD?
suite('#factorial()', function() {
test('equals 1 for sets of zero length', function() {
assert.equal(1, factorial(0));
});
test('equals 1 for sets of length one', function() {
assert.equal(1, factorial(1));
});
test('equals 2 for sets of length two', function() {
assert.equal(2, factorial(2));
});
test('equals 6 for sets of length three', function() {
assert.equal(6, factorial(3));
});
});
Пример написан на JavaScript, но думаю, в синтаксическом плане он будет примерно также выглядеть для PHP или Java. Все достаточно очевидно. Открыв тест, можно легко сказать, какие моменты протестированы не были. Можно легко и быстро что-то добавить. И таким образом проверить, корректно ли работает ли ваша функция.
BDD очень похож на TDD, но отличается тем, что формулировки в описании немного лучше адаптированы для людей, менее плотно связанных с разработкой: менеджеров или бизнес-аналитиков.
describe('#factorial()', function() {
it('should return 1 when given 0', function() {
factorial(0).should.equal(1);
});
it('should return 1 when given 1', function() {
factorial(1).should.equal(1);
});
it('should return 2 when given 2', function() {
factorial(2).should.equal(2);
});
it('should return 6 when given 3', function() {
factorial(3).should.equal(6);
});
});
Особенности модульного тестирования
Из-за своей атомарности и простоты, модульное тестирование позволяет разработчику писать много тестов, причем достаточно быстро.
Участки вашего кода (mocks, stubs, spies) приходится изолировать. Модульность предполагает тестирование отдельных небольших участков, соответственно, если эти участки связаны с другими, то их нужно тестировать отдельно.
Важный момент — вы можете написать тест только тогда, когда понимаете, как должен работать код (тестирование белого ящика). Во многих языках есть инструменты для юнит-тестирования, но в JavaScript их просто колоссальное количество. В основном они заточены на тестирование описания BDD-стиля. Хотя есть например, QUnit и другие сервисы, созданные на раннем этапе развития JS и jQuery, тяготеющие к TDD-подходу. Каждый может выбрать то, что ему больше нравится, в англоязычных комьюнити распространены Karma и Jasmine. Мне больше нравится Mocha, очень производительная штука, эффективная непосредственно на этапе разработки.
Рис. 6. Инструменты юнит-тестирования.
QUnit — библиотека от разработчиков jQuery, позволяющая писать юнит-тесты в TDD-стиле, с механизмом assert. Вы пишете qunit.test, название теста, и что вы хотите протестировать.Затем запускаете его в отдельном файле, который должен видеть ваш код, и можете убедиться в том, что код работает.
Mocha — фреймворк для тестирования, позволяющий писать тесты в TDD и BDD-формате. Как правило, он используется совместно с другими инструментами для того, чтобы полностью реализовать TDD-подход в работе. То есть он позволяет запускать и описывать тесты в нужном формате, а к примеру за обработку проверок утверждений (asserts) отвечает другая библиотека (чаще всего Chai).
Sinon— инструмент для создания Mocks, stubs и spies, который очень часто используется в современных успешных проектах. Он содержит набор функций и модулей, которые здорово помогают и решают большое количество типичных проблем, которые возникают у разработчика во время создания юнит-тестов.
Jasmine — популярный BDD-библиотека, которая фактически стала стандартом в экосистеме самого распространенного Javascript-фреймворка Angular.
Мифы
Тестирование — это долго и дорого. На самом деле, нужно находить баланс и объяснять заказчику, зачем оно нужно. Если мы не покроем тестами библиотеку, которая работает с финансовыми данными, то, конечно, выпустим наш сервис на три дня раньше. Но заказчик должен понимать и принимать риски, которые могут возникнуть в результате отказа от тестирования. Насколько важна бесперебойная работа того или иного модуля, как правило, заказчик понимает очень хорошо.
Писать тесты — это скучно и нудно. Но чистить зубы тоже не слишком захватывающе, а мы делаем это каждый день. Просто потому, что привыкли к этому ритуалу. Точно такое же положение должны занять тесты в жизни разработчика. Может, стоит немного перестроить свое мышление и смотреть на свои программы в том числе через призму тестирования.
Можно покрыть тестами 100% кода. Даже если вы не нашли ошибок, это не значит, что в итоге не всплывет каких-то багов. Но написание тестов существенно повышает шансы на то, что конечный продукт будет работать. Если вы почистили зубы, это не значит, что они никогда не выпадут. Но в пользе этой гигиенической процедуры никто не сомневается.
Ситуации в проектах
Заказчик не поддерживает идею писать юнит-тесты в проекте. В таких ситуациях стоит искать баланс и вести переговоры для того, чтобы создаваемый продукт отвечал необходимому уровню качества. Бывают и сознательные клиенты, которые сами просят обязательно писать тесты.
Второй момент — тесты пишутся только для наличия метрик, а «чистоте» тестов не уделяется должного внимания. «Когда нечем заняться — такое редко, но тоже бывает — пиши тесты», — говорит менеджер. Разработчик соглашается, но пишет тесты, не рассматривая корнер-кейсы, а просто дублирует предыдущие. Никому эти тесты не нужны, но покрытие растет, деньги капают. Только в конце есть все шансы получить провальный продукт.
Вы пишете тесты намного позже создания кода — на самом деле, это тоже плохо. Не думаю, что написать тесты на собственный код, скажем, трехлетней давности в приницпе реально. Понять, как код работал, каковы основные условия в работе отдельных модулей, совсем непросто.