Критерий тестируемости кода

Автор: Денисов Виктор Сергеевич
Источник: RSDN Magazine #2-2010
Опубликовано: 20/02/2011
Версия текста: 1.1
Термины и договоренности
Инструменты и системы сборки
Введение
Тесты как движущий импульс архитектуры
Задача
Разработка
Критерий тестируемости метода класса
Шов
Внутренний экземпляр класса
Исходящая зависимость
Заключение
Список использованных источников

Термины и договоренности

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

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

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

Все используемые здесь переводы английских терминов – лишь одна из попыток адаптировать английские понятия к русской речи. Некоторые из них не совпадают с существующими переводами.

Определим термины, которые используются далее:

Принцип наименьшего удивления (Principle of Least Surprise) - указывает на необходимость помещать методы и классы логически связно.

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

Принцип единственной ответственности (Single Responsibility Principle) [Мартин] указывает на то, что класс должен быть ответственным за одну и только одну задачу.

Закон Деметера (Demeter Law) предостерегает от проникновения во внутреннюю структуру объектов, с которыми работает код.

Цепочки вызовов методов через точку x.getY().getZ().process() – классический пример нарушения закона Деметера.

Принцип обращения зависимостей (Inversion of Control) предлагает вместо прямого обращения к классам и объектам (путем доступа по статическим методам или создания экземпляров) предоставлять точки для внедрения зависимостей.

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

      public myMethod() {
new EntityDao().getEntity(id)
}

Следует писать, как вариант, следующее:

MyClass(Dao enitityDao) {
this.entityDao = entityDao;
}

public myMethod() {
  this.entityDao.getEntity(id)
}

Шов (Seam) – участок в коде, где поведение может быть изменено без изменения самого этого участка.

Рефакторинг (Refactoring) – изменение структуры исходного кода без изменения его поведения.

Разработка через тестирование (Test Driven Development)[Бек] – методология разработки, когда тесты для нового поведения пишутся прежде, чем в код вводится это поведение.

Тестовый двойник (Test Double) [Месарош] — это любой объект или компонент, который устанавливается вместо реального компонента на время работы теста. Такие объекты часто, не совсем точно, называют mock-объектами.

Прямой ввод и вывод [Месарош] — аргументы и возвращаемые значения метода. В противоположность им есть непрямые вводы и выводы – все остальные опосредованные пути передачи данных методу и получения данных о выполнении метода.

Инструменты и системы сборки

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

Введение

В современной разработке сложных программных комплексов важное место занимает построение гибких, прозрачных, легко поддерживаемых и модифицируемых архитектур. Зачастую, качественные архитектуры и чистый исходный код есть результат длительного предыдущего опыта по разработке программных систем. Основополагающие книги по архитектуре, разработке и модульному тестированию, в основном, описывают паттерны проектирования [Гамма], паттерны рефакторинга [Фаулер], паттерны модульных тестов [Месарош], то есть решения, которые сложились как результат предыдущего опыта. Некоторые сходятся в мысли, что качество кода и архитектуры является чисто психологическим показателем. Такой показатель как принцип наименьшего удивления [Мартин] явно имеет психологическую природу. Принцип единственной ответственности, также описанный Робертом Мартином в книге Чистый код, можно отнести как к объективным, так и к психологическим. Хотя человек может сравнительно легко понять, что же именно является единственной ответственностью, достаточно трудно строго определить, что же именно является, по определению, одной и только одной причиной для изменения класса. На фоне этих принципов закон Деметера и, например, принцип обращения зависимостей выглядят достаточно строго определяемыми.

Последнее десятилетие дало сообществу разработчиков непростой в использовании, но мощный инструмент для создания качественного программного кода и качественного программного продукта. Имя этому инструменту – модульные тесты (unit tests). Джерард Месарош(Gerard Meszaros) – автор книги "Паттерны тестирования xUnit" (xUnit Test Patterns) называет их сетью безопасности для кода. Майкл Физерс (Michael Feathers) в книге "Эффективная работа с унаследованным кодом" (Working effectively with legacy code) считает отсутствие тестов основной причиной прихода кода в упадок. Многие авторы сходятся во мнении, что тесты улучшают качество кода. Как же влияют тесты на архитектуру приложения?

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

