Зачем и как писать тесты?

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

Представьте, что вы написали метод capitalize(text), который делает заглавной первую букву переданной строки:

capitalize("hello"); // "Hello"

Вот один из вариантов его реализации:

public static String capitalize(String text) {
    return text.substring(0, 1).toUpperCase() + text.substring(1);
}

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

System.out.println(capitalize("hello")); // "Hello"
System.out.println(capitalize("how are you")); // "How are you"

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

Фактически, весь этот процесс и есть тестирование. Но не автоматическое, а ручное. Задача такого тестирования — убедиться, что код работает как надо. И нам совершенно без разницы, как конкретно реализован этот метод. Это и есть главный ответ на вопрос, заданный в начале урока.

Тесты проверяют, что код (или приложение) работает корректно. И не заботятся о том, как конкретно написан код, который они проверяют.

Автоматические тесты

Всё, что требуется от автоматических тестов — повторить проверки, которые мы выполняли, делая ручное тестирование. Для этого достаточно старого доброго if и исключений.

Даже если вы не знакомы с исключениями, ничего страшного. В этом курсе достаточно знать две вещи: для чего они нам нужны и какой у них синтаксис. До сих пор в курсах Хекслета вы встречались с ошибками, которые возникают непроизвольно: вызов несуществующего метода, обращение к несуществующей константе и так далее. Но ошибки можно порождать самостоятельно с помощью исключений, что необходимо для нашей ситуации. В Java есть специальный тип исключений, который принято "пробрасывать" при возникновении ошибок в тестах. Они называются AssertionError, что в дословном переводе означает "ошибка утверждения", т.е. вы в тестах что-то утверждали и, если это утверждение оказалось ошибочным, то пробрасывается AssertionError. Исключения создаются такой конструкцией:

// Дословно: выбросить новую ошибку
// Исключения бросают
throw new AssertionError("описание исключения");
// Код, следующий за этим выражением, не выполнится, а сама программа завершится с ошибкой
System.out.println("nothing");

Пример теста:

public static void capitalizeTest() {
    if (!"Hello".equals(capitalize("hello"))) { // Если результат метода не равен ожидаемому значению
        // Выбрасываем исключение и завершаем выполнение теста
        throw new AssertionError("Метод работает неверно!");
    }
}

Теперь для запуска теста вам достаточно лишь вызвать метод capitalizeTest() в вашем методе main и выполнить программу.

Из примера выше видно, что тесты — это точно такой же код, как и любой другой. Он подчиняется тем же правилам, например, стандартам кодирования. А ещё он может содержать ошибки. Но это не значит, что надо писать тесты на тесты. Избежать всех ошибок невозможно, да и не нужно, иначе стоимость разработки стала бы неоправданно высокой. Обнаруженные ошибки в тестах исправляются, и жизнь продолжается дальше ;)

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

src/
└── hexlet
    └── SomeClass.java
tests/
└── hexlet
    └── SomeClassTest.java

Структура этой директории зависит от того, на базе чего пишутся тесты, например, на базе какого фреймворка. В простых случаях, она отражает структуру исходного кода. Если предположить, что наш метод capitalize(text) определён в файле src/hexlet/SomeClass.java, то его тест лучше поместить в файл tests/hexlet/SomeClassTest.java. Слово Test в имени класса с тестами, используется только для более явного обозначения цели файла.

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

src/
└── hexlet
    ├── SomeClass.java
    └── SomeClassTest.java

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

Как пишутся тесты

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

Если поменялась сигнатура метода (входные или выходные параметры, его имя), то придётся переписывать тесты. Если сигнатура осталась той же, но поменялись внутренности метода:

public static String capitalize(String text) {
    return Character.toUpperCase(text.charAt(0)) + text.substring(1);
}

Тогда тесты должны продолжать работать без изменений.

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

Сколько и какие нужно писать проверки?

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

При написании тестов нужно ориентироваться на разнообразие входных данных. У любого метода есть один или несколько основных сценариев использования. Например, в случае capitalize — это любое слово. Достаточно написать ровно одну проверку, которая покрывает этот сценарий. Дальше нужно смотреть на "пограничные случаи". Это ситуации, в которых код может повести себя по-особенному:

  • Работа с пустой строкой
  • Обработка null
  • Деление на ноль (в большинстве языков вызывает ошибку)
  • Специфические ситуации для конкретных алгоритмов

Для capitalize пограничным случаем будет пустая строка:

if (!"".equals(capitalize(""))) {
    throw new AssertionError("Метод работает неверно!");
}

Добавив тест на пустую строку, мы увидим, что вызов показанного в начале урока метода capitalize завершается с ошибкой. Внутри него идёт обращение к первому индексу строки без проверки его существования. Исправленная версия кода:

public static String capitalize(String text) {
    if ("".equals(text)) {
        return "";
    }
    return text.substring(0, 1).toUpperCase() + text.substring(1);
}

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

  • Метод выполнился так, что не был выполнен ни один условный блок
  • Метод выполнился так, что был выполнен только первый условный блок
  • Метод выполнился так, что был выполнен только второй условный блок
  • Метод выполнился так, что были выполнены оба условных блока

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

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

// В этом методе забыли проверить что входной аргумент не равен null
// Этот код сработает корректно в большинстве ситуаций,
// Но если text == null, то программа упадёт по NullPointerException
public static int len(String text) {
    return text.length();
}

Собирая всё вместе

В конечном итоге в этом уроке мы получили такую структуру директорий:

src/
└── hexlet
    ├── Capitalize.java
    └── CapitalizeTest.java

Содержимое класса с тестами:

package hexlet;

import static hexlet.Capitalize.capitalize;

public class CapitalizeTest {

    public static void capitalizeTest() {
        if (!"Hello".equals(capitalize("hello"))) {
            throw new AssertionError("Метод работает неверно!");
        }
        if (!"".equals(capitalize(""))) {
            throw new AssertionError("Метод работает неверно!");
        }
        System.out.println("Все тесты пройдены!");
    }
}

Содержимое класса Capitalize.java, в котором кроме самого метода capitalize() находится ещё и метод main(), в котором происходит вызов тестов:

package hexlet;

public class Capitalize {

    public static void main(String[] args) {
        CapitalizeTest.capitalizeTest();
    }

    public static String capitalize(String text) {
        if ("".equals(text)) {
            return "";
        }
        return Character.toUpperCase(text.charAt(0)) + text.substring(1);
    }
}

Таким образом остаётся лишь скомпилировать нашу программу и вызвать в ней метод capitalizeTest().

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

Мы учим программированию с нуля до стажировки и работы. Попробуйте наш бесплатный курс «Введение в программирование» или полные программы обучения по Javascript, PHP, Python и Java.

Хекслет

Подробнее о том, почему наше обучение работает →