Property-based тестирование для JavaScript и UI: необычный подход к автоматизированным тестам +43


Elon Musk's Tesla Roadster
Falcon Heavy Demo Mission

Писать тесты скучно. А то, что скучно делать, постоянно откладывается. Меня зовут Назим Гафаров, я разработчик интерфейсов в Mail.ru Cloud Solutions, и в этой статье покажу вам другой, немного странный подход к автоматизированному тестированию.

Что не так с обычным тестированием и что делать


Итак, представьте, что у вас есть такая функция суммирования:

function sum (a, b) {
   return a + b
}

Все мы понимаем важность юнит-тестов. Давайте напишем тест на эту функцию:

const {equal} = require('assert')

const actual = sum(1, 2)
const expected = 3

equal(actual, expected)

Передаем на вход 1 и 2, на выходе ожидаем 3. Все просто — это классическое юнит-тестирование на основе примеров, так называемый example-based testing. Тест работает, все довольны, можно катить в прод. Но тут в игру вступает ваш коллега — сказочный энтерпрайз-программист. Однажды ему понадобилась ваша функция суммирования, но по каким-то причинам он решил ее немного подправить:

function sum (a, b) {
 return 3
}

В этом коде есть какая-то проблема, но с другой стороны — все тесты проходят, а TDD нас учит, что нужно писать минимальный код, который заставит ваши тесты проходить. Это справедливо. Преодолев свой подростковый гнев, вы пишете еще один тест — передаете 4 и 8, ожидаете 12:

equal(
 sum(4, 8),
 12
)

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

function sum (a, b) {
 if (a == 4 && b == 8) return 12
 return 3
}

Вы могли бы добавить в тестовый набор еще примеры, и так продолжалось бы до бесконечности. В этот момент вы думаете: «Зачем его только на работу взяли?», но деваться некуда. Вы выпускаете свое секретное оружие — рандом:

const a = Math.random()
const b = Math.random()
const actual = sum(a, b)
const expected = a + b

equal(actual, expected)

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

  • теперь у вас есть две реализации одной и той же функции, которые нужно держать в актуальном состоянии;
  • очевидно, что функция суммирования довольно примитивна, а представьте, если ваш код делает что-то посложнее суммирования.

Кроме того, когда мы так тестируем код, то понимаем, что это некоторое лукавство, ведь мы проверили его только на двух парах входных данных.

equal( sum(1, 2), 3 )
equal( sum(4, 8), 12 )

Этот тест показывает, что код правильно отрабатывает только в этих двух случаях. Лучшие из нас догадываются, что неплохо протестировать граничные случаи, вроде отрицательных чисел, чисел с плавающей точкой и других. Но это все равно отражает предвзятость разработчика.

Опять же возникает проблема с чертовым энтерпрайз-программистом (The Enterprise Developer From Hell). Этот термин ввел Скотт Влашин, известный популяризатор F#. Вы можете подумать, что энтерпрайз-программист нереалистичен. Понятно, что в здоровой компании ни один нормальный человек не будет ломать функции, но во многих случаях мы сами действуем таким образом.

Мы ведь пишем функции намного сложнее, чем A+B, и в процессе реализации можем написать код, который работает в частных конкретных случаях, а не в общем. Это происходит не из-за злого умысла, а непреднамеренно, из-за неосознанности и слепоты.

Итак, что мы можем сделать с этим. Давайте думать.

A + B

Нет смысла завязывать тесты на A или на B, нужно тестировать то, что посередине, сам плюсик. То есть нужно написать такой тест, который сфокусируется не на входе-выходе, а на свойствах. Эти свойства должны быть истинными для любой правильной реализации. Поэтому давайте подумаем, какие свойства есть у суммирования.

Коммутативность


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

const actual = sum(1, 2)
const expected = sum(2, 1)

equal(actual, expected)

В этом тесте хорошо то, что он работает с любыми входными данными, а не только со специальными магическими числами. Ничего не мешает нам сделать что-то такое:

const rand = Math.random
const [n1, n2] = [ rand(), rand() ]

const actual = sum( n1, n2 )
const expected = sum( n2, n1 )

