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

Введение

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

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

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

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

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

Заблуждения при начале работы с проектным тестированием

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

На этой странице содержаться рекомендации по преодолению первых трудностей создания проектных тестов и по созданию тестового комплекта и его успешной поддержки. Дополнительная информация приведена в документе Рекомендации: Поддержка автоматизированных комплектов тестов

Создайте ожидания

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

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

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

Автоматизируйте тесты

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

  /* Убедитесь, что элемент может быть добавлен по крайней мере один раз. */
 // Установка
 Database db = new Database();
 db.add("key1", "value1");
 // Тест
 boolean result = db.add("key1", "another value");
 expect(result == false);

Тесты отличаются только одним от обычного кода клиента - вместо того, чтобы верить результатам, выдаваемым API, они проверяют этот результат. Если API позволяет легко писать клиентский код, значит тестовый код также легко писать. Если писать тестовый код не легко, то вы получаете раннее предупреждение, что API надо улучшить. Итак, проектирование с ранним тестированием отражает подход RUP к работе с рисками на ранних стадиях.

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

Графические пользовательские интерфейсы

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

Нажатие кнопки вызывает выполнение определенного кода в компоненте. Очень вероятно, что этот код изменяет состояние некоторых объектов пользовательского интерфейса. Следовательно, необходимо организовать программный запрос этих объектов.

Базовые программы

Предположим, что тестируемый компонент не является базой данных. Это - оболочка вокруг реальной базы данных на диске. Тестирование с применением этой реальной базы данных будет затруднительно. Ее, возможно, трудно установить и настроить. Необходимо купить лицензии. База данных может замедлить тесты настолько, что разработчик не захочет делать их часто. В таких случаях целесообразно создать "заготовку" базы данных, т.е. программу, делающую минимум для поддержания теста.

Заготовки также необходимы, когда компонент, к которому обращается тестируемый компонент, еще не готов. Это необходимо, чтобы не ждать кода другого разработчика.

Дополнительная информация приведена в документе Концепция: Заготовки.

Не создавайте собственные инструменты тестирования

Проектное тестирование кажется довольно простым процессом. Настройка объекта, вызов через API, проверка результата и извещение об ошибке в случае, если результат не такой, как ожидалось. Также удобно группировать тесты в комплекты. Инструменты, отвечающие этим требованиям, называются системы тестирования.

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

Создавайте поддерживающий исходный код

Тестовый код обычно повторяется. Часто можно видеть тестовый код наподобие следующего:

 // пустое имя не допустимо
    retval = o.createName(""); 
    expect(retval == null);    
    // пробел в начале строки не допустим
    retval = o.createName(" l"); 
    expect(retval == null);    
    // пробел после строки не допустим
    retval = o.createName("name "); 
    expect(retval == null);    
    // первый символ не может быть цифрой
    retval = o.createName("5allpha"); 
    expect(retval == null);    

Этот код создан с помощью копирования кода проверки, вставки и редактирования для новой проверки.

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

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

  void expectNameRejected(MyClass o, String s) { 
    Object retval = o.createName(s);    
    expect(retval == null); }
 ...
 // пустое имя не допустимо
 expectNameRejected(o, ""); 
 // пробел в начале строки не допустим
 expectNameRejected(o, " l"); 
 // пробел после строки не допустим
 expectNameRejected(o, "name "); 
 // первый символ не может быть цифрой
 expectNameRejected(o, "5alpha"); 

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

Сначала напишите тест.

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

Делайте тесты понятными

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

double sqrt(double x);

В этой версии отрицательный аргумент заставляет функцию квадратный корень вернуть значение NaN ("не число" из IEEE 754-1985 Стандарт для двоичной арифметики с плавающей точкой). В новой итерации метод квадратного корня допускает отрицательное число и возвращает сложный результат:

Complex sqrt(double x);

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

  void testSQRT () {    
    //  обновление данных тестов для новой функции квадратный корень
    // когда будет время
    /* double result = sqrt(0.0); ...     */ }

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

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

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

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

