Чистый код. Создание, анализ и рефакторинг — страница 23 из 94

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

В листинге 9.1 приведен фрагмент кода из проекта FitNesse. Эти три теста трудны для понимания; несомненно, их можно усовершенствовать. Прежде всего, повторные вызовы addPage и assertSubString содержат огромное количество повторяющегося кода [G5]. Что еще важнее, код просто забит второстепенными подробностями, снижающими выразительность теста.


Листинг 9.1. SerializedPageResponderTest.java

public void testGetPageHieratchyAsXml() throws Exception

{

  crawler.addPage(root, PathParser.parse("PageOne"));

  crawler.addPage(root, PathParser.parse("PageOne.ChildOne"));

  crawler.addPage(root, PathParser.parse("PageTwo"));


  request.setResource("root");

  request.addInput("type", "pages");

  Responder responder = new SerializedPageResponder();

  SimpleResponse response =

      (SimpleResponse) responder.makeResponse(

         new FitNesseContext(root), request);

  String xml = response.getContent();


  assertEquals("text/xml", response.getContentType());

  assertSubString("PageOne", xml);

  assertSubString("PageTwo", xml);

  assertSubString("ChildOne", xml);

}


public void testGetPageHieratchyAsXmlDoesntContainSymbolicLinks()

  throws Exception

{

  WikiPage pageOne = crawler.addPage(root, PathParser.parse("PageOne"));

  crawler.addPage(root, PathParser.parse("PageOne.ChildOne"));

  crawler.addPage(root, PathParser.parse("PageTwo"));


  PageData data = pageOne.getData();

  WikiPageProperties properties = data.getProperties();

  WikiPageProperty symLinks = properties.set(SymbolicPage.PROPERTY_NAME);

  symLinks.set("SymPage", "PageTwo");

  pageOne.commit(data);


  request.setResource("root");

  request.addInput("type", "pages");

  Responder responder = new SerializedPageResponder();

  SimpleResponse response =

      (SimpleResponse) responder.makeResponse(

         new FitNesseContext(root), request);

  String xml = response.getContent();


  assertEquals("text/xml", response.getContentType());

  assertSubString("PageOne", xml);

  assertSubString("PageTwo", xml);

  assertSubString("ChildOne", xml);

  assertNotSubString("SymPage", xml);

}


public void testGetDataAsHtml() throws Exception

