Рекомендация: Сопровождение автоматизированных комплектов тестов
Принципы проектирования и управления при сопровождении комплектов тестов.
Взаимосвязи
Основное описание

Введение

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

public boolean login (String username);

Для версии 2.0 отдел маркетинга потребовал реализовать защиту на основе пароля. Поэтому метод был заменен следующим:

public boolean login (String username, String password);

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

Очевидно, для исправления тестов нужно воспользоваться утилитой глобального поиска и замены, указав ей заменять все вхождения login(что-то) на login(что-то, "dummy password"). Укажите этот пароль ("dummy password") для всех учетных записей, используемых для тестирования, и тесты снова будут работать.

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

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

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

public boolean testLogin (String username) {
  return product.login(username);
}

После изменений для версии 2.0 она изменяется и становится такой:

public Boolean testLogin (String username) {
  return  product.login(username

, "dummy password");
}

Вместо изменения тысяч тестов нужно изменить только одну функцию.

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

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

Абстрагирование помогает в работе со сложными системами

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

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

Другой пример

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

/*
 * В выражении
 *   while (i<0) { f(a+i); i++;}
 * нельзя выносить расчет "a+i" из цикла, т.к.
 * в нем содержится переменная, изменяемая в цикле.
 */
loopTest = new LessOp(new Token("i"), new Token("0"));
aPlusI = new PlusOp(new Token("a"), new Token("i"));
statement1 = new Statement(new Funcall(new Token("f"), aPlusI));
statement2 = new Statement(new PostIncr(new Token("i"));
loop = new While(loopTest, new Block(statement1, statement2));
expect(false, loop.canHoist(aPlusI))

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

loop=Parser.parse("while (i<0) { f(a+i); i++; }");
// Получение указателя на компонент "a+i" цикла.
aPlusI = loop.body.statements[0].args[0];
expect(false, loop.canHoist(aPlusI));

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

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

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

Концентрация на тестируемой ветви алгоритма

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

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

loop=Parser.parse("while (i<0) { f(a+i); i++; }");


aPlusI = fetchSubtree(loop, "a+i");

expect(false, loop.canHoist(aPlusI));

Теперь тест восприимчив только к двум вещам: определению языка (например, доступность операции инкрементирования ++) и проверяемым правилам оптимизации циклов.

Одноразовость тестов

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

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

  1. Как много времени уйдет на качественное исправление теста, в т.ч. использование вспомогательных библиотек?
  2. На что еще это время можно было бы потратить?
  3. Какова вероятность того, что этот тест поможет обнаружить серьезные дефекты в будущем? Какие результаты он и связанные с ним тесты показали за время своего существования?
  4. Сколько времени пройдет до следующего нарушения работы теста?

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

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