7.12 – Введение в тестирование кода

Добавлено 30 мая 2021 в 12:46

Итак, вы написали программу, она компилируется и даже работает! Что теперь?

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

Если ваша программа полностью линейна (не имеет условных выражений, таких как операторы if или switch), не принимает входных данных и дает правильный ответ, то всё готово. В этом случае вы уже протестировали всю программу, запустив ее и проверив вывод.

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

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

Проверка программного обеспечения (также известная как тестирование программного обеспечения) – это процесс определения того, работает ли программное обеспечение должным образом во всех случаях.

Задача тестирования

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

Рассмотрим следующую простую программу:

#include <iostream>
 
void compare(int x, int y)
{
    if (x > y)
        std::cout << x << " is greater than " << y << '\n'; // case 1
    else if (x < y)
        std::cout << x << " is less than " << y << '\n'; // case 2
    else
        std::cout << x << " is equal to " << y << '\n'; // case 3
}
 
int main()
{
    std::cout << "Enter a number: ";
    int x{};
    std::cin >> x;
 
    std::cout << "Enter another number: ";
    int y{};
    std::cin >> y;
 
    compare(x, y);
 
    return 0;
}

Предполагая, что int занимает 4 байта, непосредственное тестирование этой программы со всеми возможными комбинациями входных данных потребует выполнения программы 18 446 744 073 709 551 616 (~ 18 квинтиллионов) раз. Понятно, что эта задача невыполнима!

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

Теперь ваша интуиция должна подсказывать вам, что вам на самом деле не нужно запускать показанную выше программу 18 квинтиллионов раз, чтобы убедиться, что она работает. Вы можете разумно заключить, что если случай 1 работает для одной пары значений x и y, где x > y, он должен работать для любой пары x и y, где x > y. Учитывая это, становится очевидным, что, чтобы иметь высокую степень уверенности, что программа работает правильно, нам нужно запустить ее только три раза (один раз для проверки каждого из трех случаев в функции compare()). Существуют и другие аналогичные приемы, которые мы можем использовать, чтобы значительно сократить количество раз, когда нам нужно что-то тестировать, чтобы сделать тестирование управляемым.

О методологиях тестирования можно написать много – на самом деле, мы могли бы написать об этом целую главу. Но поскольку это тема, не специфичная для C++, мы будем придерживаться краткого и неформального введения, предоставляющего необходимую информацию для вас (как разработчика), тестирующего свой собственный код. В следующих нескольких подразделах мы поговорим о некоторых практических вещах, о которых вам следует подумать при тестировании кода.

Тестируйте свои программы небольшими частями

Рассмотрим производителя автомобилей, который создает нестандартный концепт-кар. Как вы думаете, что из перечисленного он делает?

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

Вероятно, кажется очевидным, что вариант а) – лучший выбор. И всё же многие начинающие программисты пишут код, подобно варианту b)!

В случае b), если какая-либо из частей автомобиля не будет работать должным образом, механику придется провести диагностику всей машины, чтобы определить, что случилось – проблема может быть где угодно. У симптома может быть много причин – например, автомобиль не заводится из-за неисправной свечи зажигания, аккумулятора, топливного насоса или чего-то еще? Это приводит к потере времени, в попытках точно определить, где проблемы, и что с ними делать. И если проблема обнаружена, последствия могут быть катастрофическими – изменение в одной области может вызвать «волновые эффекты» (изменения) во многих других местах. Например, слишком маленький топливный насос может привести к изменению конструкции двигателя, что приведет к изменению конструкции рамы автомобиля. В худшем случае вы можете в конечном итоге переделать огромную часть автомобиля, просто чтобы учесть то, что изначально было небольшой проблемой!

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

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

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

Изолированное тестирование небольшой части кода для проверки правильности «единицы» (юнита, модуля) кода называется модульным тестированием (юнит-тестированием). Каждый модульный тест (юнит-тест) предназначен для проверки правильности определенного поведения модуля.

Лучшая практика


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

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

Итак, как мы можем протестировать наш код в модулях?

Неформальное тестирование

Один из способов тестирования кода – это неформальное тестирование при написании программы. После написания единицы кода (функции, класса или какого-либо другого дискретного «пакета» кода) вы можете написать код для проверки только что добавленного модуля, а затем стереть тест, как только он будет пройден. Например, для следующей функции isLowerVowel() вы можете написать следующий код:

#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
 
    return 0;
}

Если возвращаемые результаты буду как 1 и 0, тогда всё готово. Вы знаете, что ваша функция работает в некоторых основных случаях, и, глядя на код, вы можете разумно сделать вывод, что она будет работать для случаев, которые вы не тестировали ('e', 'i', 'o' и 'u') . Таким образом, вы можете стереть этот временный тестовый код и продолжить написание программы.

Сохранение ваших тестов

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

#include <iostream>
 
bool isLowerVowel(char c)
{
    switch (c)
    {
    case 'a':
    case 'e':
    case 'i':
    case 'o':
    case 'u':
        return true;
    default:
        return false;
    }
}
 
// Сейчас ниоткуда не вызывается
// Но находится здесь, если вы захотите повторить тест позже
void testVowel()
{
    std::cout << isLowerVowel('a'); // временный тестовый код, должен выдать 1
    std::cout << isLowerVowel('q'); // временный тестовый код, должен выдать 0
}
 
int main()
{
    return 0;
}

По мере создания дополнительных тестов вы можете просто добавлять их в функцию testVowel().

Автоматизация ваших тестовых функций

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

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

#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 testVowel()
{
    if (isLowerVowel('a') != true) return 1;
    if (isLowerVowel('q') != false) return 2;
 
    return 0;
}
 
int main()
{
    return 0;
}

Теперь вы можете вызывать testVowel() в любое время, чтобы еще раз убедиться, что ничего не сломали, и процедура тестирования сделает всю работу за вас, вернув либо сигнал «всё в порядке» (возвращаемое значение 0), либо номер теста, который не прошел, чтобы вы могли выяснить, почему он сломался. Это особенно полезно при возвращении и изменении старого кода, чтобы убедиться, что вы ничего случайно не сломали!

Фреймворки для модульного тестирования

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

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

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

Небольшой тест

Вопрос 1

Когда начинать тестирование кода?

Как только вы написали нетривиальную функцию.

Теги

C++ / CppLearnCppДля начинающихМодульное тестирование / Юнит-тестирование / Unit testingОбучениеПрограммированиеТестирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.