Доводы в пользу тестирования на основе собственности

Вступление

Генеративное тестирование - широко распространенный подход в функциональном мире, в основном благодаря популярности QuickCheck Haskell и реализации test.check Clojure QuickCheck.

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

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

Традиционный подход

Чтобы прояснить ситуацию, давайте начнем с примера.

const add = (a, b) => a + b

Итак, у нас есть собственная функция add, созданная вручную, что означает, что мы можем написать несколько тестов, чтобы убедиться, что все работает должным образом. Типичный тест для добавления может выглядеть примерно так (вы, вероятно, сгруппируете ожидания типа «должен работать с нулем» и т. Д., Но на данный момент этого должно хватить):

describe('add', () => {
  it('should work', () => {
    assert(add(1, 1), 2)
    assert(add(1, 3), 4)
    assert(add(0, 0), 0)
    assert(add(-1, 1), 0)
    assert(add(10, -1), 9)
  })
})

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

Введение в тестирование на основе свойств

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

Вкратце: с генеративным тестированием мы указываем свойства, а библиотека тестирования генерирует эти тесты за нас.

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

Написание функций свойств не так тривиально по сравнению с традиционными модульными тестами. Возьмем, к примеру, ранее определенную функцию add. Написание функции свойства, например

(a, b) => a + b === add(a, b)

бесполезно, поскольку мы повторно реализуем функцию add, которую на самом деле пытаемся протестировать.

Более подходящим подходом было бы убедиться, что добавление нуля работает.

const zeroProperty = x => add(x, 0) === x

Сложение также является коммутативным, поэтому мы также можем основывать наши предположения на этом факте, чтобы убедиться, что add действительно работает.

const commutativeProperty = (x, y) => add(x, y) === add(y, x)

Еще один хороший подход к написанию функции свойств - использование нескольких функций для описания их обратной связи.

const result = x => decodeSomething(encodeSomething(x)) === x

Также можно протестировать функцию по сравнению с другой существующей функцией. Например, вы можете протестировать свою собственную функцию min на соответствие Math.min.

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

const result = add(4, 0) + add(0, 4) === add(4, 4)

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

Детальное тестирование на основе собственности

Теперь, когда у нас есть общее представление о том, что такое тестирование на основе свойств, пора посмотреть, как это работает в настройке JavaScript.

Следующие примеры основаны на testcheck-js Ли Байрона, но есть и другие альтернативы, такие как jsverify.

У нас есть функция handlePayment, которая ожидает два аргумента balance и payment, balance, представляющие общую сумму счета и платеж, представляющий собой сумму, которая будет добавлена ​​или вычтена из общего баланса.

const handlePayment = (balance, payment) =>
  (!payment || balance - payment <= 0) ? balance : balance - payment

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

import { assert } from 'chai'
import handlePayment from '../src/handlePayment'
describe('handlePayment', () => {
  it('should handle zero inputs', () => {
    assert.equal(handlePayment(0, 1), 0)
    assert.equal(handlePayment(1, 0), 1)
  })
  it('should handle negative inputs', () => {
    assert.equal(handlePayment(-1, 1), -1)
    assert.equal(handlePayment(10, -1), 11)
  })
  it('should handle positive inputs', () => {
    assert.equal(handlePayment(200, 1), 199)
    assert.equal(handlePayment(10, 11), 10)
  })
})

Запускаем тесты и видим, что все зеленое.

Какую функцию свойства нам нужно определить для проверки наших предположений?

Давайте проверим, что при нулевом платеже всегда возвращается остаток.

import { check, property, gen } from 'testcheck';
import handlePayment from './handlePayment'
const result = check(property([gen.int], (x) => {
  return handlePayment(x, 0) === x
}))
console.log(result)

Это вывод журнала:

{ result: true, ‘num-tests’: 100, seed: 1471133618254 }

testcheck-js провел сто тестов для handlePayment, и все случаи были пройдены. Теперь давайте проверим, правильно ли работает handlePayment при работе с балансом 100 и случайным входом payment.

const result = check(property([gen.intWithin(0, 100)], x => {
  return handlePayment(100, x) === 100 - x;
}))
console.log(result)

Результат ясно показывает, что дело не удалось.

{ result: false,
  'failing-size': 22,
  'num-tests': 23,
  fail: [ 100 ],
  shrunk: 
   { 'total-nodes-visited': 7,
     depth: 0,
     result: false,
     smallest: [ 100 ] } }

