Тестирование права собственности - один из самых мощных инструментов в экосистеме scala. В этом посте я объясню, как использовать юридическое тестирование и какую ценность оно даст вам, используя подробные примеры кода.

Этот пост предназначен для разработчиков Scala, которые хотят улучшить свои знания и навыки в области тестирования. Это предполагает некоторые знания Scala, кошек и других функциональных библиотек.

Введение

  • Возможно, вы знакомы с типами, которые представляют собой набор значений (например, значения Int: 1,2,3… Строковые значения: “John Doe” etc).
  • Вы также можете быть знакомы с функциями, которые являются отображением типа ввода на тип вывода.
  • Свойство определяется для типа или функции и описывает желаемое поведение.

Так что же такое Закон? Продолжайте читать!

Конкретный пример

Вот наш любимый Person тип данных:

case class Person(name: String, age: Int)

И код сериализации с использованием Play-Json, библиотеки, которая позволяет преобразовать ваш Person тип в JSON :

val personFormat: OFormat[Person] = new OFormat[Person] {
  override def reads(json: JsValue): JsResult[Person] = {
    val name = (json \ "name").as[String]
    val age = (json \ "age").as[Int]
    JsSuccess(Person(name, age))
  }
override def writes(o: Person): JsObject =
    JsObject(Seq("name" -> JsString(o.name), 
                 "age" -> JsNumber(o.age)))
}

Теперь мы можем протестировать эту функцию сериализации на конкретном входе следующим образом:

import org.scalatest._
class PersonSerdeSpec extends WordSpecLike with Matchers {
  "should serialize and deserialize a person" in {
    val person = Person("John Doe", 32)
    val actualPerson =
      personFormat.reads(personFormat.writes(person))
    actualPerson.asOpt.shouldEqual(Some(person))
  }
}

Но теперь нам нужно спросить себя, все ли люди успешно сериализуются? А как насчет человека с неверными данными (например, отрицательным возрастом)? Хотим ли мы повторить этот мыслительный процесс поиска крайних случаев для всех наших тестовых данных?

И самое главное, останется ли этот код читабельным с течением времени? (например: изменение типа данных person [добавление поля LastName], повторные тесты для других типов данных и т. д.)

«Мы можем решить любую проблему, добавив дополнительный уровень косвенности».

Тестирование на основе собственности

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

В нашем примере свойство будет:

  • Для каждого человека p, если мы сериализуем и десериализуем их, мы должны вернуть того же человека.

Запись этого свойства с помощью проверки scala выглядит так:

object PersonSerdeSpec extends org.scalacheck.Properties("PersonCodec") {
  property("round trip consistency") = 
org.scalacheck.Prop.forAll { a: Person =>
    personFormat.reads(personFormat.writes(a)).asOpt.contains(a)
  }
}

Для проверки свойства требуется способ создания лиц. Это делается с помощью Arbitrary[Person], который можно определить следующим образом:

implicit val personArb: Arbitrary[Person] = Arbitrary {
  for {
    name <- Gen.alphaStr
    age  <- Gen.chooseNum(0, 120)
  } yield Person(name, age)
}

Кроме того, мы можем использовать “scalacheck-shapeless” - удивительную библиотеку, которая устраняет (почти) все потребности в подробном (довольно беспорядочном и очень подверженном ошибкам) ​​произвольном определении типа, создавая его для нас!

Это можно сделать, добавив:

libraryDependencies += "com.github.alexarchambault" %% "scalacheck-shapeless_1.14" % "1.2.0"

и импортируем в наш код следующее:

import org.scalacheck.ScalacheckShapeless._

А затем мы можем удалить экземпляр personArb , который мы определили ранее.

Закон о кодеках

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

trait CodecLaws[A, B] {
  def serialize: A => B
  def deserialize: B => A
  def codecRoundTrip(a: A): Boolean = serialize.
andThen(deserialize)(a) == a
}

Это означает, что данный

  • Типы A, B
  • Функция из A to B
  • Функция из B to A

Мы определяем функцию с именем «codecRoundTrip», которая принимает “a: A”, передает его через функции и гарантирует, что мы вернем то же значение типа A.

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