{

  crawler.addPage(root, PathParser.parse("TestPageOne"), "test page");


  request.setResource("TestPageOne");

  request.addInput("type", "data");

  Responder responder = new SerializedPageResponder();

  SimpleResponse response =

       (SimpleResponse) responder.makeResponse(

          new FitNesseContext(root), request);

  String xml = response.getContent();


  assertEquals("text/xml", response.getContentType());

  assertSubString("test page", xml);

  assertSubString("

}

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

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

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


Листинг 9.2. SerializedPageResponderTest.java (переработанная версия)

public void testGetPageHierarchyAsXml() throws Exception {

  makePages("PageOne", "PageOne.ChildOne", "PageTwo");


  submitRequest("root", "type:pages");


  assertResponseIsXML();

  assertResponseContains(

    "PageOne", "PageTwo", "ChildOne"

  );

}


public void testSymbolicLinksAreNotInXmlPageHierarchy() throws Exception {

  WikiPage page = makePage("PageOne");

  makePages("PageOne.ChildOne", "PageTwo");


  addLinkTo(page, "PageTwo", "SymPage");


  submitRequest("root", "type:pages");


  assertResponseIsXML();

  assertResponseContains(

    "PageOne", "PageTwo", "ChildOne"

  );

  assertResponseDoesNotContain("SymPage");

}


public void testGetDataAsXml() throws Exception {

  makePageWithContent("TestPageOne", "test page");


  submitRequest("TestPageOne", "type:data");


  assertResponseIsXML();

  assertResponseContains("test page", "

}

В структуре тестов очевидно воплощен паттерн ПОСТРОЕНИЕ-ОПЕРАЦИИ-ПРОВЕРКА[29]. Каждый тест четко делится на три части. Первая часть строит тестовые данные, вторая часть выполняет операции с тестовыми данными, а третья часть проверяет, что операция привела к ожидаемым результатам.

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

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

Предметно-ориентированный язык тестирования

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

Тестовый API не проектируется заранее; он развивается на базе многократной переработки тестового кода, перегруженного ненужными подробностями. По аналогии с тем, как я переработал листинг 9.1 в листинг 9.2, дисциплинированные разработчики перерабатывают свой тестовый код в более лаконичные и выразительные формы.

Двойной стандарт

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

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


Листинг 9.3. EnvironmentControllerTest.java

@Test

  public void turnOnLoTempAlarmAtThreashold() throws Exception {

    hw.setTemp(WAY_TOO_COLD);

    controller.tic();

    assertTrue(hw.heaterState());

    assertTrue(hw.blowerState());

    assertFalse(hw.coolerState());


Листинг 9.3 (продолжение)

    assertFalse(hw.hiTempAlarm());

    assertTrue(hw.loTempAlarm());

  }

Конечно, этот листинг содержит множество ненужных подробностей. Например, что делает функция tic? Я бы предпочел, чтобы читатель не задумывался об этом в ходе чтения теста. Читатель должен думать о другом: соответствует ли конечное состояние системы его представлениям о «слишком низкой» температуре.

Обратите внимание: в ходе чтения теста вам постоянно приходится переключаться между названием проверяемого состояния и условием проверки. Вы смотрите на heaterState (состояние обогревателя), а затем ваш взгляд скользит налево к assertTrue. Вы смотрите на coolerState (состояние охладителя), а ваш взгляд отступает к assertFalse. Все эти перемещения утомительны и ненадежны. Они усложняют чтение теста.

В листинге 9.4 представлена новая форма теста, которая читается гораздо проще.


Листинг 9.4. EnvironmentControllerTest.java (переработанная версия)

@Test

  public void turnOnLoTempAlarmAtThreshold() throws Exception {

    wayTooCold();

    assertEquals("HBchL", hw.getState());

  }

Конечно, я скрыл функцию tic, создав более понятную функцию wayTooCold. Но особого внимания заслуживает странная строка в вызове assertEquals. Верхний регистр означает включенное состояние, нижний регистр — выключенное состояние, а буквы всегда следуют в определенном порядке: {обогреватель, подача воздуха, охладитель, сигнал о высокой температуре, сигнал о низкой температуре}.

Хотя такая форма близка к нарушению правила о мысленных преобразованиях[30], в данном случае она выглядит уместной. Если вам известен смысл этих обозначений, ваш взгляд скользит по строке в одном направлении и вы можете быстро интерпретировать результаты. Чтение таких тестов почти что доставляет удовольствие. Взгляните на листинг 9.5 и убедитесь, как легко понять их смысл.


Листинг 9.5. EnvironmentControllerTest.java (расширенный набор)

@Test

  public void turnOnCoolerAndBlowerIfTooHot() throws Exception {

    tooHot();

    assertEquals("hBChl", hw.getState());

  }


  @Test

  public void turnOnHeaterAndBlowerIfTooCold() throws Exception {

    tooCold();

    assertEquals("HBchl", hw.getState());

  }


  @Test

  public void turnOnHiTempAlarmAtThreshold() throws Exception {

    wayTooHot();

    assertEquals("hBCHl", hw.getState());

  }


  @Test

  public void turnOnLoTempAlarmAtThreshold() throws Exception {

    wayTooCold();

    assertEquals("HBchL", hw.getState());

  }

Функция getState приведена в листинге 9.6. Обратите внимание: эффективность этого кода оставляет желать лучшего. Чтобы сделать его более эффективным, вероятно, мне стоило использовать класс StringBuffer.


Листинг 9.6. MockControlHardware.java

public String getState() {

    String state = "";

    state += heater ? "H" : "h";

    state += blower ? "B" : "b";

    state += cooler ? "C" : "c";

    state += hiTempAlarm ? "H" : "h";

    state += loTempAlarm ? "L" : "l";

    return state;

  }

Класс StringBuffer некрасив и неудобен. Даже в коде продукта я стараюсь избегать его, если это не приводит к большим потерям; конечно, в коде из листинга 9.6 потери невелики. Однако следует учитывать, что приложение пишется для встроенной системы реального времени, в которой вычислительные ресурсы и память сильно ограничены. С другой стороны, в среде тестирования такие ограничения отсутствуют.

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

Одна проверка на тест