Если мы присмотримся внимательнее, мы обнаружим свойство shrunk. Это мощная функция, которая есть в большинстве библиотек генеративного тестирования. Вместо того, чтобы возвращать большой набор данных случаев, где, возможно, один или несколько случаев не удалось, testcheck-js попытается найти наименьший неудачный тест (см. Свойство smallest внутри сжатие), как только одно дело не удается. В этом конкретном случае наименьшее значение, которое не удалось, равно 100. Это дает нам очень конкретные данные, чтобы выяснить, заключается ли проблема в функции предиката, проверяющей наш handlePayment, или в самом handlePayment или если созданный нами набор данных недостаточно ясен.

Набор данных должен быть в порядке. Давайте проверим функцию handlePayment.

Очевидно, что случай, когда баланс и платеж могут быть равны, в этом случае это [100, 100], не обрабатывается должным образом. Мы проверили, что баланс должен возвращать баланс, когда платеж превышает баланс, но не охватили этот конкретный случай. Исправим updatePayment.

const handlePayment = (balance, payment) =>
  (!payment || balance - payment < 0 // fix. only less than zero
  ) ? balance : balance - payment

Это решит проблему.

Давайте проверим, что paymentTransaction может обрабатывать как отрицательный баланс, так и отрицательный платеж.

const { strictNegInt : strNegInt } = gen
const result = check(property([strNegInt, strNegInt], (x, y) => {
  return handlePayment(x, y) === x - y;
}), {seed: 50})
console.log(result)

Если вы внимательно посмотрите на наше новое свойство, мы добавили объект параметров, в котором мы определили начальное значение, чтобы иметь возможность воспроизводить сгенерированные тесты. (Благодарим Сьюн Симонсен за то, что подчеркнули этот факт)

Запуск вновь добавленного теста приводит к сбою в новом случае.

{ result: false,
  'failing-size': 1,
  'num-tests': 2,
  fail: [ -2, -1 ],
  shrunk: 
   { 'total-nodes-visited': 1,
     depth: 0,
     result: false,
     smallest: [ -2, -1 ] } }

Баланс -2 и платеж -1 будут иметь положительный результат на балансе. Нам снова нужно провести рефакторинг handlePayment.

const handlePayment = (balance, payment) =>
  (!payment ||
  (balance <= 0 && balance - payment < balance) ||
  (balance > 0 && balance - payment < 0)) ?
    balance : balance - payment

В конце концов, повторное выполнение теста во всех случаях приводит к успешному прохождению теста.

{ result: true, 'num-tests': 100, seed: 50 }

Давайте в последний раз реорганизуем handlePayment, чтобы сделать его более читабельным.

const handlePayment = (balance, payment) =>
  payment &&
  ((balance <= 0 && balance - payment > balance) ||
  balance - payment >= 0) ?
     balance - payment : balance

Тесты все еще подтверждают свойство.

{ result: true, ‘num-tests’: 100, seed: 50 }

Два неудачных случая могут быть добавлены к ранее определенным модульным тестам.

Мы не рассказали, как интегрировать testcheck-js с mocha или jasmine. Это выходит за рамки данной статьи, но существуют определенные обертки mocha и jasmine testcheck-js, см. Mocha-check и jasmine-check для получения дополнительной информации по теме. jsverify также имеет интеграцию для мокко и жасмин.

Резюме

Генеративное тестирование или тестирование на основе свойств не заменяет традиционное модульное тестирование. В то время как подход, основанный на примерах, должен проверять общие предположения, подход, основанный на свойствах, должен помочь выявить пропущенные случаи. Кроме того, традиционные тесты легче писать, в то время как генеративный подход требует большего мышления в отношении определения свойств. Использование их обоих рядом друг с другом даст наибольшие преимущества. Самый простой способ начать генеративное тестирование - это попробовать библиотеку, например jsverify или testcheck-js, и начать писать пару тестов, чтобы полностью понять преимущества.

Я добавил пару ссылок, которые могут быть полезны для начала работы с тестированием на основе свойств.

Изменить

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

Также прочтите Генеративное тестирование Redux: редукторы, чтобы увидеть пример тестирования редукторов редукторов на основе свойств.

Обратная связь или вопросы через твиттер »

Ссылки

Тестирование генеративных свойств для библиотек JavaScript

jsverify

testcheck-js

неожиданная проверка

graue / gentest

Другие полезные ресурсы

QuickCheck

Введение в QuickCheck

test.check

Испытание трудностей и сохранение рассудка, Джон Хьюз

Эффективное тестирование с помощью test.check, Рид Дрейпер