Структура теста должна соответствовать структуре продукта.

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

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

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

Позвольте тестам нарушать инкапсуляцию

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

Изображение примера списка, соединенного двойной связью

Рисунок1: список, соединенный двойной связью

В частности, тестируется метод СписокСоединенныйДвойнойСвязью.вставитьПеред(СуществующийОбъект, Объект НовыйОбъект). В одном из тестов в середину списка вставляется элемент и затем проверяется, правильно ли он был вставлен. Тест использует список выше чтобы создать этот обновленный список:

Изображение примера списка, соединенного двойной связью со вставленным объектом

Рисунок2: список, соединенный двойной связью, со вставленным элементом

Правильность списка проверяется следующим образом:

  // лист стал длиннее на один элемент.
 expect(list.size()==3);
 // элемент на правильном месте
 expect(list.get(1)==m); 
 // остальные элементы присутствуют.
 expect(list.get(0)==a); expect(list.get(2)==z);

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

Изображение примера списка, соединенного двойной связью с ошибкой реализации

Рисунок3: список, соединенный двойной связью, с ошибкой реализации

Если СписокСоединенныйДвойнойСвязью.получить(int index) проходит список от начала до конца (что вероятно), тест не обнаружит эту ошибку. Если же класс предоставляет методы элементПеред и элементПосле, то обнаружение такой ошибки не сложно.

  // Проверка обновления всех связей
 expect(list.elementAfter(a)==m);
 expect(list.elementAfter(m)==z);
 expect(list.elementBefore(z)==m);
 //это выдаст ошибку
 expect(list.elementBefore(m)==a);

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

  // Проверка правильности обратной связи от Z.
 list.insertBefore(z, x);
 // В случае, если обновление не было сделано, X будет вставлено
 // после A.
 expect(list.get(1)==m); 

Но такой тест труднее создать и поддерживать. (Также без хороших комментариев не будет понятна его работа.) Существуют два решения:

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

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

Обычно тесты размещаются в том же пакете, что и класс, который они тестируют. Им выделяется защищенный или дружественный доступ.

Типичные ошибки проектирования тестов

Каждый тест использует компонент и проверяет правильность результата. При выполнении тест может обнаруживать некоторые ошибки или нечаянно пропускать их. Вот некоторые типичные ошибки проектирования тестов

Неточность указания ожидаемых результатов

Предположим, что тестируется компонент, преобразовывающий XML в HTML. В данном случае легким решением будет взять пример XML, запустить преобразование и проверить результат в браузере. Если результат выглядит правильным, он принимается как официальный. После чего, тест сравнивает полученный результат преобразования с ожидаемым результатом.

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

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

Ошибка при проверке системы

Тесты обычно проверяют делаемые изменения, но не проверяют то, что должно остаться без изменений. Предположим, что программа меняет первые 100 записей в файле. Необходимо убедиться, что 101-ая запись не была изменена.

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

Ошибка при проверке хранения

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

Ошибка при проверке ограниченных случаев

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

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

Вот дополнительная выгода. Если программа путает поле X с полем Y, то в случае одинакового значения, это невозможно обнаружить.

Ошибка при тестировании упрощенных данных

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

Целесообразно затратить небольшое усилие и использовать реалистичные данные.

Ошибка при отсутствии деятельности проверяемого исходный кода

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

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

Ошибка при неправильной деятельности проверяемого исходный кода

Иногда программа делает правильную вещь в неправильный момент. Рассмотрим следующий простейший пример:

  if (a < b && c)     
     return 2 * x;
 else
     return x * x;

Логическое выражение неверно, и тест неверно оценивает его и идет по неправильному пути. Случайно, переменная X имеет значение 2 в этом тесте. Таким образом, результат неправильного пути случайно оказывается правильным, т.е. таким, как если бы тест пошел по правильному пути.

Для каждого ожидаемого результата необходимо выяснить, можно ли его получить неправильным способом. Часто это нельзя сказать, однако иногда можно.