Тесты как движущий импульс архитектуры

Для начала рассмотрим небольшой пример влияния тестов на архитектуру. Мы возьмем задачу к Google Code Jam и будем решать, постепенно вводя тесты. Хочется обратить внимание, что в этом разделе не рассказывается о том, как надо разрабатывать. Это не пример разработки, ведомой тестами (Test Driven Development). Это наблюдение за тем, как изменяется код в случае необходимости ввести тесты.

Задача

Вот примерный перевод задачи с Google Code Jam.

Десятичная система счисления состоит из 10 цифр, которые мы обычно представляем как "0123456789". Вообразите, что вы открыли инопланетную систему счисления, состоящую из набора цифр, число которых может как совпадать с десятичной, так и различаться. Например, если инопланетная система счисления представлена как oF8, тогда числа от одного до десяти выглядят следующим образом (F, 8, Fo, FF, F8, 8o, 8F, 88, Foo, FoF). Нам хотелось бы работать с числами в произвольной инопланетной системе. Мы хотим переводить числа, записанные в одной инопланетной системе, в другую.

Входные данные. В первой строке входного файла содержится N – число тестов. Далее следуют N тестов. Строка теста выглядит следующим образом

ПРИМЕЧАНИЕ

число_в_инопланетной_системе исходный_алфавит целевой_алфавит

Каждый алфавит представлен как набор цифр, перечисленных от наименьшей к наибольшей. Никакая из цифр не может повториться в представлении, все цифры в инопланетном числе будут представлены в исходном алфавите. И первая цифра не будет наименьшей в исходном алфавите (проще говоря, нет лидирующих нулей). Каждая цифра - это цифра от 0 до 9, большая или маленькая буква латинского алфавита, или один из следующих символов. !"#$%& ()*+,-./:;<=>?@[]_

Выходные данные. Для каждого теста выведите строчку вида ``Case \#x:'' с числом, переведенным в новый алфавит.

Будем также заранее полагать, что нам может понадобиться изменить код для решения другой задачи, которую назовем Alien Arithmetic. Однако мы будем об этом только помнить, и проанализируем этот факт в конце, но никак не будем учитывать при решении первоначальной задачи Alien Numbers. Задача Alien Arithmetic была предложена как расширение задачи Alien Numbers на Cisco DCAS Engineering Workshop Биллом Уолкером.

Основное отличие задачи Alien Arithmetic состоит в том, что в качестве входных данных подается строка следующего вида.

ПРИМЕЧАНИЕ

число1 алфавит1 число2 алфавит2 операнд целевой_алфавит

На выходе, соответственно, ожидается результат операции в целевом алфавите.

Разработка

Рассмотрим, как изменяется исходный код с введением тестов и рефакторингом исходного кода для тестирования.

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

        package org.creativelabs.paper;

import java.io.BufferedReader;
import java.io.FileReader;

publicclass App 
{

  publicstatic void main(String[] args) throws Exception 
{
    BufferedReader br = new BufferedReader(new FileReader("input.txt"));
    int n = Integer.parseInt(br.readLine());
    for (int lineNum = 0; lineNum < n; ++lineNum) 
{
      String line = br.readLine();
      String[] data = line.split(" ");
      int base = 1;
      int value = 0;

      for (int i = data[0].length() - 1; i >= 0; --i) 
{
        int v = data[1].indexOf(data[0].charAt(i));
        value += v * base;
        base *= data[0].length();
      }

      StringBuffer ans = new StringBuffer();
      base = data[2].length();
      while (value > 0) 
      {
        ans.append(data[2].charAt(value % base));
        value /= base;
      }

      System.out.println("Case #" + (lineNum + 1) + ":" + ans.reverse());

    }

    br.close();
  } 
}

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

