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

Что такое генеративное тестирование?

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

describe('squareRoot', () => { it('should return 2 when given 4', () => { expect(squareRoot(4)).toEqual(2); }); it('should return 3 when given 9', () => { expect(squareRoot(9)).toEqual(3); }); it('should return 0 when given 0', () => { expect(squareRoot(0)).toEqual(0); }); });

Все эти тесты пройдены и на первый взгляд выглядят довольно солидно — мы проверили, что функция работает должным образом, и даже протестировали возможный пограничный случай squareRoot(0). Этот метод тестирования известен как «тестирование на основе примеров». Вы указываете примеры тестовых данных (в данном случае 4, 9 и 0) и проверяете, что результат применения функции к этим данным соответствует ожидаемому.

Однако одна проблема с этим подходом заключается в том, что вы можете пропустить крайние случаи. В приведенном выше примере мы не тестировали нашу функцию squareRoot для отрицательных чисел, Infinity, NaN или чисел с нецелым квадратным корнем. Кроме того, предполагается, что ему будет присвоено только число — что произойдет, если ему будет присвоена строка? Вы можете попытаться подумать о каждом возможном пограничном случае, но в JavaScript их так много, что вы, скорее всего, пропустите несколько.

Генеративные тесты не полагаются на примеры данных, вместо этого вы указываете ограничения, которым должны соответствовать ваши тестовые данные, а затем проверяете, что выходные данные функции подчиняются некоторым другим ограничениям. Например, в нашей функции squareRoot у нас есть ограничение, согласно которому квадрат вывода должен равняться входу (т. е. squareRoot(x) ^ 2 = x), учитывая, что вход — положительное число. Платформа тестирования генерирует сотни возможных входных данных для функции, а затем проверяет правильность каждого вывода.

Генеративные тесты обеспечивают действительно хорошее тестовое покрытие и гораздо эффективнее обнаруживают ошибки и пограничные случаи, чем тесты на основе примеров. Они также заставляют вас более глубоко задуматься о том, как должна вести себя ваша функция — например, что должна возвращать squareRoot для отрицательных чисел: undefined, null, NaN или что-то еще? Или звонящий должен давать ему только положительные числа? Вам нужно подумать об этих вещах, прежде чем вы сможете пройти тесты.

Как это работает?

Я буду использовать библиотеку TestCheck.js Ли Байрона в качестве руководства, чтобы объяснить, как именно работает генеративное тестирование. Это порт библиотеки Clojure, test.check, так что очевидно, что она намного более высокого качества, чем большинство js-библиотек (смиритесь с этим).

TestCheck.js имеет функцию check, которую вам в основном нужно предоставить 2 вещи: один или несколько «генераторов» (в основном функции, которые возвращают случайные тестовые данные) и функцию-предикат, которая принимает сгенерированные значения и возвращает логическое значение. check сгенерирует тестовые данные и запустит функцию предиката указанное количество раз. Если предикат каждый раз возвращает true, то тест пройден. Если он вернет false хотя бы один раз, то тест не пройден. Без лишних слов, вот код:

const squareRoot = Math.sqrt; const result = check( property( gen.number, x => { const sqrt = squareRoot(x); return sqrt * sqrt === x; } ), { numTests: 1337 } // options )

Это запустит генеративный тест, который проверит ограничение, которое мы обсуждали выше (squareRoot(x) ^ 2 = x). Однако на самом деле этот тест провалится, и result будет выглядеть примерно так:

{ result: false, seed: 1510848781067, fail: [ -0.5 ], shrunk: { depth: 1, result: false, smallest: [ -1 ], totalNodesVisited: 2 }, failingSize: 0, numTests: 1 } }

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

  • Это дает вам значение seed, так что вы можете легко воссоздать неудачный запуск теста, если тест периодически дает сбой. Вы можете передать это семя check в карте опций.
  • Если произойдет сбой, check запустит процесс «сжатия» — в основном он попытается найти минимальный набор тестовых данных, который приведет к сбою теста, чтобы помочь с отладкой. Вот откуда берется значение smallest.

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

const result = check( property( // only positive numbers, excluding NaN and Infinity gen.posNumber.suchThat(x => Number.isFinite(x)), x => { const sqrt = squareRoot(x); return sqrt * sqrt === x; } ), { numTests: 1337 } // options )

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

x => { const sqrt = squareRoot(x); return Math.abs(sqrt * sqrt - x) <= x * 0.000001; }

Теперь у нас есть первый пройденный генеративный тест!

Пользовательские генераторы

Существует множество встроенных генераторов для генерации строк, чисел, объектов и т. д. Их достаточно для многих ситуаций, но часто вам нужно быть более конкретным в отношении формы ваших тестовых данных. Вы можете легко создавать собственные генераторы, комбинируя встроенные генераторы и используя .then или .suchThat (RTFM). Одной из распространенных потребностей является создание объектов с определенными ключами, каждый из которых имеет определенный тип. Вы можете легко сделать это так:

const myCustomGenerator = gen.object({ id: gen.posNumber, name: gen.string, age: gen.posNumber.suchThat(age => age < 150) })

Вы также можете использовать собственные генераторы для таких вещей, как адресные строки, строки UUID и URL-адреса.

Я воспользуюсь этой возможностью, чтобы беззастенчиво продвигать свою собственную библиотеку swagger-testcheck, которую вы можете использовать для автоматического создания генераторов из определения swagger. Это устраняет много скучной работы по написанию генераторов вручную, а также помогает вам поддерживать ваши генераторы в актуальном состоянии по мере изменения вашего API.

Недостатки генеративных тестов

Хотя я уверен, что вы все убеждены, что за генеративным тестированием будущее, у него есть несколько недостатков:

  • Ошибки округления — как мы видели ранее, ошибки округления с плавающей запятой — настоящая проблема при использовании генеративных тестов.
  • Поддержка пользовательских генераторов — ваши пользовательские генераторы могут легко рассинхронизироваться с вашим кодом, и их обслуживание может стать головной болью. Например, если у вас есть функция, которая принимает объект с ключами a и b, если вы хотите добавить к этому объекту третий ключ c, вам также потребуется обновить генератор. Если вы этого не сделаете, это часто приводит к провалу тестов, но не всегда.
  • Время выполнения — генеративные тесты выполняются гораздо дольше, чем тесты на основе примеров, что может быть проблемой, если вы тестируете что-то ресурсоемкое. Вы можете уменьшить количество тестовых прогонов, чтобы компенсировать это, но при этом вы увеличите риск пропуска ошибок.
  • Усилие — генеративные тесты, как правило, сложнее написать, чем тесты, основанные на примерах, потому что они заставляют вас действительно понять, как должна/работает тестируемая система. Обычно это очень хорошо потраченные усилия, ИМО, но не всегда.

дальнейшее чтение

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

Первоначально опубликовано на www.uvd.co.uk 20 ноября 2017 г.