Статьи

Тестирование на основе свойств с ScalaCheck

В  предыдущей статье  мы рассмотрели, как   можно использовать Spock для тестирования на основе свойств. Один из примеров « привет, мир » тестирования на основе свойств — убедиться, что абсолютное значение произвольного целого числа всегда неотрицательно. Мы сделали это тоже. Однако наш тест не обнаружил очень важный крайний случай. Убедитесь сами, на этот раз с  ScalaCheck  и  ScalaTest :

import org.scalatest.FunSuite
import org.scalatest.prop.Checkers

class AbsSuite extends FunSuite with Checkers {

    test("absolute value should not be negative") {
        check((somInt: Int) => {
            somInt.abs >= 0
        })
    }
}

… или с другим синтаксисом:

import org.scalatest.FunSuite
import org.scalatest.matchers.ShouldMatchers
import org.scalatest.prop.{GeneratorDrivenPropertyChecks, Checkers}

class AbsSuite extends FunSuite with GeneratorDrivenPropertyChecks with ShouldMatchers{

    test("absolute value should not be negative") {
        forAll((someInt: Int) => {
            someInt.abs should be >= 0
        })
    }
}

Результаты удивительны:

GeneratorDrivenPropertyCheckFailedException was thrown during property evaluation.
 (AbsSuite.scala:7)
  Falsified after 8 successful property evaluations.
  Location: (AbsSuite.scala:7)
  Occurred when passed generated values (
    arg0 = -2147483648
  )

ScalaCheck говорит нам, что наше свойство не встречается для input =  . Что такого особенного в этом номере? s не симметричны,   =   пока   =  . Невозможно представить   в  :-2147483648intInteger.MIN_VALUE-2147483648Integer.MAX_VALUE21474836472147483648Int

scala> (-2147483647).abs
res0: Int = 2147483647

scala> (-2147483648).abs
res0: Int = -2147483648

Вы почувствовали вкус ScalaCheck в сочетании со ScalaTest. ScalaCheck гораздо более продвинутый по сравнению с нашим решением Groovy, потому что он поддерживает:

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

Для тестирования драйва ScalaCheck мы будем работать над простой банковской абстракцией:

case class AccountNo(num: BigInt) extends AnyVal

case class Account(accNo: AccountNo, balance: BigDecimal) {
    def withBalancePlus(amount: BigDecimal): Account =
        this.copy(balance = this.balance + amount)

    def withBalanceMinus(amount: BigDecimal) = withBalancePlus(-amount)

}

class Bank(accounts: Map[AccountNo, Account]) {

    def this(newAccounts: TraversableOnce[Account]) {
        this(newAccounts.map(acc => (acc.accNo, acc)).toMap)
    }

    def transfer(from: AccountNo, to: AccountNo, amount: BigDecimal): Bank = {
        val modifiedFrom = accounts(from).withBalanceMinus(amount)
        val modifiedTo = accounts(to).withBalancePlus(amount)
        val newAccounts = accounts
            .updated(from, modifiedFrom)
            .updated(to, modifiedTo)
        new Bank(newAccounts)
    }

    def totalMoney = accounts.values.map(_.balance).sum

}

Чтобы оставаться в духе функционального программирования, наша  Bank реализация неизменна ( accounts имеет  тип), а также   и  . Каждый раз , когда мы называем  , новый экземпляр   создается, почти точно такой же, но с   и   счета изменены. Это значительно упрощает кодирование в многопоточной среде. Код довольно прост: взять  scala.collection.immutable.MapAccountAccountNoBank.transfer()Bankfromtoamount денег с одного счета и положить его на другой. Предположим, у нас мало примеров тестов, и мы уверены, что этот код работает. Но для большей безопасности мы собираемся построить тест на основе свойств. Какая собственность будет удовлетворена, независимо от того, сколько переводов мы выполняем? Наиболее важным является то, что общая сумма денег в банке должна оставаться неизменной независимо от того, сколько внутрибанковских переводов выполняется. В конце концов, мы не хотим, чтобы деньги исчезали или появлялись из ниоткуда.

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

class BankSuite extends FunSuite with Checkers {

    test("Total money should not change after arbitrary number of intra-bank transfers") {
        check((bank: Bank, transfers: List[Transfer]) => {
            val bankAfterTransfers = transfers.foldLeft(bank) { (curBank, transfer) =>
                curBank.transfer(transfer.from, transfer.to, transfer.amount)
            }
            bank.totalMoney == bankAfterTransfers.totalMoney
        })
    }
}

case class Transfer(from: AccountNo, to: AccountNo, amount: BigDecimal)