Модульный тест тестирует маленькую часть функциональности приложения. С высокой вероятностью эта часть функциональности при изменении приложения под другую задачу останется неизменной, а если и изменится, то менять придется совсем мало и изменения будут очевидны. Если запустить следующие команды, то первая покажет количество запущенных и пройденных тестов, вторая построит отчеты в формате html в директории target/site.

ПРИМЕЧАНИЕ

mvn test

mvn site

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

прочитать количество входных тестов;

прочитать данные для теста;

перевести во внутреннее представление;

перевести в целевую систему счисления;

вывести результат.

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

перевести во внутреннее представление;

перевести в целевую систему счисления.

В результате получим следующий листинг.

        package org.creativelabs.paper;
import java.io.BufferedReader;
import java.io.FileReader;

public class App {

  publicstaticvoid main(String[] args) throws Exception 
{
    BufferedReader br = new BufferedReader(new FileReader("input.txt"));
    int n = Integer.parseInt(br.readLine());

    for (int lineNum = 0; lineNum < n; ++lineNum) 
{
      String line = br.readLine();
      String[] data = line.split(" ");
      int value = fromAlphabetToInt(data[0], data[1]);
      System.out.println("Case #" + (lineNum + 1) + 
        ":" + fromIntToAlphabet(value, data[2]));
    }
    br.close();
  }

  staticint fromAlphabetToInt(String data, String alphabet) 
{
    int base = 1;
    int value = 0;
    for (int i = data.length() - 1; i >= 0; --i) 
{
      int v = alphabet.indexOf(data.charAt(i));
      value += v * base;
      base *= data.length();
    }
    return value;
  }

  static String fromIntToAlphabet(int value, String alphabet) 
{
    StringBuffer ans = new StringBuffer();
    int base = alphabet.length();

    while (value > 0) 
{
      ans.append(alphabet.charAt(value % base));
      value /= base;
    }

    return ans.reverse().toString();
  }
}

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

        package org.creativelabs.paper;

import org.testng.annotations.Test;
import org.testng.AssertJUnit;

publicclass AppTest 
{

  @Test(groups="all")
  publicvoid testFromAlphabetToInt() 
{
    int v = App.fromAlphabetToInt("3", "0123456789");

    AssertJUnit.assertEquals(3, v);

  }

  @Test(groups="all")
  publicvoid testFromIntToAlphabet() 
{
    String ans = App.fromIntToAlphabet(3, "0123456789");

    AssertJUnit.assertEquals("3", ans);
  }
}

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

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

        package org.creativelabs.paper;

publicclass InputCase 
{

  privateint value;
  private String outAlphabet;

  public InputCase(String line) 
{

    String[] data = line.split(" ");
    value = fromAlphabetToInt(data[0], data[1]);

    outAlphabet = data[2];
  }

  public String getResult() 
{
    return fromIntToAlphabet(value, outAlphabet);
  }

  int fromAlphabetToInt(String data, String alphabet) 
{
    int base = 1;
    int value = 0;

    for (int i = data.length() - 1; i >= 0; --i) 
{
      int v = alphabet.indexOf(data.charAt(i));

      value += v * base;
      base *= data.length();
    }
    return value;
  }

  String fromIntToAlphabet(int value, String alphabet) 
  {

    StringBuffer ans = new StringBuffer();
    int base = alphabet.length();

    while (value > 0) 
{
      ans.append(alphabet.charAt(value % base));
      value /= base;
    }

    return ans.reverse().toString();
  }
}

Соответственно, изменились и тесты. Появились новые тестирующие методы:

        public
        class InputCaseTest 
{

  @Test(groups="all")
  publicvoid testGetResult() 
{
    String value = new InputCase("3 0123456789 01").getResult();
    AssertJUnit.assertEquals("11", value);
  }

