Статьи

Тестирование на основе свойств для доменных моделей


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

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

Такие фреймворки, как
QuickCheck в Haskell или
ScalaCheck в Scala, позволяют вам написать спецификации свойств, которым должна удовлетворять модель, а затем сгенерировать данные для оценки правильности выполнения и выполнения тестов, а также их полноты. Вот что упоминает Брайан О’Салливан и др., Обсуждая тестирование на основе свойств в своей книге
Real World Haskell .


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

Я много занимался моделированием доменов в системе торговли ценными бумагами. Эта модель довольно сложна, и, как и большинство из нас, я начал с тестирования на основе xUnit, чтобы проверить некоторые инварианты и ограничения, которые должна соблюдать система. Но очень быстро я обнаружил, что свойства предлагают более лаконичный способ абстрагирования ограничений и инвариантов моей модели. Следовательно, использование такого инструмента, как ScalaCheck, делает его намного проще, если в качестве входных данных указать правильный генератор данных. Рассмотрим простое свойство в следующем примере.

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

property("Enrichment of a trade should result in netvalue > 0") {
  forAll((a: Trade) =>
    enrichTrade(a).netAmount.get should be > (BigDecimal(0)))
}

Здесь мне не нужно создавать конкретные данные вручную (на самом деле это то, что делает основанные на xUnit фреймворки сложной системой ручного тестирования — она ​​автоматизирует только часть выполнения вашего тестирования). Вместо этого я предоставляю торговый генератор, который дает ScalaCheck достаточно информации, на основе которой он может генерировать множество сделок. Типичная упрощенная версия торгового генератора заключается в следующем.

implicit lazy val arbTrade: Arbitrary[Trade] =
  Arbitrary {
    for {
      a <- Gen.oneOf("acc-01", "acc-02", "acc-03", "acc-04")
      i <- Gen.oneOf("ins-01", "ins-02", "ins-03", "ins-04")
      r <- Gen.oneOf("r-001", "r-002", "r-003")
      m <- arbitrary[Market]
      u <- Gen.oneOf(BigDecimal(1.5), BigDecimal(2), BigDecimal(10))
      q <- Gen.oneOf(BigDecimal(100), BigDecimal(200), BigDecimal(300))
    } yield Trade(a, i, r, m, u, q)
  }

Я могу сделать более сложные свойства и попросить систему проверить, удовлетворяет ли модель свойству или нет.

def tradeGeneration(market: Market, broker: Account, clientAccounts: List[Account]) =
  // client orders           executed at market by broker        & allocated to client accounts
  kleisli(clientOrders) >=> kleisli(execute(market)(broker)) >=> kleisli(allocate(clientAccounts)
property("Enrichment should mean netValue equals principal + taxes") {
  forAll((a: Trade) => {
    val et = enrichTrade(a)
    et.netAmount should equal (et.taxFees.map(_.foldLeft(principal(et))((a, b) => a + b._2)))
  })
}

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

Сквозная проверка свойств

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

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

def tradeGeneration(market: Market, broker: Account, clientAccounts: List[Account]) =
  // client orders           executed at market by broker        & allocated to client accounts
  kleisli(clientOrders) >=> kleisli(execute(market)(broker)) >=> kleisli(allocate(clientAccounts))

 Мы можем использовать спецификацию на основе свойств, чтобы протестировать весь этот жизненный цикл с помощью ScalaCheck. Давайте проверим один из инвариантов во всем этом процессе —

инвариант: «Общее количество ордера будет равно общему количеству проданных

товаров », и вот спецификация свойства в ScalaCheck.

property("Client trade allocation in the trade pipeline should maintain quantity invariant") {
  forAll { (clientOrders: List[ClientOrder], args: (Market, Account, List[Account])) =>
    whenever (clientOrders.size > 0 && args._2.size > 0 && args._3.size > 0) {
      val trades = tradeGeneration(args._1, args._2, args._3)(clientOrders)
      trades.size should be > 0
      (trades.sequence[({type λ[a]=Validation[NonEmptyList[String],a]})#λ, Trade]) match {
        case Success(l) => {
          val tradeQuantity = l.map(_.quantity).sum
          val orderQuantity = fromClientOrders(clientOrders).map(_.items).flatten.map(_.qty).sum
          tradeQuantity should equal(orderQuantity)
        }
        case _ => fail("should get a list of size > 0")
      }
    }
  }
}

 

Свойства являются правилами домена и могут быть подготовлены экспертами домена. Я твердо верю в совместное участие экспертов по предметной области в построении модели. Я выступал за использование предметно-ориентированных языков в качестве тонкой лингвистической абстракцииповерх модели предметной области, которая должна быть построена итеративно с экспертами предметной области. Тестирование на основе свойств — это третий этап, который завершает структуру решения для разработки полных моделей предметной области. Тестирование, основанное на свойствах, усиливает эту точку зрения о более активном участии доменных людей в создании программного обеспечения. В конце дня оставьте все для соответствующих экспертов — сотрудники домена задают свойства и инварианты, разработчик реализует спецификацию, а базовая структура тестирования выполняет тяжелую работу по генерации достаточного количества данных и автоматизации процесса выполнения.