Другой способ сказать это - заявить, что наши типы A и B изоморфны.

Мы можем абстрагироваться еще больше, используя библиотеку законов о кошках с IsEq case-классом для определения описания Equality.

import cats.laws._
trait CodecLaws[A, B] {
  def serialize: A => B
  def deserialize: B => A
  def codecRoundTrip(a: A): cats.laws.IsEq[A] = serialize.andThen(deserialize)(a) <-> a
}
/** Represents two values of the same type that are expected to be equal. */
final case class IsEq[A](lhs: A, rhs: A)

Из этого типа и синтаксиса мы получаем описание равенства между двумя значениями, а не результат равенства, как раньше.

Тест кодека

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

import cats.laws.discipline._
import org.scalacheck.{ Arbitrary, Prop }
trait CodecTests[A, B] extends org.typelevel.discipline.Laws {
  def laws: CodecLaws[A, B]
  def tests(
    implicit
    arbitrary: Arbitrary[A],
    eqA: cats.Eq[A]
  ): RuleSet =
    new DefaultRuleSet(
      name   = name,
      parent = None,
      "roundTrip" -> Prop.forAll { a: A =>
        laws.codecRoundTrip(a)
      }
    )
}

Мы определяем типаж CodecTest, который принимает 2 параметра типа A and B, которые в нашем примере будут Person и JsResult.

Признак содержит экземпляр законов и определяет метод тестирования, который принимает Arbitrary[A] и средство проверки равенства (типа Eq[A]) и возвращает rule-set для запуска scalacheck.

Обратите внимание, что здесь фактически не выполняются никакие тесты. Это дает нам возможность запускать эти тесты, которые определены только один раз для всех типов, которые мы хотим.

Теперь мы можем зафиксировать конкретный тип и реализацию (например, Play-Json сериализацию), создав экземпляр CodecTest с соответствующими типами.

object JsonCodecTests {
  def apply[A: Arbitrary](implicit format: Format[A]): CodecTests[A, JsValue] =
    new CodecTests[A, JsValue] {
      override def laws: CodecLaws[A, JsValue] =
        CodecLaws[A, JsValue](format.reads, format.writes)
    }
}

A (тип) объезд

Но теперь мы получаем ошибку:

Error:(11, 38) type mismatch;
 found   : play.api.libs.json.JsResult[A]
 required: A

Мы ожидали, что типы будут вытекать из:

  A  =>  B  =>  A

Но типы Play-Json идут от:

 A  =>  JsValue  =>  JsResult[A]

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

Чтобы абстрагироваться от типов, теперь нам нужно использовать синтаксис конструктора типа the F[_]:

trait CodecLaws[F[_],A, B] {
  def serialize: A => B
  def deserialize: B => F[A]
  def codecRoundTrip(a: A)(implicit app:Applicative[F]): IsEq[F[A]] =
    serialize.andThen(deserialize)(a) <-> app.pure(a)
}

Экземпляр Applicative используется для того, чтобы взять простое значение типа A и поднять его в контекст Applicative, который возвращает значение типа F[A].

Этот процесс аналогичен взятию некоторого значения x и поднятию его до Option контекста с помощью Some(x) или, в нашем конкретном примере, взятию значения a:A и преобразованию его к типу JsResult с помощью JsSuccess(a).

Теперь мы можем закончить реализацию для CodecTests и JsonCodecTests следующим образом:

trait CodecTests[F[_], A, B] extends org.typelevel.discipline.Laws {
  def laws: CodecLaws[F, A, B]
  def tests(
    implicit
    arbitrary: Arbitrary[A],
    eqA: cats.Eq[F[A]],
    applicative: Applicative[F]
  ): RuleSet =
    new DefaultRuleSet(
      name   = name,
      parent = None,
      "roundTrip" -> Prop.forAll { a: A =>
        laws.codecRoundTrip(a)
      }
    )
}
object JsonCodecTests {
  def apply[A: Arbitrary](implicit format: Format[A]): CodecTests[JsResult, A, JsValue] =
    new CodecTests[JsResult, A, JsValue] {
      override def laws: CodecLaws[JsResult, A, JsValue] =
        CodecLaws[JsResult, A, JsValue](format.reads, format.writes)
    }
}