  @Test(groups="all")
  publicvoid testFromAlphabetToInt() 
{
    int v = 
new InputCase("stub line value").fromAlphabetToInt("3", "0123456789");
    AssertJUnit.assertEquals(3, v);
  }

  @Test(groups="all")
  publicvoid testFromIntToAlphabet() 
{
    String ans = 
      new InputCase("stub line value").fromIntToAlphabet(3, "0123456789");
    AssertJUnit.assertEquals("3", ans);
  }
}

Обратим внимание на методы fromAlphabetToInt и fromIntToAlphabet. Они имеют область видимости пакета только для целей тестирования. Если бы не было необходимости тестировать этим методы, то, скорее всего, эти методы были бы объявлены как private. Вообще говоря, согласно [Мартин] и [Физерс], наличие private-методов, которые нужно протестировать, заставляет задуматься об изменении архитектуры. Чаще всего это означает выделение новых сущностей с публичными методами. Не приходится даже размышлять над названием для сущности, в которую следовало бы вынести эти методы. Вот так выглядит класс AlienNumber:

        package org.creativelabs.paper;

publicclass AlienNumber 
{

  privateint value;

  public AlienNumber(String data, String alphabet) 
{
    int base = 1;
    value = 0;

    for (int i = data.length() - 1; i >= 0; --i) 
{
      int v = alphabet.indexOf(data.charAt(i));
      value += v * base;

      base *= data.length();
    }
  }

  public String toAlphabet(String alphabet) 
{
    StringBuffer ans = new StringBuffer();

    int base = alphabet.length();
    int tempvalue = this.value;

    while (tempvalue > 0) 
{
      ans.append(alphabet.charAt(tempvalue % base));
      tempvalue /= base;
    }
    return ans.reverse().toString();
  }
}

Теперь тесты на методы fromAlphabetToInt и fromIntToAlphabet переместились в класс AlienNumberTest и стали называться по-другому. Важно следить, чтобы тесты после каждого преобразования архитектуры проходили, и чтобы в ответ на каждое добавление логики появлялись новые тесты. Следить за тем, что при появлении новой логики появляются новые тесты, нам помогает cobertura. Отчет о покрытии кода тестами следует выполнять всякий раз после добавления новой логики в программу.

        public
        class AlienNumberTest 
{

  @Test(groups="all")
  publicvoid testConstructor() 
{
    AlienNumber alienNumber = new AlienNumber("3", "0123456789");
    String ans = alienNumber.toAlphabet("0123456789");
    AssertJUnit.assertEquals("3", ans);
  }

  @Test(groups="all")
  publicvoid testToAlphabet() 
{
    AlienNumber alienNumber = new AlienNumber("3", "0123456789");
    String ans = alienNumber.toAlphabet("01");
    AssertJUnit.assertEquals("11", ans);
  }
}

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

Посмотрим, что же происходит, если у нас появляется другая задача. Перейдем к задаче AlienArithmetic. Будем модифицировать программу, получившуюся в результате решения и тестирования AlienNumbers. Оказывается, что получившуюся архитектуру приходится менять не так уж сильно. Изменения, главным образом, затронут класс InputCase, и необходимо будет добавить несколько методов в класс AlienNumber для выполнения бинарных операций над Alien-числами. Вот измененные классы и тесты к ним:

        package org.creativelabs.paper;

publicclass InputCase 
{

  private AlienNumber alienNumber1;
  private AlienNumber alienNumber2;
  private String operator;
  private String outAlphabet;

  public InputCase(String line) 
{
    String[] data = line.split(" ");
    alienNumber1 = new AlienNumber(data[0], data[1]);
    alienNumber2 = new AlienNumber(data[2], data[3]);

    operator = data[4];
    outAlphabet = data[5];
  }

  public String getResult() 
{
    if ("+".equals(operator)) 
{
      return alienNumber1.add(alienNumber2).toAlphabet(outAlphabet);
    } elseif ("*".equals(operator)) {
      return alienNumber1.mult(alienNumber2).toAlphabet(outAlphabet);
    }

    thrownew RuntimeException("should be + or * operator");
  }
}

