Шаблон для генерации отрицательных сценариев 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.

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

  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 символов.

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

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

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

1 ответ

Решение

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

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

Другие вопросы по тегам