Статьи

В похвале за продуманный дизайн: как тестирование на основе свойств помогает мне быть лучшим разработчиком

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

Сила этого метода тестирования была быстро реализована сообществом Scala (где родилась библиотека ScalaCheck ) и многими другими, но в экосистеме Java уже давно отсутствует интерес к внедрению тестирования на основе свойств . К счастью, с момента появления jqwik все постепенно меняется в лучшую сторону .

Для многих довольно сложно понять, что такое тестирование на основе свойств и как его можно использовать. Отличная презентация Jessica Kerr на основе Property-Testing for Better Code и всесторонняя серия статей, посвященная тестированию на основе Property , Property-based Testing Patterns — отличные источники для того, чтобы подсказать вам, но в сегодняшнем посте мы попытаемся обнаружить практическая сторона тестирования на основе свойств для типичного разработчика Java с использованием jqwik .

Для начала, что на самом деле подразумевает тестирование на основе свойств имени? Первая мысль каждого Java-разработчика будет заключаться в том, что он нацелен на тестирование всех методов получения и установки (привет 100% охват)? Не совсем, хотя для некоторых структур данных это может быть полезно. Вместо этого мы должны определить характеристики высокого уровня , если хотите, компонента, структуры данных или даже отдельной функции и эффективно проверить их, сформулировав гипотезу.

Наш первый пример относится к категории «Туда и обратно» : сериализация и десериализация в представление JSON . Тестируемый класс — User POJO , хотя он и тривиален, обратите внимание, что у него есть одно временное свойство типа OffsetDateTime .

1
2
3
4
5
6
7
public class User {
    private String username;
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss[.SSS[SSS]]XXX", shape = Shape.STRING)
    private OffsetDateTime created;
     
    // ...
}

Удивительно видеть, как часто манипуляции со свойствами даты / времени вызывают проблемы в наши дни, так как каждый пытается использовать свое представление. Как вы могли заметить, в нашем контракте используется формат обмена ISO-8601 с необязательной миллисекундной частью. Мы хотели бы убедиться, что любой действительный экземпляр User может быть сериализован в JSON и возвращен обратно в Java-объект без потери точности даты / времени. В качестве упражнения попробуем сначала выразить это в псевдокоде:

1
2
3
4
For any user
  Serialize user instance to JSON
  Deserialize user instance back from JSON
  Two user instances must be identical

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

01
02
03
04
05
06
07
08
09
10
11
12
@Property
void serdes(@ForAll("users") User user) throws JsonProcessingException {
    final String json = serdes.serialize(user);
 
    assertThat(serdes.deserialize(json))
        .satisfies(other -> {
            assertThat(user.getUsername()).isEqualTo(other.getUsername());
            assertThat(user.getCreated().isEqual(other.getCreated())).isTrue();
        });
         
    Statistics.collect(user.getCreated().getOffset());
}

Тестовый пример читается очень просто, в основном естественно, но, очевидно, за аннотациями jqwik @Property и @ForAll скрыт некоторый фон. Давайте начнем с @ForAll и выясним , откуда все эти пользовательские экземпляры. Как вы можете догадаться, эти экземпляры должны быть сгенерированы, предпочтительно случайным образом.

Для большинства встроенных типов данных jqwik имеет богатый набор провайдеров данных ( Arbitraries ), но, поскольку мы имеем дело с классом для конкретного приложения, мы должны предоставить собственную стратегию генерации. Он должен иметь возможность создавать экземпляры класса User с широким диапазоном имен пользователей и моментов даты / времени для различного набора часовых поясов и смещений. Давайте сначала заглянем в реализацию провайдера и сразу же обсудим его подробнее.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
@Provide
Arbitrary<User> users() {
    final Arbitrary<String> usernames = Arbitraries.strings().alpha().ofMaxLength(64);
  
    final Arbitrary<OffsetDateTime> dates = Arbitraries
        .of(List.copyOf(ZoneId.getAvailableZoneIds()))
        .flatMap(zone -> Arbitraries
            .longs()
            .between(1266258398000L, 1897410427000L) // ~ +/- 10 years
            .unique()
            .map(epochMilli -> Instant.ofEpochMilli(epochMilli))
            .map(instant -> OffsetDateTime.from(instant.atZone(ZoneId.of(zone)))));
 
    return Combinators
        .combine(usernames, dates)
        .as((username, created) -> new User(username).created(created));
 
}

Источник имен пользователей прост: просто случайные строки. Источником дат в основном может быть любая дата / время между 2010 и 2030 годами, тогда как часть часового пояса (таким образом, смещение) выбирается случайным образом из всех доступных региональных идентификаторов зон. Например, ниже приведены некоторые примеры, которые придумал jqwik .