package org.creativelabs.paper;

publicclass AlienNumber 
{

  privateint value;

  public AlienNumber() 
{
    this.value = 0;
  }

  public AlienNumber(String data, String alphabet) 
{
    int base = 1;
    value = 0;

    for (int i = data.length() - 1; i >= 0; --i) 
{
      int v = alphabet.indexOf(data.charAt(i));
      value += v * base;
      base *= data.length();
    }
  }

  public String toAlphabet(String alphabet) 
{
    StringBuffer ans = new StringBuffer();

    int base = alphabet.length();
    int tempvalue = this.value;

    while (tempvalue > 0) 
{
      ans.append(alphabet.charAt(tempvalue % base));
      tempvalue /= base;
    }
    return ans.reverse().toString();
  }

  public AlienNumber add(AlienNumber number) 
{
    AlienNumber result = new AlienNumber();
    result.value = value + number.value;
    return result;
  }
  public AlienNumber mult(AlienNumber number) 
{
    AlienNumber result = new AlienNumber();
    result.value = value * number.value;
    return result;
  }
}

package org.creativelabs.paper;

import org.testng.annotations.Test;
import org.testng.AssertJUnit;

publicclass InputCaseTest 
{
  @Test(groups="all")
  publicvoid testGetResult() 
{
    String value = 
      new InputCase("3 0123456789 3 0123456789 + 01").getResult();
    AssertJUnit.assertEquals("110", value);

  }
}

package org.creativelabs.paper;

import org.testng.annotations.Test;
import org.testng.AssertJUnit;

publicclass AlienNumberTest 
{

  @Test(groups="all")
  publicvoid testConstructor() 
{
    AlienNumber alienNumber = new AlienNumber("3", "0123456789");

    String ans = alienNumber.toAlphabet("0123456789");

    AssertJUnit.assertEquals("3", ans);
  }

  @Test(groups="all")
  publicvoid testToAlphabet() 
{
    AlienNumber alienNumber = new AlienNumber("3", "0123456789");
    String ans = alienNumber.toAlphabet("01");

    AssertJUnit.assertEquals("11", ans);
  }

  @Test(groups="all")
  publicvoid testAdd() 
{
    AlienNumber alienNumber1 = new AlienNumber("3", "0123456789");
    AlienNumber alienNumber2 = new AlienNumber("3", "0123456789");
    String ans = alienNumber1.add(alienNumber2).toAlphabet("0123456789");
    AssertJUnit.assertEquals("6", ans);
  }

  @Test(groups="all")
  publicvoid testMult() 
{
    AlienNumber alienNumber1 = new AlienNumber("3", "0123456789");
    AlienNumber alienNumber2 = new AlienNumber("3", "0123456789");

    String ans = alienNumber1.mult(alienNumber2).toAlphabet("0123456789");
    AssertJUnit.assertEquals("9", ans);
  }
}

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

Критерий тестируемости метода класса

В этом разделе будет обсуждаться вопрос формального критерия тестируемости метода класса. Здесь будет строго разделено понятие тестируемости и понятие зависимостей метода.

Шов

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

a.doSomething()

будет швом, если a – это аргумент метода, и не будет являться швом, если a был создан внутри метода конструкцией

a = new A();

С первого взгляда кажется, что это определение – ключ к формальному определению тестируемости метода класса. Вместе с этим понятие исходящей зависимости (Fan out) [Дюваль] класса наводит на мысль о следующем определении:

ПРИМЕЧАНИЕ

Индекс связанности – количество исходящих зависимостей, не являющихся швами.

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

На некоторое время пришлось отказаться от понятия шва и понятия исходящей зависимости.

Внутренний экземпляр класса

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

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