equal(actual, expected)

Сложение двух чисел — не то что мы делаем каждый день, однако подобный подход можно использовать не только для математических операций, но и для тестирования реальных веб-служб, баз данных и даже интерфейсов. Поэтому следующий пример с делением двух чисел:

function div (dividend, divisor) {
 return dividend / divisor
}

Заходим в Википедию, и оказывается, что у деления есть свойство дистрибутивность справа. Оно означает, что деление суммы двух чисел на какой-то делитель — это то же самое, что деление их по отдельности. Отлично, давайте протестируем это:

const [n1, n2, n3] = [rand(), rand(), rand()]

const left = div(n1 + n2, n3)
const right = div(n1, n3) + div(n2, n3)

equal(left, right)

Теперь запускаем этот тест в цикле много-много раз и при должном терпении получаем такую комбинацию входных данных:

const [n1, n2, n3] = [0, 0, 0]

И тест не проходит, потому что деление нуля на ноль дает NaN:

assert.js:85
 throw new AssertionError(obj);
 ^

AssertionError [ERR_ASSERTION]: NaN == NaN

А NaN, как известно, не равен NaN. Это нормальное поведение JavaScript, но теперь мы понимаем, что в нашей функции деления нужно сделать проверку на нули. Крутим наш тест в цикле дальше, каждый раз генерируя новую порцию случайных данных. В какой-то момент получаем такую комбинацию:

const [n1, n2, n3] = [2, 1, -347]

И тест опять падает:

assert.js:85
 throw new AssertionError(obj);
 ^

AssertionError [ERR_ASSERTION]:
-0.008645533141210375 == -0.008645533141210374

Это неточность округления чисел с плавающей точкой. То есть опять нормальные ограничения вычислений, но когда мы генерируем входные данные, эти ограничения становятся явными. Теперь нам в нашей функции нужно подумать про явное округление или алгоритм, который уменьшит вычислительную погрешность, например алгоритм Кахана Бабушки.

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

Это и есть тестирование на основе свойств — property-based testing. То есть комбинация следующих вещей:

  1. Сначала мы описываем входные данные — говорим системе, какие случайные данные нужно сгенерировать.
  2. Потом описываем ожидаемые свойства — какие-то условия прохождения теста.
  3. А потом просто запускаем этот тест много-много раз.

Как выявлять свойства?


Чтобы выявить свойство для вашей конкретной функции, сначала нужно сформулировать требования. При таком тестировании даже не надо генерировать случайные данные. Вы можете проверять свойства на конкретных примерах, допустим, на сильно отличающихся значениях, каких-то граничных случаях.

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

$(\forall x\in X) P(x)$

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

Фреймворки


Как известно, все лучшее в программировании было изначально придумано в мире Haskell. 20 лет назад идея property-тестирования была реализована во фреймворке QuickCheck.

Сейчас эта форма тестирования в экосистеме Haskell фактически является доминирующей. Для JavaScript есть несколько библиотек, но я остановлюсь на двух: JSVerify и fast-check.

const jsc = require('jsverify')

jsc.assertForall(
 jsc.integer, jsc.integer,
 (a, b) => a + b === b + a
)

Это простой тест на переместительное свойство сложения, о котором мы говорили в начале. Так как у нас нетипизированный язык, надо как-то сообщить фреймворку, какие аргументы мы ожидаем. Тут мы говорим, что нам нужно два числа и последним аргументом передаем предикат. По умолчанию JSVerify запустит тест сто раз, каждый раз генерируя новую пару входных значений.

Давайте проверим переместительное свойство у вычитания. Конечно, такого свойства у вычитания нет, поэтому мы получим объект с ошибкой и законсолим ее:

const subtractionIsCommutative = jsc.checkForall(
 jsc.integer, jsc.integer,
 (a, b) => a - b === b - a
)

console.log(subtractionIsCommutative)

{
 counterexample: [ 0, 1 ],
 tests: 1,
 shrinks: 4,
 rngState: '0e168f30eac572b94d'
}