01
02
03
04
05
06
07
08
09
10
11
{"username":"zrAazzaDZ","created":"2020-05-06T01:36:07.496496+03:00"}
{"username":"AZztZaZZWAaNaqagPLzZiz","created":"2023-03-20T00:48:22.737737+08:00"}
{"username":"aazGZZzaoAAEAGZUIzaaDEm","created":"2019-03-12T08:22:12.658658+04:00"}
{"username":"Ezw","created":"2011-10-28T08:07:33.542542Z"}
{"username":"AFaAzaOLAZOjsZqlaZZixZaZzyZzxrda","created":"2022-07-09T14:04:20.849849+02:00"}
{"username":"aaYeZzkhAzAazJ","created":"2016-07-22T22:20:25.162162+06:00"}
{"username":"BzkoNGzBcaWcrDaaazzCZAaaPd","created":"2020-08-12T22:23:56.902902+08:45"}
{"username":"MazNzaTZZAEhXoz","created":"2027-09-26T17:12:34.872872+11:00"}
{"username":"zqZzZYamO","created":"2023-01-10T03:16:41.879879-03:00"}
{"username":"GaaUazzldqGJZsqksRZuaNAqzANLAAlj","created":"2015-03-19T04:16:24.098098Z"}
...

По умолчанию jqwik запускает тест для 1000 различных наборов значений параметров (случайные пользовательские экземпляры). Весьма полезный контейнер статистики позволяет собирать любые интересующие вас сведения о распределении. На всякий случай, почему бы не собрать распределение по смещению зоны?

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
...
    -04:00 (94) :  9.40 %
    -03:00 (76) :  7.60 %
    +02:00 (75) :  7.50 %
    -05:00 (74) :  7.40 %
    +01:00 (72) :  7.20 %
    +03:00 (69) :  6.90 %
    Z      (62) :  6.20 %
    -06:00 (54) :  5.40 %
    +11:00 (42) :  4.20 %
    -07:00 (39) :  3.90 %
    +08:00 (37) :  3.70 %
    +07:00 (34) :  3.40 %
    +10:00 (34) :  3.40 %
    +06:00 (26) :  2.60 %
    +12:00 (23) :  2.30 %
    +05:00 (23) :  2.30 %
    -08:00 (20) :  2.00 %
    ...

Давайте рассмотрим другой пример. Представьте себе, что в какой-то момент мы решили переопределить равенство для класса User (что в Java означает переопределение equals и hashCode ) на основе свойства username . При этом для любой пары экземпляров класса User должны выполняться следующие инварианты:

  • если два экземпляра пользователя имеют одинаковое имя пользователя , они равны и должны иметь одинаковый хэш-код
  • если два экземпляра пользователя имеют разные имена пользователей , они не равны (но хеш-код не обязательно может быть разным)

Он идеально подходит для тестирования на основе свойств, и jqwik, в частности, делает такие тесты тривиальными для написания и поддержки.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
@Provide
Arbitrary&ltString> usernames() {
    return Arbitraries.strings().alpha().ofMaxLength(64);
}
 
@Property
void equals(@ForAll("usernames") String username, @ForAll("usernames") String other) {
    Assume.that(!username.equals(other));
         
    assertThat(new User(username))
        .isEqualTo(new User(username))
        .isNotEqualTo(new User(other))
        .extracting(User::hashCode)
        .isEqualTo(new User(username).hashCode());
}

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

Вопрос, который вы можете держать до сих пор: какой смысл? Безусловно, можно тестировать сериализацию / десериализацию или метод equals / hashCode, не прибегая к тестированию на основе свойств и не используя jqwik , так зачем вообще беспокоиться? Справедливо, но ответ на этот вопрос в основном лежит в том, как мы подходим к проектированию наших программных систем.

В целом, на тестирование на основе свойств большое влияние оказывает функциональное программирование, и это не первое, что приходит на ум в отношении Java (по крайней мере, пока), мягко говоря. Рандомизированное генерирование тестовых данных само по себе не является новой идеей, однако то, что побуждает вас делать тестирование на основе свойств , по крайней мере, на мой взгляд, состоит в том, чтобы мыслить более абстрактно, фокусироваться не на отдельных операциях (равно, сравнивать, добавлять , сортировать, сериализовать, …) но какие свойства, характеристики, законы и / или инварианты они подчиняются, чтобы подчиняться. Это определенно похоже на инопланетную технику, если хотите, смену парадигмы, побуждает тратить больше времени на разработку правильной вещи. Это не означает, что отныне все ваши тесты должны основываться на свойствах, но я считаю, что это, безусловно, заслуживает места в первом ряду наших наборов инструментов тестирования.

Пожалуйста, найдите полные источники проекта, доступные на Github .

Опубликовано на Java Code Geeks с разрешения Андрея Редько, партнера нашей программы JCG . См. Оригинальную статью здесь: В похвале за продуманный дизайн: как тестирование на основе свойств помогает мне стать лучшим разработчиком

Мнения, высказанные участниками Java Code Geeks, являются их собственными.