Итак, вы написали программу, она компилируется, и даже работает! Что дальше?
Есть несколько вариантов. Если вы написали программу лишь бы один раз запустить и забыть, то дальше ничего делать не нужно. Здесь не столь важно, что ваша программа может некорректно работать в некоторых случаях — если при первом запуске она работает, так как вы и ожидали, и если вы дальше запускать и использовать её не планируете, тогда всё — финиш.
Если ваша программа полностью линейна (не имеет условных выражений, т.е. операторов if или switch), не принимает входных данных и выводит правильный результат, тогда всё готово также. В этом случае вы уже протестировали всю программу, запустив ее один раз и сверив результат.
Но если же вы написали программу, которую собираетесь запускать много раз, которая имеет циклы с условными выражениями и принимает пользовательский ввод, тогда здесь уже немного по-другому. Возможно, вы написали функцию, которую хотите повторно использовать в других программах. Возможно, вы даже намереваетесь распространять эту программу для других людей. В таком случае вам действительно нужно будет проверить, как ваша программа работает в самых разных условиях.
Только потому, что она корректно сработала с одними значениями пользовательского ввода – совсем не значит, что она будет работать со всеми другими значениями того же ввода.
Тестирование программного обеспечения (a.k.a. проверка программного обеспечения) — это процесс определения работоспособности программного обеспечение согласно ожиданиям.
Суть тестирования
Прежде чем будем говорить о некоторых практических способах тестирования вашего кода, давайте поговорим о том, почему комплексное тестирование может быть сложным.
Рассмотрим простую программу:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
#include <iostream> #include <string> void compare(int a, int b) { if (a > b) std::cout << a << " is greater than " << b << '\n'; // случай №1 else if (a < b) std::cout << a << " is less than " << b << '\n'; // случай №2 else std::cout << a << " is equal to " << b << '\n'; // случай №3 } int main() { std::cout << "Enter a number: "; int a; std::cin >> a; std::cout << "Enter another number: "; int b; std::cin >> b; compare(a, b); } |
Учитывая 4-байтовый тип int и его диапазон значений, то для тестирования всех возможных значений нам потребуется 18 446 744 073 709 551 616 (~ 18 квинтиллионов) раз запустить эту программу. Ясно, что это абсурд.
Каждый раз, когда мы запрашиваем пользовательский ввод или используем условное выражение в программе — мы увеличиваем в разы количество возможных способов, по которым наша программа может выполняться. Для всех программ, кроме простейших, тестировать каждую комбинацию входных данных, да и еще вручную – как то не логично, вам не кажется?
Сейчас ваша интуиция должна подсказывать вам, что для того, чтобы убедиться в полной работоспособности программы выше не нужно будет её запускать 18 квинтиллионов раз. Вы можете прийти к выводу, что если код выполняется, когда выражение x > y равно true при одной паре значений x и y, то код должен правильно работать и с любыми другими парами x и y, где x > y. Учитывая это, становится очевидным, что для тестирования программы нам потребуется запустить её всего лишь три раза (по одному для каждого кейса: x > y, x < y, x = y), чтобы убедиться, что она работает корректно. Есть и другие трюки, позволяющие упростить процесс тестирования кода.
Про методологии тестирования можно долго рассказывать — на самом деле мы могли бы даже написать об этом целую главу. Но так как эта тема не очень специфическая для C++, то мы будем придерживаться краткого и понятного введения, охватываемого с точки зрения вас как разработчика, который тестирует свой собственной код. В следующих нескольких подзаголовках мы расскажем вам о некоторых практических вещах, о которых вам следует знать.
Неофициальное тестирование кода
Большинство разработчиков проводят неофициальное тестирование, когда пишут свои программы. После написания части кода (функции, класса или какого-либо другого «куска» кода) разработчик пишет некий код для проверки только что добавленной части, и если тест пройден успешно, то разработчик удаляет код этого теста. Например, для следующей функции isLowerVowel() мы можем написать следующий код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
#include <iostream> bool isLowerVowel(char c) { switch (c) { case 'a': case 'e': case 'i': case 'o': case 'u': return true; default: return false; } } int main() { std::cout << isLowerVowel('a'); // временный тестовый код, результатом должно быть 1 std::cout << isLowerVowel('q'); // временный тестовый код, результатом должно быть 0 } |
Если вы получите 1 или 0, тогда всё хорошо. Вы знаете, что ваша функция работает, поэтому можно удалить временный тестовый код и продолжить программировать.
Совет при тестировании №1: Пишите свою программу по частям: в небольших, четко определенных единицах (функциях) и часто компилируйте их
Возьмем, к примеру, автопроизводителя, который создает автомобиль. Как вы думаете, что из следующего он делает?
a) Создает (или покупает) и проверяет каждый компонент автомобиля отдельно перед его установкой. Как только компонент успешно проходит проверку — интегрирует его в автомобиль и повторяет проверку, чтобы убедиться, что интеграция прошла успешно. В конце, перед презентацией, проводит генеральный тест работоспособности всего автомобиля.
b) Создает автомобиль из всех компонентов без какой-либо предварительной проверки — за один заход, затем в конце проводит первое и последнее тестирование уже готового автомобиля.
Не кажется вам, что более правильным является вариант a). И все же, большинство начинающих программистов пишут свой код в соответствии с вариантом b)!
В случае b), если какая-либо из частей автомобиля будет работать не правильно, то механику придется провести диагностику всего автомобиля, чтобы определить, что не так — проблема может находится где угодно. Например, автомобиль может не заводиться из-за неисправной свечи зажигания, аккумулятора, топливного насоса или чего-то еще. Это приведет к потере большого количества потраченного впустую времени в попытках точного определения корня проблемы. И если проблема будет найдена, то последствия могут быть катастрофическими — изменение в одной части автомобиля могут привести к «эффекту бабочки» — серьезным изменениям в других частях автомобиля. Например, слишком маленький топливный насос может привести к изменению двигателя, что приведет к реорганизации каркаса автомобиля. В конечном итоге вам придется переделывать огромную часть авто, просто чтобы выправить то, что изначально было небольшой проблемой!
В случае a), автопроизводитель проверяет все детали по мере поступления. Если какой-либо компонент оказался бракованным, то механики сразу понимают, в чем проблема и как её решить. Ничто не интегрируется в автомобиль, пока не будет успешно протестировано. К тому времени, когда они уже соберут весь автомобиль, у них будет разумная уверенность в его работоспособности — в конце концов, все его части были успешно протестированы. Все же возможно, что что-то произошло не так при соединении частей, но по сравнению с вариантом б) — это очень малая вероятность, о которой и не следует серьезно беспокоиться.
Вышеупомянутая аналогия справедлива и для программистов, хотя по некоторым причинам новички часто этого не осознают. Гораздо лучше писать небольшие функции, а затем сразу их компилировать и тестировать. Таким образом, если вы допустили ошибку, вы будете знать, что она находится в небольшом количестве кода, который вы только что написали/изменили. А это в свою очередь означает, что площадь поиска ошибки невелика, и время на отладку будет потрачено намного меньше.
Правило: Часто компилируйте свой код и всегда тестируйте все нетривиальные функции, которые вы пишете.
Совет при тестировании №2: Нацеливайтесь на 100% покрытие кода
Понятие покрытие кода (охват кода) относится к количеству исходного кода программы, которое было задействовано во время тестирования. Есть много разных показателей покрытия кода, но лишь несколько из них стоит упомянуть.
Покрытие стейтментов – это процент стейтментов в вашем коде, которые были задействованы во время выполнения тестирования.
Рассмотрим следующую функцию:
1 2 3 4 5 6 7 8 9 |
int boo(int a, int b) { bool z = b; if (a > b) { z = a; } return z; } |
Вызов этой функции как boo(1, 0) даст вам полный охват стейтментов для этой функции, так как выполниться каждая строчка кода.
В случае с функцией isLowerVowel():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
bool isLowerVowel(char c) { switch (c) // стейтмент 1 { case 'a': case 'e': case 'i': case 'o': case 'u': return true; // стейтмент 2 default: return false; // стейтмент 3 } } |
Здесь потребуется два вызова для проверки всех стейтментов, так как определить работу стейтментов 2 и 3 в одном вызове функции мы не сможем.
Правило: Убедитесь, что во время тестирования задействованы все стейтменты в вашей функции.
Совет при тестировании № 3: Нацеливайтесь на 100% покрытие ветвлений кода
Покрытие ветвлений кода относится к проценту ветвлений, которые были выполнены в каждом случае (положительном и отрицательном) отдельно. Оператор if имеет два ветвления – случаи true и false (даже если нет оператора else). Оператор switch может иметь много ветвлений.
1 2 3 4 5 6 7 8 9 |
int boo(int a, int b) { bool z = b; if (a > b) { z = a; } return z; } |
Предыдущий вызов boo(1, 0) дал нам 100%-ый охват стейтментов и ветвление true. Но это всего лишь 50%-ный охват ветвлений. Нам нужен еще один вызов boo(0, 1), чтобы протестировать вариант false.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
bool isLowerVowel(char c) { switch (c) { case 'a': case 'e': case 'i': case 'o': case 'u': return true; default: return false; } } |
В функции isLowerVowel() нужны два вызова (например, isLowerVowel(‘a’) и isLowerVowel(‘q’)), чтобы убедиться в 100%-ном охвате ветвлений (все буквы, которые находятся в switch-е тестировать не обязательно, если сработала одна – сработают и остальные).
Пересмотрим функцию сравнения в примере выше:
1 2 3 4 5 6 7 8 9 |
void compare(int a, int b) { if (a > b) std::cout << a << " is greater than " << b << '\n'; // случай №1 else if (a < b) std::cout << a << " is less than " << b << '\n'; // случай №2 else std::cout << a << " is equal to " << b << '\n'; // случай №3 } |
Здесь необходимы 3 вызова, чтобы получить 100%-ный охват ветвлений: compare(1,0) проверяет вариант true для первого оператора if. compare(0, 1) проверяет вариант false для первого оператора if и вариант true для второго оператора if (else if). compare(0, 0) проверяет вариант false для второго оператора if и выполняет инструкцию else. Таким образом, мы можем сказать, что эту функцию можно протестировать с помощью всего лишь 3 вызовов (а не 18 квинтиллионов).
Правило: Тестируйте каждый случай ветвления в вашей программе.
Совет при тестировании №4: Нацеливайтесь на 100%-ное покрытие циклов
Покрытие циклов (неофициально называемый «тест 0, 1, 2») сообщает, что если у вас есть цикл в коде, то, чтобы убедиться в его работоспособности, нужно выполнить его итерацию 0, 1 и 2 раза. Если он работает правильно в случае 2 итерации, то должен работать правильно и для всех итераций > 2 (3, 4, 10, 100 и т.д.).
Например:
1 2 3 4 5 6 |
#include <iostream> int spam(int timesToPrint) { for (int count=0; count < timesToPrint; ++count) std::cout << "Spam!!!"; } |
Чтобы протестировать цикл внутри функции, нам придется вызвать его три раза: spam(0), чтобы проверить случай нулевой итерации, spam(1) для проверки итерации №1 и spam(2) для проверки итерации №2. Если spam(2) работает, тогда и spam(n) будет работать (где n > 2).
Правило: Используйте тест 0, 1, 2 для проверки циклов на корректную работу с разным количеством итераций.
Совет при тестировании № 5: Убедитесь, что вы тестируете разные категории ввода
Когда вы пишете функции, которые принимают параметры или пользовательский ввод, заметьте, что происходит с разными категориями ввода. В этом контексте термин «категория» используется для обозначения набора вводов, имеющих аналогичные характеристики.
Например, если я написал функцию вычисления квадратного корня целого числа, то какие значения имело бы смысл протестировать? Вероятнее всего, вы бы начали с нормальных значений, например с 4. Но также было бы неплохо протестировать и с 0 и с отрицательным числом.
Вот несколько основных рекомендаций по тестированию категорий значений ввода:
для целых чисел убедитесь, что вы рассмотрели варианты, как ваша функция обрабатывает ноль, отрицательные и положительные значения. При наличии пользовательского ввода вы также должны проверить вариант возникновения переполнения;
для чисел типа с плавающей запятой убедитесь, что вы рассмотрели варианты, как ваша функция обрабатывает значения, которые имеют неточности (значения, которые немного больше или меньше ожидаемых). Хорошие тестовые значения – это 0.1 и -0.1 (для проверки чисел, которые немного больше ожидаемых) и 0.6 и -0.6 (для проверки чисел, которые немного меньше ожидаемых);
для строк убедитесь, что вы рассмотрели вариант, как ваша функция обрабатывает пустую строку, строку с допустимыми значениями, с пробелами и строку, содержимое которой одни пробелы.
Правило: Тестируйте различные категории значений ввода, чтобы убедиться, что ваш «кусок» кода правильно их обрабатывает.
Сохранение ваших тестов
Хотя написание тестов и последующее их удаление — достаточно хороший вариант для быстрого и временного тестирования, но для кода, который вы намереваетесь повторно использовать или модифицировать в будущем, может иметь смысл сохранить эти тесты. Например, вместо удаления вашего временного теста, вы можете переместить его в функцию test():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
#include <iostream> bool isLowerVowel(char c) { switch (c) { case 'a': case 'e': case 'i': case 'o': case 'u': return true; default: return false; } } // сейчас нигде не вызывается // но находится здесь в случае, если вы захотите повторно провести тестирование функции void test() { std::cout << isLowerVowel('a'); // временный тестовый код, результатом должно быть 1 std::cout << isLowerVowel('q'); // временный тестовый код, результатом должно быть 0 } int main() { return 0; } |
Автоматизация тестирования
Одна из проблем с вышеупомянутой тестовой функцией заключается в том, что вам придется вручную проверять результаты теста. А можно сделать лучше – добавить к тесту правильные ожидаемые результаты, которые должны получиться при успешном тестировании.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
#include <iostream> bool isLowerVowel(char c) { switch (c) { case 'a': case 'e': case 'i': case 'o': case 'u': return true; default: return false; } } // возвращается номер теста, который не был пройден или 0, если все тесты были пройдены успешно int test() { if (isLowerVowel('a') != true) return 1; if (isLowerVowel('q') != false) return 2; return 0; } int main() { return 0; } |
Теперь вы можете вызывать test() в любое время и функция сама всё сделает за вас.
Тест
1. Когда вы должны начинать тестировать свой код?
Ответ 1
Сразу, как только написали нетривиальную функцию.
2. Сколько тестов потребуется для следующей функции для минимального подтверждения её работоспособности?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
bool isLowerVowel(char c, bool yIsVowel) { switch (c) { case 'a': case 'e': case 'i': case 'o': case 'u': return true; case 'y': return (yIsVowel ? true : false); default: return false; } } |
Ответ 2
4 будет достаточно. Один для проверки случаев a/e/i/o/u. Один для проверки случая по умолчанию. Один для тестирования isLowerVowel(‘y’, true). И один для тестирования isLowerVowel(‘y’, false).
В ответе на тест 2 объясните пожайлуста что такое случай по умолчанию?
default:
"Совет при тестировании №4…"
Функция spam(int timesToPrint) должна быть типа void.
Вопрос: почему в 4 байтах максимальное число 18 квинтилионов?
Если в 1 байте максимальное число FF, а в 4 — FF'FF'FF'FF, что равно 4 294 967 296
Потому что каждое число из диапазона сравнивается со всеми значениями диапазона (1 с 1, 1 с 2, 1 с 3, 1 с 123 и т.д., затем 2 с 1, 2 с 2 и т.д.). И получается что диапазон нужно поделить на диапазон (4 294 967 295 * 4 294 967 295), дальше уже математика.
понял, спасибо