Шаблон для создания отрицательных сценариев Scalacheck: использование тестирования на основе свойств для проверки логики проверки в Scala

Мы ищем жизнеспособный шаблон проектирования для создания Scalacheck Gen (генераторов), который может создавать как положительные, так и отрицательные тестовые сценарии. Это позволит нам запустить forAll тестов для проверки функциональности (положительные случаи), а также убедиться, что наша проверка класса случаев работает правильно, отказавшись от всех недопустимых комбинаций данных.

Сделать простой параметризованный Gen, который делает это на разовой основе, довольно легко. Например:

  def idGen(valid: Boolean = true): Gen[String] = Gen.oneOf(ID.values.toList).map(s => if (valid) s else Gen.oneOf(simpleRandomCode(4), "").sample.get)

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

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

  1. Я хочу сгенерировать плохой для каждого элемента данных, где один элемент данных плохой (так что, как минимум, не менее 100 плохих экземпляров, проверяя, что каждый недопустимый параметр улавливается логикой проверки).

  2. Я хочу иметь возможность переопределять конкретные элементы, например, вводить неверный идентификатор или неверный "foobar". Что бы это ни было.

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

val f = Foo("a", "b") // f: Foo = Foo(a,b)
val t = Foo.unapply(f) // t: Option[(String, String)] = Some((a,b))
Foo(t.get._1, "c") // res0: Foo = Foo(a,c)

Выше мы видим основную идею создания мутирующего объекта из шаблона другого объекта. На Scala это проще выразить так:

val f = someFoo copy(b = "c")

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

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

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

  3. Имея такой контейнер, мы можем легко создать новый объект из недопустимого набора значений (используя либо apply(), либо какой-либо другой метод).

  4. В качестве альтернативы, возможно, мы можем разработать шаблон, который использует кортеж, а затем просто apply(), что-то вроде метода copy, пока мы можем случайным образом изменить одно или несколько значений.

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

def thingGen(invalidValueCount: Int): Gen[Thing] = ???
def someTest = forAll(thingGen) { v => invalidV = v.invalidate(1); validate(invalidV) must beFalse }

В приведенном выше коде у нас есть генератор thingGen, который возвращает (действительно) Things. Затем для всех возвращаемых экземпляров мы вызываем общий метод invalidate(count: Int), который случайным образом делает недействительными count значений, возвращая недопустимый объект. Затем мы можем использовать это, чтобы убедиться, что наша логика проверки работает правильно.

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

Конечно, чтобы аннулировать известный единственный параметр (чтобы ввести неверные данные в тестовое условие), мы можем просто использовать метод копирования:

def thingGen(invalidValueCount: Int): Gen[Thing] = ???
def someTest = forAll(thingGen) { v => v2 = v copy(id = "xxx"); validate(v2) must beFalse }

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


person Zac    schedule 11.01.2017    source источник


Ответы (1)


Мы можем объединить допустимый экземпляр и набор недопустимых полей (так что каждое поле, если оно будет скопировано, приведет к сбою проверки), чтобы получить недопустимый объект, используя shapeless.

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

В приведенном ниже примере я предоставлю недопустимый экземпляр для каждого предоставленного поля.

import shapeless._, record._
import shapeless.labelled.FieldType
import shapeless.ops.record.Updater

Подробное введение

Давайте представим, что у нас есть класс данных и его действительный экземпляр (нам нужен только один, поэтому его можно жестко закодировать)

case class User(id: String, name: String, about: String, age: Int) {
  def isValid = id.length == 3 && name.nonEmpty && age >= 0
}
val someValidUser = User("oo7", "Frank", "A good guy", 42)
assert(someValidUser.isValid)

Затем мы можем определить класс, который будет использоваться для недопустимых значений:

case class BogusUserFields(name: String, id: String, age: Int)
val bogusData = BogusUserFields("", "1234", -5)

Экземпляры таких классов могут быть предоставлены с помощью ScalaCheck. Гораздо проще написать генератор, в котором все поля вызовут сбой. Порядок полей не имеет значения, важны их имена и типы. Здесь мы исключили about из User набора полей, чтобы мы могли сделать то, о чем вы просили (загрузив только подмножество полей, которые вы хотите протестировать).

