Fast Tests Help Humans, Deep Tests Help Servers

The following text is a partial translation of the original English article, performed by ChatGPT (gpt-3.5-turbo) and this Jekyll plugin:

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

Рассмотрим следующий фрагмент кода на Java с простым статическим методом toString():

static String toString(InputStream stream) throws IOException {
  final StringBuilder buf = new StringBuilder();
  while (true) {
    int d = stream.read();
    if (d == -1) {
      break;
    }
    buf.append((char) d);
  }
  return buf.toString();
}

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

@Test
void readsSomeData() throws IOException {
  Assertions.assertEquals(
    "ABC",
    toString(
      new ByteArrayInputStream(
        new byte[] {0x41, 0x42, 0x43}
      )
    )
  );
}

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

@Test
void readsFromManyFiles(@TempDir Path tmp) throws IOException {
  Path f = tmp.resolve("test.txt");
  Files.write(f, "Hello, world!".getBytes());
  for (int i = 0; i < 20000; ++i) {
    Assertions.assertEquals(
      13,
      toString(new FileInputStream(f.toFile())).length()
    );
  }
}

Когда я запускаю этот тест, возникает FileNotFoundException с сообщением Too many open files. Если я снижу верхний предел в цикле for до 10000, ошибка исчезает. Это определенно происходит из-за максимального числа открытых файлов в Mac OS X равного 12,288. Однако на Ubuntu это ограничение установлено на 65536. Таким образом, мой тест не обнаружит ошибку, если я запущу его на Ubuntu. Я уверен, что вы знаете, как исправить эту ошибку в методе toString().

Очевидно, что второй тест гораздо медленнее первого, занимая 650 мс на моем ноутбуке (в 130 раз медленнее!). Это всего лишь пример теста, который помогает выявить ошибки, но требует много времени. Обычно интеграционные тесты демонстрируют такое негативное влияние на производительность, потому что они включают “внешние” ресурсы, которые работают медленно. Файловая система, используемая во втором тесте, является одним из таких внешних ресурсов.

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

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

Классификация тестов на быстрые и глубокие может быть решением. Первая категория включает тесты, которые имитируют как можно больше и выполняются не более 20 мс. Вторая категория состоит из тестов, которые исследуют глубже, чтобы обнаружить трудноуловимые ошибки, которые могут быть упущены быстрыми тестами. Как правило, модульные тесты относятся к первой категории, в то время как интеграционные тесты относятся ко второй. Различие между “модульными-против-интеграционными” тестами, по моему мнению, вводит в заблуждение. “Быстрые-против-глубоких” тестов гораздо понятнее, так как сразу ясно, к какой категории относится тест. Если тест выполняется менее 20 мс, он быстрый; если нет, то глубокий.

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

@Test
@Tag("fast")
void readsSomeData() throws IOException {
  // ...
}
@Test
@Tag("deep")
void readsFromManyFiles(@TempDir Path tmp) throws IOException {
  // ...
}

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

Вот как может быть настроен pom.xml, чтобы включить “быстрые” тесты по умолчанию:

<plugin>
  <artifactId>maven-surefire-plugin</artifactId>
  <configuration>
    <groups>fast</groups>
  </configuration>
</plugin>

В среде CI Maven должен быть запущен с использованием следующего флага:

$ mvn test -Dgroups=slow

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

P.S. Кстати, у метода toString() есть еще одна ошибка, которую не обнаруживают ни первый, ни второй тест. Вы можете ее определить? Могли бы вы разработать тест, который выявит эту ошибку? Как бы вы классифицировали этот тест: “быстрый” или “глубокий”?

Translated by ChatGPT gpt-3.5-turbo/35 on 2023-09-20 at 22:01

sixnines availability badge   GitHub stars