Любое конструирование внутри метода создает внутренний экземпляр.

Является ли левая часть присвоения внешней или внутренней, зависит от результата выражения справа.

Результат цепочки вызовов – внутренний экземпляр, если в цепочке вызовов самый левый операнд является внутренним экземпляром.

Результат цепочки вызовов – внешний экземпляр, если в цепочке вызовов самый левый операнд является внешним экземпляром.

Если метод не работает с внутренними экземплярами, то он является абсолютно тестируемым.

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

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

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

Исходящая зависимость

Выше было описано, что такое абсолютная тестируемость метода, и как добиться высокого показателя тестируемости кода. Следует заметить, что Майкл Физерс, говоря о техниках разрыва зависимостей, имеет в виду именно повышение тестируемости метода и удаление внутренних экземпляров. Однако, хотя возможность внедриться в метод для анализа непрямых входов и контроля непрямых выходов [Месарош] является хорошим подспорьем в тестировании, этого зачастую недостаточно. Если вы можете подменить все классы двойниками, это делает код абсолютно тестируемым. Но если для запуска одного теста нужно создать двойники для всей системы, это создаст слишком большую нагрузку на разработчика. Это может привести к чрезмерной спецификации кода и хрупкому тесту [Месарош].

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

ПРИМЕЧАНИЕ

Класс A есть исходящая зависимость метода М, если метод М требует наличия экземпляра класса A.

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

ПРИМЕЧАНИЕ

Назовем класс A существенной исходящей зависимостью метода M , если для выполнения метода М требуется, чтобы экземпляр класса A принимал не null.

То есть если есть обращения к полям и методам класса A, то это создает дополнительные расходы на построение необходимой внутренней структуры класса A для целей тестирования. Существенные исходящие зависимости значительно затрудняют тестирование. В связи с этим становится ясен смысл закона Деметера [Мартин], говорящего о нераскрытии классами их внутренней структуры.

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

x = a.getValue().patternMatcher("Hello");

Если a имеет тип MyBigApplication, getValue имеет тип возвращаемого значения Regex, а patternMatcher возвращает объект класса PatternMatcher, то понятно, что наш метод существенно зависит от MyBigApplication и Regex, и несущественно – от PatternMatcher, так как последний может быть и null, если только у переменной x не вызываются методы класса PatternMatcher где-нибудь ниже.

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

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

"Индекс связанности — количество исходящих зависимостей, являющихся внутренними экземплярами", -

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

ПРИМЕЧАНИЕ

Индекс тестируемости — отношение количества внешних экземпляров к числу исходящих зависимостей.

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

Заключение

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

Список использованных источников

  1. [Мартин] Роберт К. Мартин, Чистый код. Создание, анализ, рефакторинг. СПб.: Питер, 2010.
  2. [Бек] Кент Бек, Экстремальное программирование: Разработка через тестирование. СПб.:Питер 2003
  3. [Гамма] Эрих Гамма, Ричард Хелм, Ральф Джонсон, Джон Валиссидес, Приемы объектно-ориентированного проектирования. Паттерны проектирования., СПб.: Питер, 2010.
  4. [Месарош] Джерард Месарош, Шаблоны тестирования., М.:Вильямс, 2009.
  5. [Фаулер] Мартин Фаулер, Рефакторинг. Улучшение существующего кода., СПб.: Символ-плюс, 2009.
  6. [Физерс] Майкл Физерс, Эффективная работа с унаследованным кодом., М.: Вильямс, 2009.
  7. [Дюваль] Поль Дюваль, Непрерывная интеграция., М.: Вильямс, 2008.
  8. [Guice] Кевин Бурильон, Боб Ли, Java on Guice: Dependency Injection the Java Way. 2007. URL: http://video.google.com/videoplay?docid=6068447410873108038#


Эта статья опубликована в журнале RSDN Magazine #2-2010. Информацию о журнале можно найти здесь