Некоторое время назад я посмотрел гипнотическое видео Эмили Баче, в котором она отрабатывает так называемое ката «Позолоченная роза». Цель этого ката — добавить небольшую функцию в код, который на первый взгляд нечитабелен. Поскольку тестов нет, она начинает с их написания. Затем она может приступить к масштабному рефакторингу кода, чтобы значительно упростить его и убедиться, что она ничего не сломала. В итоге она легко добавляет новую функцию.
Я, конечно, восхищен ее использованием IntelliJ и ее методологией, но что действительно привлекло мое внимание в этом видео, так это фреймворк, который она использует: ApprovalTests. Концепция сильно отличается от привычных мне модульных тестов, и я сразу же захотел попробовать ее на своем проекте на C++ (где модульные тесты выполняются с помощью Catch2). И тогда я захотела рассказать вам об этом!
В этой статье я использую реализацию для Java вместе с JUnit, чтобы заставить вас поверить, что я могу делать что-то еще, кроме Python и C++!
Если вам не нравится Java или C++, вы должны знать, что ApprovalTests также доступен на C#, PHP, Python, Swift, NodeJS, Perl, Go, Lua, Objective-C или Ruby.
Настройка проекта с помощью Maven
Чтобы сосредоточиться на тестах, а не на тестируемом коде, мы будем использовать слишком простой проект, с 2 файлами .java
:
В pom.xml
мы добавляем зависимости для получения JUnit и ApprovalTests:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>fr.younup</groupId>
<artifactId>TryApprovalTestsWithJava</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>com.approvaltests</groupId>
<artifactId>approvaltests</artifactId>
<version>14.0.0</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.8.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
</project>
Класс для тестирования
Тестируемый класс Candidate
так же прост, как и структура проекта. Это простая запись
с одним полем:
package fr.younup;
public record Candidate(String name) {
}
Метод public String toString()
автоматически генерируется компилятором. Это идеальный вариант для опробования ApprovalTests, и вы поймете почему.
Наш первый тест
Поскольку наш класс представляет собой record
, мало шансов, что его конструктор и метод toString()
окажутся багами, но это не мешает нам написать тест.
Принцип теста с ApprovalTests заключается в конструировании объекта, манипулировании им и последующей «проверке». Эта проверка заключается в генерации строки, представляющей объект, и сравнении ее с эталонной строкой, которую мы предварительно «одобрили» (отсюда и название фреймворка). Теперь вы понимаете, почему использование record
, который автоматически генерирует метод toString()
, удобно для наших тестов! Фаза «манипулирования» объектом после строительства будет очень мала (можно даже сказать, что ее нет), но это нормально, потому что мы все равно можем проиллюстрировать, как работает каркас.
Две строки хранятся в двух файлах, и ApprovalTests просто выполняет различие между этими файлами. Результат diff показывает, прошел тест или не прошел.
Вот наш первый тестовый код:
package fr.younup;
import org.approvaltests.Approvals;
import org.junit.jupiter.api.Test;
public class TestCandidate {
@Test
void candidate() {
Candidate candidate = new Candidate("John Doe");
Approvals.verify(candidate);
}
}
Первый тест завершится неудачно, и мы получим следующую ошибку в консоли:
java.lang.Error: Failed Approval
Approved:D:TryApprovalTestsWithJava.srctestjavafryounupTestCandidate.candidate.approved.txt
Received:D:TryApprovalTestsWithJava.srctestjavafryounupTestCandidate.candidate.received.txt
at org.approvaltests.approvers.FileApprover.fail(FileApprover.java:57)
[...]
at org.approvaltests.Approvals.verify(Approvals.java:55)
at fr.younup.TestCandidate.candidate(TestCandidate.java:11)
[...]
Эти два файла были сгенерированы рядом с тестовым классом, и их имена являются производными от имен тестового класса и тестового метода. Файл TestCandidate.candidate.approved.txt
является ссылкой, а TestCandidate.candidate.received.txt
содержит строку, полученную методом verify()
. Поскольку файл TestCandidate.candidate.approved.txt
не существует, сравнение файлов обязательно завершится неудачей.
ApprovalTests автоматически открывает нашу утилиту diff с этими двумя файлами для простого сравнения. В реальной жизни он пробует набор Reporters
, соответствующих классическим инструментам diff, и надеется найти один.
На моем ПК открывается TortoiseMerge:
Содержимое TestCandidate.candidate.received.txt
хорошо соответствует ожидаемому результату. Мы используем его для заполнения файла TestCandidate.candidate.approved.txt
:
Второй тестовый запуск пройдет успешно:
После этого файл TestCandidate.candidate.received.txt
автоматически удаляется. Действительно, он сохраняется только в том случае, если соответствующий тест не прошел.
Давайте разберем тесты
Давайте изменим класс Candidate
, чтобы он имел 2 поля вместо одного:
package fr.younup;
public record Candidate(String firstName, String lastName) {
}
Давайте также изменим тесты, чтобы они компилировались:
@Test
void candidate() {
Candidate candidate = new Candidate("John", "Doe");
Approvals.verify(candidate);
}
Если мы запустим тесты снова, файл TestCandidate.candidate.received.txt
действительно будет содержать новое поле, но файл TestCandidate.candidate.approved.txt
по-прежнему будет соответствовать старой версии класса. Таким образом, тесты проваливаются, и дифф снова открывается:
Мы можем принять изменения и снова запустить тесты, которые снова будут успешными.
Вы поняли принцип
Мы рассмотрели основы ApprovalTests. Существуют более продвинутые функции проверки, но они работают по тому же принципу: после манипулирования объектами мы генерируем строку и сравниваем ее с эталонной строкой.
Далее мы попробуем использовать некоторые более продвинутые функции сравнения.
Тестирование списка объектов
Если мы можем тестировать объект, кажется очевидным, что мы можем тестировать список объектов, потому что список — это объект. Мы добавляем метод в наш класс TestCandidate
:
@Test
void candidates() {
ArrayList<Candidate> candidates = new ArrayList<>();
candidates.add(new Candidate("John", "Doe"));
candidates.add(new Candidate("Jean", "Bonneau"));
candidates.add(new Candidate("Harry", "Cover"));
Approvals.verify(candidates);
}
Здесь нет ничего сложного.
Выходными файлами являются TestCandidate.candidates.approved.txt
и TestCandidate.candidates.received.txt
. После принятия они содержат :
[Candidate[firstName=John, lastName=Doe], Candidate[firstName=Jean, lastName=Bonneau], Candidate[firstName=Harry, lastName=Cover]]
Тестирование комбинаций
Вместо того чтобы вручную подбирать пары «имя/фамилия», можно воспользоваться возможностью ApprovalTests генерировать и проверять комбинации.
Здесь представлен новый метод тестирования с функцией генерации комбинаций:
@Test
void combinations() {
CombinationApprovals.verifyAllCombinations(
this::generateCandidate,
new String[]{"Jean", "Jeanne"},
new String[]{"Dupont", "Martin"}
);
}
Candidate generateCandidate(String firstName, String lastName) {
return new Candidate(firstName, lastName);
}
Выходные файлы TestCandidate.combinations.approved.txt
и TestCandidate.combinations.received.txt
содержат:
[Jean, Dupont] => Candidate[firstName=Jean, lastName=Dupont]
[Jean, Martin] => Candidate[firstName=Jean, lastName=Martin]
[Jeanne, Dupont] => Candidate[firstName=Jeanne, lastName=Dupont]
[Jeanne, Martin] => Candidate[firstName=Jeanne, lastName=Martin]
Файлы для версионирования
Все файлы *.approved.txt
должны быть версионными, поскольку они являются частью тестов. В них описываются результаты, и они понадобятся другим членам команды, а также вашему CI для проведения тестов.
Говоря о CI, интересно, что происходит, когда тесты терпят неудачу и открывается инструмент diff? Хороший вопрос! Фактически, он не запускается. Более подробная информация здесь: «Машины сборки и серверы непрерывной интеграции».
Кстати, я слышал, что вам может понадобиться добавить *.approved.* binary
в ваш файл .gitattributes
, чтобы избежать ошибок в конце строк.
Заключение
Мне очень понравилась эта основа. Я написал несколько действительно хороших тестов, гораздо более простых и читабельных, чем при использовании «классических» утверждений юнит-тестов.
ApprovalTests не заменяет модульное тестирование с помощью Catch2, JUnit или [вставьте название вашего фреймворка здесь], он просто предоставляет другие способы тестирования вашего кода. Это очень эффективно для классов, которые генерируют данные (особенно если это текст). Он также отлично подходит для существующего кода, который вы знаете, что работает, например, Gilded Rose из видео в начале этой статьи, потому что вы можете утвердить весь набор данных сразу, без необходимости писать десятки утверждений.
Теперь у вас есть дополнительный инструмент для тестирования вашего кода. Используйте его с пользой!