Система говорит, что упала после первого же теста на контрпримере 0 и 1. RngState — это состояние генератора случайных чисел. В данном случае тестовые данные являются детерминировано случайными. Random number generator выводит для нас seed, который можно подсунуть в test runner, чтобы воспроизвести упавший кейс. Это удобно для отладки, помогает с воспроизводимостью в CI/CD.

mocha test.js --jsverifyRngState 0e168f30eac572b94d

JSVerify имеет небольшой DSL для типов, который позволяет немного сократить запись. Иногда это бывает удобно, например, когда нужны пользовательские типы, легче написать так:

jsc.assert(jsc.forall(
 '{ name: asciinestring; age: nat }',
 (obj) => {
     console.log(obj) // { name: '9lfpy', age: 34 }
     return true
 }
))

Чем так:

jsc.record({
 name: jsc.asciinestring,
 age: jsc.nat,
})

Выбирайте удобный вам способ. Так мы можем генерировать любые собственные типы, например объекты, которые приходят нам с бэкенда. Если встроенных генераторов не хватает, вы легко можете написать собственный. Допустим, вам нужна не просто строка, а строка с email-адресом. Можно сгенерировать ее таким образом:

const emailGenerator = jsc
 .asciinestring.generator
 .map(str => `${str}@example.com`)

В реальной жизни


Теперь давайте посмотрим, как это можно применять в реальной жизни. У библиотеки query-string шесть миллионов скачиваний в неделю. Этот пакет указан в зависимостях более чем четырех тысяч других пакетов.

Query-string делает одну простую вещь — парсит URL-строку в объект и, наоборот, может из объекта сгенерировать URL или его часть:

queryString.parseUrl('https://foo.bar?foo=bar')
//=> {url: 'https://foo.bar', query: {foo: 'bar'}}

queryString.stringify({b: 1, c: 2, a: 3})
//=> 'b=1&c=2&a=3'

Естественно, эта библиотека покрыта кучей классических example-based тестов. Суммарно 400 строк кода тестов.

Но вы не можете учесть все варианты, сколько бы тестов ни написали. Вместо того чтобы выдумывать новые примеры, автор библиотеки fast-check написал один единственный тест, сконцентрированный на свойствах библиотеки:

fastCheck.property(
 queryParamsArbitrary, optionsArbitrary,
 (object, options) => deepEqual(
   queryString.parse(queryString.stringify(object, options), options),
   object
 )
)

Query-string — классическая инверсия, то есть любой объект должен быть переведен в query-строку, а если спарсить эту строку, то должен получиться исходный объект.

Как вы понимаете, он тут же словил баг.

Этот же подход он применил для тестирования печально известной библиотеки left-pad и обнаружил баг со строками, которые содержат символы вне основной плоскости Юникод, например emoji.

Но как ему удавалось так легко выводить свойства, которые находят баги? Все потому, что он знаком с популярными свойствами. Давайте тоже рассмотрим их.

Инверсия


Подход известен так же, как Бильбо-тестирование в честь повести Толкиена «Туда и обратно».

Допустим, у нас есть функция шифрования. Если к полученному результату применить дешифрование, в итоге мы должны получить исходное сообщение:

const string = 'ANY_STRING'
const encrypted = encrypt(string)

expect( decrypt(encrypted) ).toBe( string )

При этом не важно, какое именно сообщение мы зашифровали — это может быть любая строка. Соответственно, мы можем ее сгенерировать. То же самое можем применить для сериализации-десериализации, кодирования-декодирования, сжатия без потерь и так далее.

Именно это свойство мы видели при тестировании query-string:

const obj = {any: 'object'}

_.isEqual(
   JSON.parse( JSON.stringify(obj) ),
   obj,
)

Запись/чтение, вставка/поиск также соответствуют этому шаблону, даже если они не являются строгими инверсиями.

Обратимость


Также частным случаем инверсии является round-trip. Это когда мы берем обратимую функцию и применяем ее дважды:

_.isEqual(
 [...array].reverse().reverse(),
 array,
)

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

Инвариантность


Поиск инвариантов — это поиск чего-то, что не меняется при применении функции. Допустим, у нас есть функция сортировки. Если применить сортировку к любому массиву, длина этого массива не должна поменяться:

equal(
 [...array].sort().length,
 array.length,
)

Кроме того, элементы массива тоже не должны меняться: они могут поменять свой порядок, но сами по себе не меняются — каждый элемент исходного массива должен быть найден в отсортированном массиве.

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

Идемпотентность


Например, к свойству идемпотентности. Это свойство при повторном применении функции будет давать тот же результат, что и при первом. Например, если мы единожды отсортировали массив, повторная сортировка ничего не изменит:

_.isEqual(
 [...array].sort().sort(),
 array.sort(),
)

string.padStart(10) === string.padStart(10).padStart(10)

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

Трудно доказать, легко проверить


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

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

Эталонная реализация


Также этот подход называют тестовым оракулом. Допустим, у нас есть две функции, которые делают одно и то же.

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

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

_.isEqual(
 [...array].sort(),
 fastestSortingAlgorithm(array),
)

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

Только не падай


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

Допустим, у нас есть API — неважно, какие ручки мы дергаем, какие данные передаем, в любом случае сервер не должен отвечать 500. Само по себе это свойство имеет немного смысла, но как отправная точка — сгодится.

Тестирование UI


Представьте, что у вас интернет-магазин с корзиной товаров, вам надо ее протестировать. Сначала нужно определить доступные действия:

  • мы можем добавить товар в корзину;
  • удалить товар;
  • очистить корзину.

Теперь давайте выявлять свойства. Навскидку можно сказать, что количество товаров не может быть отрицательной величиной: Корзина >= 0. При этом в корзине не может быть товаров больше, чем в каталоге: Корзина <= Каталог. А сумма всей корзины не может быть меньше, чем цена самого дорогого товара в ней: sum(Корзина) >= max(Товары).

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

При таком тестировании вы можете проходить через пользовательский интерфейс и нажимать на кнопочки как в обычных e2e-тестах. Но если у вас на фронте фреймворк, в котором представление — это чистая функция от модели, то вы можете просто манипулировать моделью.

Такой подход применили разработчики Spotify для тестирования плейлиста.

Плюсы и минусы тестирования на основе свойств


Плюсы


  1. Тесты на основе свойств заменяют множество example-based тестов, то есть вы пишете меньше кода, а тестов получаете намного больше.
  2. Такие тесты могут сами находить крайние случаи, о которых вы могли не подумать: деление на ноль, строки с emoji и тому подобное.
  3. Их легче поддерживать, потому что у вас нет жестко закодированных данных в тестовом наборе — каких-то магических строк и чисел, непонятно откуда взявшихся. Такие тесты являются более общими, поэтому менее хрупкими.
  4. Писать такие тесты намного интереснее, чем традиционные, так как придумывать примеры скучно, а здесь за вас это делает библиотека.
  5. Тесты на основе свойств заставляют вас думать. Если вы лучше понимаете бизнес-требования, это заставляет вас иметь чистый дизайн и в тестах, и в коде.

Минусы


  1. Написание каждого теста требует больше усилий. Нужно подумать о требованиях и вывести свойства.
  2. Классические тесты служат документацией к коду, то есть показывают пример использования ваших функций. Property-based тесты получаются более абстрактными, а значит, сложными для понимания.
  3. Каждый тест нужно запустить сотню раз, поэтому немного увеличивается время выполнения тестов.
  4. Такие тесты дают ложное ощущение безопасности. Допустим, вы выявили несколько свойств у функции и это дает вам уверенность, что реализация правильная. Однако свойство может быть необходимым, но недостаточным. Например, функция умножения обладает свойством переместительности точно так же, как суммирование, но делают эти функции немного разные вещи.

Выводы


Мы не должны отказываться от классических тестов, но можем их комбинировать с тестированием на основе свойств.

Например, можно базовый функционал покрывать классическими тестами на основе примеров, а критически важные функции дополнительно покрывать property-тестами.

P.S.


Это текстовая версия доклада с HolyJS Piter 2019 и Panda Meetup #22.



Что еще почитать:




К сожалению, не доступен сервер mySQL