Статьи

Разработка через тестирование: три простых ошибки

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

1. Начиная с крайних случаев (пустая строка, нулевой параметр) или ошибок:

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

public class WordCounterTest {
 
  @Test
  public void wordCounterCanBeCreated() {
    assertNotNull(new WordCounter());
  }
 
  @Test(expected=IllegalArgumentException.class)
  public void nullInputCausesExeption() {
    new WordCounter().count(null);
  }
 
  @Test
  public void emptyInputGivesEmptyOutput() {
    Map<String, Integer> actual = new WordCounter().count("");
    assertEquals(new HashMap<String, Integer>(), actual);
  }
 
}

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

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

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

Итак, начнем с тестов, которые представляют ценность для бизнеса:

@Test
public void singleWordIsCounted() {
  Map<String, Integer> expected = new HashMap<String, Integer>();
  expected.put("happy", 2);
  assertEquals(expected, new WordCounter().count("happy happy"));
}

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

2. Написание тестов по выдуманным требованиям:

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

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

@Test
public void countWords() {
  assertEquals(2, new WordCounter().countWords("happy monday"));
}

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

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

3. Написание дюжины строк кода для прохождения следующего теста:

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

Предположим, у вас есть эти тесты:

@Test
public void singleWordIsCounted() {
  assertEquals("happy=1", new WordCounter().counts("happy"));
}
 
@Test
public void repeatedWordIsCounted() {
  assertEquals("happy=2", new WordCounter().counts("happy happy"counts));
}

И предположим, что вы писали простейшую вещь, которая работает, поэтому ваш код выглядит так:

public class WordCounter {
  public String counts(String text) {
    if (text.contains(" "))
      return "happy=2";
    return "happy=1";
  }
}

Теперь представьте, что вы выбрали это в качестве следующего теста:

@Test
public void differentWords() {
  assertEquals("happy=1 monday=1", new WordCounter().counts("happy monday"));
}

Это огромный скачок от текущего алгоритма, что продемонстрирует любая попытка его кодирования. Зачем? Итак, код дублирует тесты на этом этапе («happy» встречается как фиксированная строка в нескольких местах), поэтому мы, вероятно, забыли шаг REFACTOR! Пришло время удалить дублирование, прежде чем продолжить; если вы не видите его, попробуйте написать новый тест, который «ближе» к текущему коду:

@Test
public void differentSingleWordIsCounted() {
  assertEquals("monday=1", new WordCounter().counts("monday"));
}

Теперь мы можем легко и эффективно пройти этот простой набор тестов, удалив дублирование между кодом и тестами:

public class WordCounter {
  public String counts(String text) {
    String[] words = text.split(" ");
    return words[0] + "=" + words.length;
  }
}

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

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

Шаг от красной полосы к зеленой полосе должен быть быстрым. Если это не так, вы пишете код, который вряд ли будет проверен на 100% и который подвержен ошибкам. Выбирайте тесты так, чтобы шаги были небольшими, и убедитесь, что рефакторинг ВСЕХ дублирования удален до написания следующего теста, чтобы вам не приходилось кодировать его, одновременно пытаясь добраться до зеленого.