Какую главную задачу должны решать тесты? Этот вопрос невероятно важен. Ответ на него даёт понимание того, как правильно писать тесты и как писать их не нужно.
Представьте, что вы написали метод StringUtils.capitalize(text)
, который делает заглавной первую букву переданной строки:
StringUtils.capitalize("hello"); // "Hello"
Вот один из вариантов его реализации:
class StringUtils {
public static String capitalize(String text) {
return text.substring(0, 1).toUpperCase() + text.substring(1);
}
}
Что мы делаем после написания метода? Проверяем, как он работает. Например, пишем простейшую программу и пытаемся вызвать этот метод с различными аргументами:
System.out.println(StringUtils.capitalize("hello"));
// => "Hello"
System.out.println(StringUtils.capitalize("how are you"));
// => "How are you"
Таким нехитрым способом убеждаемся, что метод работает. По крайней мере для тех аргументов, которые мы передали в него. Если во время проверки заметили ошибки, то исправляем метод и повторяем всё заново.
Фактически, весь этот процесс и есть тестирование. Но не автоматическое, а ручное. Задача такого тестирования — убедиться, что код работает как надо. И нам совершенно без разницы, как конкретно реализован этот метод. Это и есть главный ответ на вопрос, заданный в начале урока.
Тесты проверяют, что код (или приложение) работает корректно. И не заботятся о том, как конкретно написан код, который они проверяют.
Автоматические тесты
Всё, что требуется от автоматических тестов — повторить проверки, которые мы выполняли, делая ручное тестирование. Для этого достаточно старого доброго if
и исключений.
Даже если вы не знакомы с исключениями, ничего страшного. В этом курсе достаточно знать две вещи: для чего они нам нужны и какой у них синтаксис. До сих пор в курсах Хекслета вы встречались с ошибками, которые возникают непроизвольно: вызов несуществующего метода, обращение к несуществующей константе и так далее. Но ошибки можно порождать самостоятельно с помощью исключений, что необходимо для нашей ситуации. В Java есть специальный тип исключений, который принято "пробрасывать" при возникновении ошибок в тестах. Они называются AssertionError
, что в дословном переводе означает "ошибка утверждения", т.е. вы в тестах что-то утверждали и, если это утверждение оказалось ошибочным, то пробрасывается AssertionError
. Исключения создаются такой конструкцией:
// Дословно: выбросить новую ошибку
// Исключения бросают
throw new AssertionError("описание исключения");
// Код, следующий за этим выражением, не выполнится, а сама программа завершится с ошибкой
System.out.println("nothing");
Пример теста:
class StringUtilsTest {
public static void testCapitalize() {
// Если результат метода не равен ожидаемому значению
if (!"Hello".equals(StringUtils.capitalize("hello"))) {
// Выбрасываем исключение и завершаем выполнение теста
throw new AssertionError("Метод работает неверно!");
}
}
}
Теперь для запуска теста вам достаточно лишь вызвать метод testCapitalize()
в вашем методе main
и выполнить программу.
Из примера выше видно, что тесты — это точно такой же код, как и любой другой. Он подчиняется тем же правилам, например, стандартам кодирования. А ещё он может содержать ошибки. Но это не значит, что надо писать тесты на тесты. Избежать всех ошибок невозможно, да и не нужно, иначе стоимость разработки стала бы неоправданно высокой. Обнаруженные ошибки в тестах исправляются, и жизнь продолжается дальше ;)
В коде, тесты складывают в директорию src/test:
```bash
# Для пакета io.hexlet
app
└── src
├── main
│ └── java
│ └── io
│ └── hexlet
│ └── SomeClass.java
└── test
└── java
└── io
└── hexlet
└── SomeClassTest.java
```
Структура этой директории, обычно, повторяет структуру исходного кода.
Как пишутся тесты
Тесты — это не магия. Нам, как разработчикам, нужно самостоятельно импортировать тестируемые методы, вызывать их с необходимыми аргументами и проверять, что методы возвращают ожидаемые значения.
Если поменялся контракт (входные данные или выход), то придётся переписывать тесты. Если контракт остался тем же, но поменялись внутренности метода, то тесты должны продолжать работать без изменений.
class StringUtils {
// Пример другой реализации того же самого метода
public static String capitalize(String text) {
return Character.toUpperCase(text.charAt(0)) + text.substring(1);
}
}
Хорошие тесты ничего не знают про внутреннее устройство проверяемого кода. Это делает их более универсальными и надёжными.
Сколько и какие нужно писать проверки?
Невозможно написать тесты, которые гарантируют 100% работоспособность кода. Для этого потребовалось бы реализовать проверки всех возможных аргументов, что физически неосуществимо. С другой стороны, без тестов вообще нет никаких гарантий, только честное слово разработчиков.
При написании тестов нужно ориентироваться на разнообразие входных данных. У любого метода есть один или несколько основных сценариев использования. Например, в случае capitalize
— это любое слово. Достаточно написать ровно одну проверку, которая покрывает этот сценарий. Дальше нужно смотреть на "пограничные случаи". Это ситуации, в которых код может повести себя по-особенному:
- Работа с пустой строкой
- Обработка
null
- Деление на ноль (в большинстве языков вызывает ошибку)
- Специфические ситуации для конкретных алгоритмов
Для capitalize
пограничным случаем будет пустая строка:
if (!"".equals(StringUtils.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();
}
Собирая всё вместе
Содержимое класса с тестами:
package io.hexlet;
public class StringUtilsTest {
public static void testCapitalize() {
if (!"Hello".equals(StringUtils.capitalize("hello"))) {
throw new AssertionError("Метод работает неверно!");
}
if (!"".equals(StringUtils.capitalize(""))) {
throw new AssertionError("Метод работает неверно!");
}
System.out.println("Все тесты пройдены!");
}
}
Остается ответить на вопрос, а как запустить эти тесты? Об этом мы поговорим в уроке про JUnit (а в практиках они запускаются автоматически).
Остались вопросы? Задайте их в разделе «Обсуждение»
Вам ответят команда поддержки Хекслета или другие студенты
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.