Шаблон для генерации отрицательных сценариев 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 различными элементами. Создать "хороший" легко. Но теперь я хочу создать "плохой", и более того:
Я хочу создать плохой для каждого элемента данных, где один элемент данных является плохим (поэтому, как минимум, не менее 100 плохих экземпляров, проверяющих, что каждый неверный параметр перехватывается логикой проверки).
Я хочу иметь возможность переопределять определенные элементы, например, вводить неверный идентификатор или плохой "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")
Используя это как вдохновение, мы можем думать о наших целях. Несколько вещей для размышления:
Во-первых, мы можем определить карту или контейнер ключей / значений для элемента данных и сгенерированного значения. Это может быть использовано вместо кортежа для поддержки именованного значения мутации.
Имея контейнер пар ключ / значение, мы могли бы легко выбрать одну (или несколько) пар случайным образом и изменить значение. Это поддерживает цель создания набора данных, в котором одно значение изменяется для создания ошибки.
Учитывая такой контейнер, мы можем легко создать новый объект из недопустимой коллекции значений (используя либо
apply()
или какая-то другая техника).В качестве альтернативы, возможно, мы можем разработать шаблон, который использует кортеж, а затем просто
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