Затем мы используем LabelledGeneric[T] для преобразования User и BogusUserFields в соответствующее значение записи (позже мы преобразуем User обратно)

val userLG = LabelledGeneric[User]
val bogusLG = LabelledGeneric[BogusUserFields]

val validUserRecord = userLG.to(someValidUser)
val bogusRecord = bogusLG.to(bogusData)

Записи представляют собой списки пар ключ-значение, поэтому мы можем использовать head для получения одного сопоставления, а оператор + поддерживает добавление/замену поля в другую запись. Давайте выберем каждое недопустимое поле в нашем пользователе по одному. Кроме того, вот обратное преобразование в действии:

val invalidUser1 = userLG.from(validUserRecord + bogusRecord.head)// invalid name
val invalidUser2 = userLG.from(validUserRecord + bogusRecord.tail.head)// invalid ID
val invalidUser3 = userLG.from(validUserRecord + bogusRecord.tail.tail.head) // invalid age

assert(List(invalidUser1, invalidUser2, invalidUser3).forall(!_.isValid))

Поскольку мы в основном применяем одну и ту же функцию (validUserRecord + _) к каждой паре ключ-значение в нашем bogusRecord, мы также можем использовать оператор map, за исключением того, что мы используем его с необычной — полиморфной — функцией. Мы также можем легко преобразовать его в List, потому что теперь все элементы будут одного типа.

object polymerge extends Poly1 {
  implicit def caseField[K, V](implicit upd: Updater[userLG.Repr, FieldType[K, V]]) =
    at[FieldType[K, V]](upd(validUserRecord, _))
}

val allInvalidUsers = bogusRecord.map(polymerge).toList.map(userLG.from)
assert(allInvalidUsers == List(invalidUser1, invalidUser2, invalidUser3))

Обобщение и удаление всех шаблонов

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

class Picks[A, AR <: HList](defaults: A)(implicit lgA: LabelledGeneric.Aux[A, AR]) {
  private val defaultsRec = lgA.to(defaults)

  object mergeIntoTemplate extends Poly1 {
    implicit def caseField[K, V](implicit upd: Updater[AR, FieldType[K, V]]) =
      at[FieldType[K, V]](upd(defaultsRec, _))
  }

  def from[B, BR <: HList, MR <: HList, F <: Poly](options: B)
    (implicit
      optionsLG: LabelledGeneric.Aux[B, BR],
      mapper: ops.hlist.Mapper.Aux[mergeIntoTemplate.type, BR, MR],
      toList: ops.hlist.ToTraversable.Aux[MR, List, AR]
    ) = {
    optionsLG.to(options).map(mergeIntoTemplate).toList.map(lgA.from)
  }
}

Итак, вот он в действии:

val cp = new Picks(someValidUser)
assert(cp.from(bogusData) == allInvalidUsers)

К сожалению, вы не можете написать new Picks(someValidUser).from(bogusData), потому что неявно для mapper требуется стабильный идентификатор. С другой стороны, экземпляр cp можно повторно использовать с другими типами:

case class BogusName(name: String)
assert(cp.from(BogusName("")).head == someValidUser.copy(name = ""))

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

case class Address(country: String, city: String, line_1: String, line_2: String) {
  def isValid = Seq(country, city, line_1, line_2).forall(_.nonEmpty)
}

val acp = new Picks(Address("Test country", "Test city", "Test line 1", "Test line 2"))
val invalidAddresses = acp.from(Address("", "", "", ""))
assert(invalidAddresses.forall(!_.isValid))

Вы можете увидеть работающий код на странице ScalaFiddle.

person Oleg Pyzhcov    schedule 12.01.2017
comment
Хорошо, это потрясающий ответ, хотя я недостаточно хорошо знаю Shapeless, чтобы следовать ему до конца, поэтому сейчас я читаю о Shapeless и с нетерпением жду возможности применить это в действии. . - person Zac; 31.01.2017