И чтобы определить рабочий Person тест сериализации в 1 строке кода:

import JsonCodecSpec.Person
import play.api.libs.json._
import org.scalacheck.ScalacheckShapeless._
import org.scalatest.FunSuiteLike
import org.scalatest.prop.Checkers
import org.typelevel.discipline.scalatest.Discipline
class JsonCodecSpec extends Checkers with FunSuiteLike with Discipline { 
  checkAll("PersonSerdeTests", JsonCodecTests[Person].tests)
}

Сила абстракции

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

Другой пример

Мы можем проверить эту теорию, добавив поддержку сериализации BSON с помощью библиотеки reactive-mongo:

import cats.Id
import io.bigpanda.laws.serde.{ CodecLaws, CodecTests }
import org.scalacheck.Arbitrary
import reactivemongo.bson.{ BSONDocument, BSONReader, BSONWriter }
object BsonCodecTests {
  def apply[A: Arbitrary](
    implicit
    reader: BSONReader[BSONDocument, A],
    writer: BSONWriter[A, BSONDocument]
  ): CodecTests[Id, A, BSONDocument] =
    new CodecTests[Id, A, BSONDocument] {
      override def laws: CodecLaws[Id, A, BSONDocument] =
        CodecLaws[Id, A, BSONDocument](reader.read, writer.write)
override def name: String = "BSON serde tests"
    }
}

Типы здесь вытекают из

A => BsonDocument => A

а не F[A], как мы ожидали. К счастью для нас, у нас есть решение, и мы используем Id-type для его представления.

И учитывая (очень длинное) определение сериализатора:

implicit val personBsonFormat
  : BSONReader[BSONDocument, Person] with BSONWriter[Person, BSONDocument] =
  new BSONReader[BSONDocument, Person] with BSONWriter[Person, BSONDocument] {
    override def read(bson: BSONDocument): Person =
      Person(bson.getAs[String]("name").get, bson.getAs[Int]("age").get)
override def write(t: Person): BSONDocument =
      BSONDocument("name" -> t.name, "age" -> t.age)
  }

Теперь мы можем определить BsonCodecTests во всей его 1 логической красе.

class BsonCodecSpec extends Checkers with FunSuiteLike with Discipline {
    checkAll("PersonSerdeTests", BsonCodecTests[Person].tests)
}

Логическая перспектива (первого порядка) на наш код

Нашу первую тестовую попытку можно описать следующим образом:

∃p:Person,s:OFormat that holds : s.read(s.write(p)) <-> p

Значит, for the specific person p(“John Doe”,32) и for the format s, следующее утверждение верно: decode(encode(p)) <-> p.

Вторая попытка (с использованием PBT) может быть:

∃s:OFormat, ∀p:Person the following should hold :  s.read(s.write(p)) <-> p

Значит, for all persons p и for the specific format s, верно следующее: decode(encode(p))<->p.

Третий (и самый мощный на данный момент оператор) с использованием law testing:

∀s:Encoder, ∀p:Person the the following should hold :  s.read(s.write(p)) <-> p

Это означает, что for all formats s и for all persons p верно следующее: decode(encode(p))<->p.

Резюме

  • Законное тестирование позволяет вам рассуждать о ваших типах данных и функциях в математической и сжатой форме и предоставляет совершенно новую парадигму для тестирования вашего кода!
  • Большинство библиотек уровня типов, которые вы используете (например, cats, circe и многие другие), используют внутреннее тестирование законов для проверки своих типов данных.
  • Избегайте написания конкретных тестовых примеров для ваших типов данных и функций и попытайтесь обобщить их, используя тесты на основе закона о свойствах.

Спасибо, что зашли так далеко! Мне очень нравится находить более абстрактные и полезные законы, которые я могу использовать в своем коде! Пожалуйста, дайте мне знать о том, что вы использовали или можете придумать.

Более вдохновляющий и подробный контент можно найти на сайте cats-law или circe.

Полные примеры кода можно найти здесь.