То, что мы говорим: для любого  bank и любого  List из  transferstotalMoney до и после должны оставаться одинаковыми. Мы должны,  foldLeft() потому что  Bank является неизменным, и каждый перенос должен быть применен к  Bank экземпляру, возвращенному из предыдущего. ScalaCheck может генерировать случайные  Ints (как мы видели в  AbsSuite) и другие примитивы, строки и т. Д. — и их коллекции. Но ScalaCheck не знает, как создать случайный  Bank или  Transfer:

Error:(34, 8) could not find implicit value for parameter a1: org.scalacheck.Arbitrary[com.nurkiewicz.banking.Bank]
        check((bank: Bank, transfers: List[Transfer]) => {
             ^

Что говорит нам компилятор, так это то, что он не может найти класс  org.scalacheck.Arbitrary[T] типа с параметризацией  Bank. Есть экземпляры этого типа класса для примитивов или коллекции, но явно не для наших  Bank. На самом деле нам нужно предоставить две абстракции:  Gen реализация и  Arbitrary класс типов, обертывающий его. Давайте пройдем это шаг за шагом. accountNoGen генерирует случайные  AccountNo значения в диапазоне от  100000 и  999999Gen это как поток данных без сохранения состояния, он генерирует бесконечное количество случайных значений. Вы можете спросить, почему бы просто не использовать ? Мы можем, но таким образом ScalaCheck может обработать все сгенерированные случайные данные и, например, позволить ответить на них позже, когда используется то же случайное начальное число. Math.rand()

val accountNoGen: Gen[AccountNo] =
    Gen.choose(100000, 999999).map(n => AccountNo(BigInt(n)))

moneyGen генерирует произвольное положительное количество денег (с точностью до цента). Имея их, мы можем составить  accountGen , взяв произвольный номер счета и остаток:

val moneyGen = for {
    value <- Gen.chooseNum(0, 100000000)
    valueDecimal = BigDecimal.valueOf(value)
} yield valueDecimal / 100

val accountGen: Gen[Account] = for {
    accNo <- accountNoGen
    balance <- moneyGen
} yield Account(accNo, balance)

Теперь мы готовы генерировать случайные  Bank. Требуется произвольное число ( Gen.containerOf[List, Account]) произвольных счетов ( accountGen), но мы не хотим создавать пустые банки или банки со слишком большим количеством счетов:

implicit val arbitraryBank = Arbitrary(
    for {
        accounts <- Gen.containerOf[List, Account](accountGen)
        if !accounts.isEmpty
        if accounts.size < 10000
    } yield new Bank(accounts)
)

Последний кусок является случайным  Transfer. Эта часть на самом деле более сложная. Для генерации произвольного перевода нам нужны два случайных счета в банке. Но мы еще не знаем счета, так как банк со счетами был создан случайно. Таким образом, наш генератор должен быть параметризован с банком, который был рандомизирован ранее. Разница между  accountNoGen и  accountNoInBankGen заключается в том, что последний выбирает существующий номер счета из данного банка, а не произвольное случайное число. В  arbitraryTransfer мы не должны проходить  в bank явном виде , поскольку она помечена как  implicit:

def accountNoInBankGen(implicit bank: Bank): Gen[AccountNo] = {
    val accNums = bank.accountNumbers.toSeq
    for {
        accNum <- Gen.chooseNum(0, accNums.size - 1)
    } yield accNums(accNum)
}

implicit def arbitraryTransfer(implicit bank: Bank) = Arbitrary {
    for {
        fromAcc <- accountNoInBankGen
        toAcc <- accountNoInBankGen
        amount <- moneyGen
    } yield Transfer(fromAcc, toAcc, amount)
}

К сожалению  check((bank: Bank, transfers: List[Transfer]) не сработает. Bank и  List[Transfer] генерируется «в то же время», так что нет никакого способа , чтобы пройти генерироваться  bank для  transfers генератора. Мы должны пойти глубже, используя другой синтаксис ScalaCheck ( forAll), слегка злоупотребляя им:

test("Total money should not change after arbitrary number of intra-bank transfers") {
    forAll((bank: Bank) => {
        implicit val anyBank = bank
        forAll((transfers: List[Transfer]) => {
            val bankAfterTransfers = transfers.foldLeft(bank) { (curBank, transfer) =>
                curBank.transfer(transfer.from, transfer.to, transfer.amount)
            }
            bank.totalMoney should equal (bankAfterTransfers.totalMoney)
        })
    })
}

Во внешнем  forAll() предложении мы генерируем произвольное  Bank. Мы должны сделать это,  implicit а затем внутри  forAll мы просим случайное  transfers. Это было много работы! Но, эй, мы нашли ошибку, вы ее заметили?

GeneratorDrivenPropertyCheckFailedException was thrown during property evaluation.
  Message: TestFailedException was thrown during property evaluation.
  Message: 467626.69 did not equal 1352118.86
  Location: (BankChecks.scala:53)
  Occurred when passed generated values (
    arg0 = List(Transfer(AccountNo(664482),AccountNo(664482),884492.17)) // 1 shrink
  )
  Location: (GeneratorDrivenPropertyChecks.scala:837)
  Occurred when passed generated values (
    arg0 = Bank[Account(AccountNo(664482),467626.69)]
  )

Деньги не складываются! Внимательно изучив, мы видим, что тест провалился только с одним аккаунтом и одним переводом. Повторяя тест, мы можем легко найти шаблон: один перевод с тем же исходным и целевым аккаунтом (на 664482 этот раз)! Вернитесь к нашей реализации и попытайтесь выяснить, почему (помните об неизменности):

def transfer(from: AccountNo, to: AccountNo, amount: BigDecimal): Bank = {
    val modifiedFrom = accounts(from).withBalanceMinus(amount)
    val modifiedTo = accounts(to).withBalancePlus(amount)
    val newAccounts = accounts
        .updated(from, modifiedFrom)
        .updated(to, modifiedTo)
    new Bank(newAccounts)
}

Если  from == to, изменения в  modifiedFrom перезаписываются изменениями  modifiedTo. Удивительно, но если бы он  Account был изменчив, эта ошибка не возникла бы (!) Давайте сначала перейдем от красного к зеленому:

def transfer(from: AccountNo, to: AccountNo, amount: BigDecimal): Bank = {
    val modifiedFrom = accounts(from).withBalanceMinus(amount)
    val accountsMinusAmount = accounts.updated(from, modifiedFrom)
    val modifiedTo = accountsMinusAmount(to).withBalancePlus(amount)
    val accountsPlusAmount = accountsMinusAmount.updated(to, modifiedTo)
    new Bank(accountsPlusAmount)
}

Убедитесь, что вы понимаете, почему два фрагмента кода принципиально отличаются. Подсказка: сравните и  . Хорошо, это работает, но я вижу слишком много идентификаторов и шума, давайте пойдем более функционально: accounts(to) accountsMinusAmount(to)

def transfer(from: AccountNo, to: AccountNo, amount: BigDecimal): Bank = {
    this.
        update(from)(_.withBalanceMinus(amount)).
        update(to)  (_.withBalancePlus(amount))
}

private def update(accNo: AccountNo)(transformation: Account => Account): Bank = {
    val account = accounts(accNo)
    val modified = transformation(account)
    val updatedAccounts = accounts.updated(accNo, modified)
    new Bank(updatedAccounts)
}

Приватный  Bank.update() изменяет одну учетную запись, применяя пользовательскую функцию поверх нее. Мы вызываем эту функцию более высокого порядка дважды: один раз для изменения  from учетной записи, затем для изменения,  to но это второе приложение работает поверх уже измененного  Bank экземпляра.

Одна вещь, которую мы не охватили, это сокращение (заметил // 1 shrink комментарий в сообщении об ошибке теста?) ScalaCheck выдает случайные, иногда очень большие входные данные, например, очень длинный список случайных транзакций. Представьте, что только одна транзакция из сотен вызывает ошибку. Если ScalaCheck находит такой список и сообщает о нем, то обнаружение того, какая именно ошибка вызвала передачу, может быть проблемой само по себе. Таким образом, ScalaCheck, используя различные эвристические методы, пытается уменьшить сгенерированный ввод, чтобы найти наименьший, все еще демонстрируя ошибочное поведение. В нашем случае это вопрос выборочного удаления переводов из списка ввода (« сокращения ») до тех пор, пока мы не обнаружим наименьшее подмножество, все еще демонстрирующее ошибку. Этот экономящий время процесс называется « сжатие ». Что еще более важно, мы можем настроить его, например, рассказать фреймворку, как сжать Bank в меньшем, все еще проблемном случае.

Как видите, тестирование на основе свойств может быть полезным. Он не заменяет тестирование на основе примеров. Более того, каждый раз, когда вы обнаруживаете ошибку с помощью ScalaCheck, вы должны начинать с написания примера теста, который не проходит (и терпит неудачу 
постоянно , а не  время от времени ). Помните, что тесты на основе свойств рандомизированы, поэтому они не всегда находят все ошибки — и, что еще хуже, иногда они обнаруживают ошибки намного позже. Такие тесты ценны, но они никогда не заменят обычных, предсказуемых тестов.