Хорошо это или плохо, что набор тестов для быстрой проверки соответствует реализации?

Я пытаюсь начать работу с Haskell QuickCheck, и хотя я знаком с концепциями, лежащими в основе методологии тестирования, я впервые пытаюсь использовать его в проекте, который выходит за рамки тестирования таких вещей, как reverse . reverse == id и тому подобное. вещи. Я хочу знать, полезно ли применять его к бизнес-логике (я думаю, что очень даже может быть).

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

shouldDiscountProduct :: User -> Product -> Bool
shouldDiscountProduct user product =
  if M.isNothing (userDiscountCode user)
     then False
     else if (productDiscount product) then True
                                       else False

Для этой функции я могу написать спецификацию QuickCheck следующим образом:

data ShouldDiscountProductParams
  = ShouldDiscountProductParams User Product

instance Show ShouldDiscountProductParams where
  show (ShouldDiscountProductParams u p) =
    "ShouldDiscountProductParams:\n\n" <>
    "- " <> show u <> "\n\n" <>
    "- " <> show p

instance Arbitrary ShouldDiscountProductParams where
  arbitrary = ShouldDiscountProductParams <$> arbitrary <*> arbitrary

shouldDiscountProduct :: Spec
shouldDiscountProduct = it behavior (property verify)
  where
    behavior =
      "when product elegible for discount\n"
      <> " and user has discount code"

    verify (ShouldDiscountProductParams p t) =
      subject p t `shouldBe` expectation p t

    subject =
      SUT.shouldDiscountProduct

    expectation User{..} Product{..} =
      case (userDiscountCode, productDiscount) of
        (Just _, Just _) -> True
        _ -> False

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

shouldDiscountProduct User{..} Product{..} =
  case (userDiscountCode, productDiscount) of
    (Just _, Just _) -> True
    _ -> False

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

Или это перебор/двойная бухгалтерия? Я полагаю, что из ООП-тестирования я усвоил, что вы должны стараться избегать зеркалирования деталей реализации, насколько это возможно, это буквально не может быть дальше этого, это реализация!

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

Каков опыт использования тестирования на основе свойств для функций бизнес-логики? Есть ли хорошие ресурсы для такого рода вещей? Я думаю, я просто хочу убедиться, что я использую QC надлежащим образом, и это просто мое прошлое ООП вызывает у меня сомнения по этому поводу...


person danbroooks    schedule 20.09.2018    source источник
comment
Осмелюсь сказать, что бизнес-логика — одно из худших приложений для проверки на основе свойств, потому что она часто вопиющим образом игнорирует математическую доступность.   -  person leftaroundabout    schedule 31.10.2018


Ответы (3)


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

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

Dfeuer говорит вам, когда приемлемо отражать реализацию, но не говорит, что делать для вашего варианта использования.

Распространенной ошибкой в ​​тестах на основе свойств (PBT) является сначала переписывание кода реализации. Но это не то, для чего нужны PBT. Они существуют для проверки свойств вашей функции. Эй, не волнуйтесь, мы все делаем эту ошибку в первые несколько раз, когда пишем PBT :D

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

if SUT.shouldDiscountProduct p t 
then isJust (userDiscountCode p) && isJust (productDiscount t) 
else isNothing (userDiscountCode p) || isNothing (productDiscount t)

Это тонко в вашем конкретном случае использования, но обратите внимание, мы изменили логику. Ваш тест проверяет ввод и на его основе утверждает результат. Мой тест проверяет вывод и на его основе утверждает ввод. В других случаях использования это может быть гораздо менее симметрично. Большую часть кода также можно отрефакторить, я даю вам это упражнение ;)

Но вы можете найти и другие типы недвижимости! Например. инвариантность свойства:

SUT.shouldDiscountProduct p{userDiscountCode = Nothing} t == False
SUT.shouldDiscountProduct p{productDiscount = Nothing} t == False

Видишь, что мы здесь сделали? Мы зафиксировали одну часть ввода (например, код скидки пользователя всегда пуст) и утверждаем, что независимо от того, как все остальное меняется, вывод остается неизменным (всегда ложным). То же самое касается скидки на продукт.

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

shouldDiscountProduct user product =
  if M.isNothing (userDiscountCode user)
     then False
     else if (productDiscount product) then True
                                       else False

shouldDiscountProduct' user product
  | Just _ <- userDiscountCode user
  , Just _ <- productDiscount product
  = True
  | otherwise = False

SUT.shouldDiscountProduct p t = SUT.shouldDiscountProduct' p t

Что читается как «Независимо от ввода, переписанная функция всегда должна возвращать то же значение, что и старая функция». Это так здорово при рефакторинге!

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

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

person Sir4ur0n    schedule 05.03.2019

По сути, единственные случаи, когда проверка свойств имеет смысл сравнивать две реализации одной и той же функции, это когда:

  1. Обе функции являются частью API, и каждая из них должна реализовывать определенную функцию. Например, мы обычно хотим liftEq (==) = (==). Поэтому мы должны проверить, что liftEq для определяемого нами типа удовлетворяет этому свойству.

  2. Одна реализация заведомо правильная, но неэффективная, а другая эффективная, но не заведомо правильная. В этом случае набор тестов должен определить заведомо правильную версию и сверить с ней эффективную версию.

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

person dfeuer    schedule 31.10.2018

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

Чтобы решить эту проблему курицы и яйца, тесты строятся на следующих принципах:

  • Tests feed predefined inputs and check for predefined outputs. Nothing "random". All sources of randomness are considered additional inputs and mocked or otherwise forced to produce specific values.
    • Sometimes, a compromise is possible: you leave a random source alone and check the output not for exact value but just for "correctness" (e.g. that it has a specific format). But then you're not testing the logic that is responsible for the parts that you don't check (though you may not need to, see below).
  • Единственный способ полностью протестировать функцию — тщательно перепробовать все возможные входные данные.
  • Since this is almost always impossible, only a few "representative" ones are selected
    • And an assumption about the code is made that it handles all other possible inputs the same way
      • This is why test coverage metric is important: it will tell you when a code has changed in such a way that this assumption no longer holds

Чтобы выбрать оптимальный «репрезентативный» ввод, следуйте интерфейсу функции.

  • Если во входных данных есть несколько диапазонов, которые вызывают различное поведение, значения края обычно являются наиболее полезными.
  • Outputs are checked against the interface's promises
    • Sometimes, the interface doesn't promise a specific value for given inputs, variations are considered implementation details. Then you test not for a specific values but only what the interface guarantees.
      • Testing implementation details is only useful if other components rely on them -- then they are not really implementation details but parts of a separate, private interface.
person ivan_pozdeev    schedule 30.10.2018
comment
Этот ответ, по-видимому, относится к более традиционному подходу к тестированию в стиле модульного тестирования. Как ваш ответ относится к тестированию на основе свойств, например, в библиотеках быстрой проверки/подобных? - person danbroooks; 31.10.2018
comment
@danbroooks Я никогда раньше не слышал о тестировании на основе свойств, но это отличная возможность, наконец, изучить какой-то принципиально новый принцип. Из того, что я вижу сейчас, это некоторая логика ИИ, которая выполняет часть работы по выбору этих репрезентативных входных данных для вас, но общие принципы те же, проверяемые условия являются статическими и представляют собой интерфейс, а не реализацию. - person ivan_pozdeev; 31.10.2018