Тестирование, если монада чтения вызывается в неправильной среде

У меня есть MonadReader, который генерирует данные для приложения, над которым я работаю. Основная монада здесь генерирует данные на основе некоторых переменных среды. Монада генерирует данные, выбирая одну из нескольких других монад для запуска в зависимости от среды. Мой код выглядит примерно так, как показано ниже, где mainMonad является основной монадой:

data EnvironmentData = EnvironmentA | EnvironmentB 

type Environment = (EnvironmentData, Integer)

mainMonad ::
  ( MonadReader Environment m
  , MonadRandom m
  )
    => m Type
mainMonad = do
  env <- ask
  case env of
    EnvironmentA -> monadA
    EnvironmentB -> monadB

monadA ::
  ( MonadReader Environment m
  , MonadRandom m
  )
    => m Type
monadA = do
  ...
  result <- helperA 
  result <- helper
  ...

monadB ::
  ( MonadReader Environment m
  , MonadRandom m
  )
    => m Type
monadB = do
  start <- local (set _1 EnvironmentA) monadA
  ...
  result  <- helper
  ...

helperA ::
  ( MonadReader Environment m
  , MonadRandom m
  )
    => m String
helperA = do
  ...

helper ::
  ( MonadReader Environment m
  , MonadRandom m
  )
    => m String
helper = do
  ...

Примечательные вещи здесь:

  • У нас есть главная монада (mainMonad), которая одновременно является и MonadReader Environment, и MonadRandom.
  • Основная монада обращается к подчиненным монадам monadA и monadB того же типа.
  • У нас есть четвертая монада, которая служит помощником для monadA и monadB.
  • monadB обращается к monadA (но использует local для изменения окружения)

Самое главное:

  • Всякий раз, когда вызывается monadA или helperA, EnvironmentData - это EnvironmentA, а всякий раз, когда monadB вызывается, EnvironmentData - это EnvironmentB.

Моя кодовая база в значительной степени представляет собой увеличенную версию этого. Есть больше подчиненных монад (на данный момент их 12, но, вероятно, их будет больше в будущем), больше помощников, и мой тип EnvironmentData немного сложнее (хотя мой Environment почти идентичен).

Последний пункт важен, потому что EnvironmentData используется в хелперах, а неправильное Environment приведет к незначительным изменениям в результатах хелперов.

Теперь моя проблема заключается в том, что довольно легко пропустить local в моем коде и просто вызвать монаду напрямую с неправильной средой. Я также боюсь вызывать монаду без использования local, потому что я думаю, что она ожидает среду, которой не является. Это крошечные и легкие ошибки (я делал это уже несколько раз), и результаты этого часто довольно тонкие и довольно разнообразные. Это приводит к тому, что симптомы проблемы довольно трудно обнаружить с помощью модульного тестирования. Поэтому я хотел бы нацелиться на проблему напрямую. Моим первым побуждением было добавить в модульный тест пункт, который говорит что-то вроде:

Вызовите mainMonad и убедитесь, что в ходе его оценки у нас никогда не было вызова монады с неправильным окружением.

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

1. Жесткий сбой при вызове с неправильной средой

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

monadA ::
     ( MonadReader m
     )
       => m Type
    monadA = do
      env <- view _1  ask
      case env of
        EnvironmentA -> return ()
        _ -> undefined
      ...

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

2. Используйте безопасность типов

Я также попытался изменить типы monadA и monadB, чтобы monadA нельзя было вызвать напрямую из monadB или наоборот. Это очень удобно, поскольку обнаруживает проблемы во время компиляции. У этого есть проблема, связанная с тем, что это немного больно поддерживать, и это довольно сложно. Поскольку monadA и monadB могут иметь несколько общих монад типа (MonadReader m) => m Type, каждая из них также должна быть поднята. На самом деле это в значительной степени гарантирует, что каждая линия теперь имеет лифт. Я не против решений, основанных на типах, но я не хочу тратить много времени только на поддержку модульного теста.

3. Переместите локальные жители внутрь объявления

Каждая монада с ограничением на EnvironmentData может начинаться с шаблона, похожего на:

monadA ::
  ( MonadReader Environment m
  , MonadRandom m
  )
    => m Type
monadA = do
  env <- view _1 <$> ask
  case env of
    EnvironmentA ->
      ...
    _ ->
      local (set _1 EnvironmentA) monadA

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

3.5. Удалите EnvironmentData

Этот в основном эквивалентен последнему, хотя, возможно, немного чище. Если я изменю тип monadA и monadB на

( MonadReader Integer m
, MonadRandom m
)
  => m Type

затем добавьте оболочку, используя runReaderT withReaderT (как предложено Даниэлем Вагнером ниже) для вызовов, поступающих от и к мои MonadReader Environmentс. Я не могу вызвать их с неправильным EnvironmentData, так как нет данных среды. У этого есть в значительной степени точные проблемы последних.


Итак, есть ли способ убедиться, что мои монады всегда вызываются из правильной среды?


person Éamonn Olive    schedule 22.07.2019    source источник
comment
Вы также можете взглянуть на withReaderT, хотя это потребует от вас выбора определенного стека монад (или, по крайней мере, его внешних битов до ReaderT), а не использования полиморфизма классов в стиле mtl.   -  person Daniel Wagner    schedule 23.07.2019
comment
Из того, что вы описали, решение на основе типов, очевидно, является правильным подходом и не должно требовать от вас отмены каждого действия (например, ответ @DanielWagner). Если вы найдете его ответ неудовлетворительным, я подозреваю, что мы можем предложить что-то еще лучше, но ваш минимальный пример слишком абстрактен и слишком псевдокодирован (например, эти сигнатуры типов недействительны с обычным определением MonadReader). Можете ли вы опубликовать несколько более реалистичный пример игрушки, который компилирует и иллюстрирует то, что вы пытаетесь сделать?   -  person K. A. Buhr    schedule 23.07.2019
comment
@K.A.Buhr Спасибо за отзыв. Я сделал вещи немного более конкретными (я также исправил свои подписи типов, это была просто ошибка с моей стороны). Я могу сделать его более реалистичным или даже дополнить определенные части, если кто-то захочет, я просто никогда не уверен, сколько излишних дел с такими вещами.   -  person Éamonn Olive    schedule 23.07.2019
comment
Насколько важно для вас использовать в этом приложении ограничения монад (например, MonadReader), а не конкретные типы монад? Я знаю, что некоторые люди считают это лучшей практикой, но есть ли у вас реальная необходимость запускать этот код полиморфно над несколькими конкретными монадами?   -  person K. A. Buhr    schedule 25.07.2019
comment
@K.A.Buhr К сожалению, на самом деле очень важно, чтобы я использовал MonadRandom, а не конкретную среду. Это связано с тем, что обработчик теста использует Gen в качестве экземпляра, а само приложение использует IO.   -  person Éamonn Olive    schedule 25.07.2019


Ответы (3)


Хотя это кажется немного странным, я полагаю, что одним из способов было бы ввести избыточный ReaderT:

 data EnvironmentA -- = ...
 data EnvironmentB -- = ...

 convertAToB :: EnvironmentA -> EnvironmentB
 convertBToA :: EnvironmentB -> EnvironmentA
 -- convertAToB = ...
 -- convertBToA = ...

 monadA :: MonadReader EnvironmentA m => m Type
 monadA = do
     env <- ask
     -- ...
     res <- runReaderT monadB (convertAToB env)
     -- ...

 monadB :: MonadReader EnvironmentB m => m Type
 monadB = do
     env <- ask
     -- ...
     res <- runReaderT monadA (convertBToA env)
     -- ...
person Daniel Wagner    schedule 22.07.2019

Ваш пример слишком упрощен, чтобы я мог судить, насколько хорошо это применимо, но вы также можете получить его, сделав параметризованным тип Environment. Может быть, GADT, что-то вроде:

data Environment t where
    EnvironmentA :: Environment A
    EnvironmentB :: Environment B

data A
data B

Затем код, который заботится о том, в какой конкретной среде он работает, может иметь ограничение MonadReader (Environment A) m или MonadReader (Environment B) m, а код, который работает с обоими, может использовать ограничение MonadReader (Environment t) m.

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

person Carl    schedule 23.07.2019
comment
С практической точки зрения, чем это отличается от наличия двух типов, EnvironmentA и EnvironmentB, как в моем ответе? Кажется, что возможность написать MonadReader (Environment t) m - единственное отличие, которое вы упомянули, но ни одно из действий, предложенных в вопросе, не может быть присвоено этому типу, так что действительно ли это различие имеет значение? - person Daniel Wagner; 23.07.2019
comment
@DanielWagner, как я уже сказал, трудно судить с такой упрощенной настройкой. Но если бы существовало другое содержимое среды, в частности то, что было бы общим для типов, это бы очень помогло. - person Carl; 23.07.2019
comment
@SriothilismO'Zaic Я не понимаю, как это чему-то мешает. Конечно, типы должны быть где-то закреплены. Речь идет о том, чтобы позволить различным помощникам иметь более узкие типы, чтобы вы могли правильно их собрать. - person Carl; 23.07.2019
comment
Сначала я неправильно понял, что здесь предлагается. Когда я увидел комментарий Даниэля Вагнера, я намного лучше его понял. - person Éamonn Olive; 23.07.2019

Вот подход, который я бы выбрал. Согласно ответу @Carl, я бы различал среды «A» и «B» на уровне типа, используя GADT, параметризованный «тегом» типа. Использование пары пустых типов для тега (data A и data B, как это сделал @Carl) работает, хотя я предпочитаю использовать DataKinds, потому что это делает намерение более ясным.

Вот предварительные сведения:

{-# OPTIONS_GHC -Wall -Wincomplete-uni-patterns #-}
{-# LANGUAGE DataKinds, GADTs, KindSignatures #-}

import Control.Monad.Reader
import Control.Monad.Random

и вот определение типа среды:

data EnvType = A | B
data Environment (e :: EnvType) where
  EnvironmentA :: Integer -> Environment 'A
  EnvironmentB :: Integer -> Environment 'B

Здесь разные среды имеют одинаковую внутреннюю структуру (т. е. каждая из них содержит Integer), но это не обязательно.

Я собираюсь сделать упрощающее предположение, что ваша монада всегда имеет среду ReaderT в качестве самого внешнего слоя, но мы будем поддерживать полиморфизм в базовой монаде (так что вы можете использовать IO или Gen для обеспечения случайности). Вы можете сделать все это, используя вместо этого ограничения MonadReader, но все усложняется по некоторым неясным техническим причинам (если вам действительно это нужно, добавьте комментарий, и я постараюсь опубликовать дополнительный ответ). То есть для произвольной базовой монады b будем работать в монаде:

type E e b = ReaderT (Environment e) b

Теперь мы можем определить действие mainMonad следующим образом. Обратите внимание на отсутствие ограничения MonadReader, так как об этом заботится подпись E e b Type. Ограничение MonadRandom b базовой монады гарантирует, что E e b будет иметь экземпляр MonadRandom. Поскольку сигнатура E e b Type полиморфна в e :: EnvType, mainMonad может работать с любым типом среды. Путем сопоставления регистра в среде GADT он может ввести ограничения e ~ 'A и т. д. в область видимости, что позволит отправлять его в monadA и т. д.

data Type = Type [String]  -- some return type

mainMonad ::
  ( MonadRandom b )
    => E e b Type
mainMonad = do
  env <- ask
  case env of
    EnvironmentA _ -> monadA
    EnvironmentB _ -> monadB

Сигнатуры типов для monadA и monadB похожи, хотя они исправляют EnvType:

monadA ::
  ( MonadRandom b )
    => E 'A b Type
monadB ::
  ( MonadRandom b )
    => E 'B b Type

Действие monadA может вызывать специфичное для A helperA, а также обычное helper:

monadA = do
  result1 <- helperA
  result2 <- helper
  return $ Type [result1,  result2]

Помощники могут использовать средства MonadRandom и проверять среду, используя такие функции, как getData, которые соответствуют регистру в среде.

helperA :: (Monad b) => E 'A b String -- we don't need MonadRandom on this one
helperA = do
  n <- asks getData
  return $ show n
helper :: (MonadRandom b) => E e b String
helper = do
  n <- asks getData
  x <- getRandomR (0,n)
  return $ show x
getData :: Environment e -> Integer
getData (EnvironmentA x) = x
getData (EnvironmentB x) = x

Также возможно непосредственное сопоставление регистра в среде. В обычном помощнике необходимо обрабатывать все типы среды, но в помощнике, специфичном для EnvType, нужно обрабатывать только тот EnvType (т. е. совпадение с шаблоном будет исчерпывающим, поэтому даже при -Wall не будет генерироваться предупреждение о несовпадающих случаях). ):

helper2 :: (Monad b) => E e b String
helper2 = do
  env <- ask
  case env of
    -- all cases must be handled or you get "non-exhaustive" warnings
    EnvironmentA n -> return $ show n ++ " with 'A'-appropriate processing"
    EnvironmentB n -> return $ show n ++ " with 'B'-appropriate processing"
helperA2 :: (Monad b) => E 'A b String
helperA2 = do
  env <- ask
  case env of
    -- only A-case need be handled, and trying to match B-case generates warning
    EnvironmentA n -> return $ show n

Действие monadB может вызывать общие помощники и может отправляться в monadA с соответствующим вызовом withReaderT.

monadB = do
  Type start <- withReaderT envBtoA monadA
  result <- helper
  return $ Type $ start ++ [result]

envBtoA :: Environment 'B -> Environment 'A
envBtoA (EnvironmentB x) = EnvironmentA x

Самое главное, конечно, что вы не можете случайно вызвать действие типа A из действия типа B:

badMonadB ::
  ( MonadRandom b )
    => E 'B b Type
badMonadB = do
  monadA  -- error: couldn't match A with B

вы также не можете случайно вызвать действие A-типа из универсального помощника:

-- this is a common helper
badHelper :: (Monad b) => E e b String
badHelper = do
  -- so it can't assume EnvironmentA is available
  helperA  -- error: couldn't match "e" with B

хотя вы можете использовать сопоставление регистра, чтобы проверить подходящую среду, а затем отправить:

goodHelper :: (Monad b) => E e b String
goodHelper = do
  env <- ask
  case env of
    EnvironmentA _ -> helperA  -- if we're "A", it's okay
    _              -> return "default"

Я чувствую, что должен указать на относительные преимущества и недостатки этого решения @DanielWagner (которое, я думаю, вы неправильно поняли).

Его решение:

  • обеспечивает безопасность типов. Если вы попытаетесь вставить res <- monadB в определение monadA, он не наберет проверку.
  • не предоставляет, как написано, механизм для определения общих вспомогательных функций, которые обращаются к среде (обычные вспомогательные функции, которым требуется только MonadRandom, будут работать нормально), но это можно сделать, введя класс типов с экземплярами для EnvironmentA и EnvironmentB, которые предоставляют методы для делать все, что обычно хелперы могут делать с окружением
  • требует специальной обработки среды в mainMonad (хотя есть некоторые вопросы о том, зачем вам вообще нужен mainMonad)
  • избегает сложного обмана на уровне типов, поэтому с ним может быть проще работать
  • Я полагаю, что добавляет дополнительный слой ReaderT при каждом переходе среды, поэтому может возникнуть штраф во время выполнения, если существует глубокая рекурсивная вложенность A-to-B-to-A-to-B.

Чтобы увидеть их рядом, вот мое полное решение:

{-# OPTIONS_GHC -Wall -Wincomplete-uni-patterns #-}
{-# LANGUAGE DataKinds, GADTs, KindSignatures #-}

import Control.Monad.Reader
import Control.Monad.Random

data EnvType = A | B
data Environment (e :: EnvType) where
  EnvironmentA :: Integer -> Environment 'A
  EnvironmentB :: Integer -> Environment 'B
getData :: Environment e -> Integer
getData (EnvironmentA x) = x
getData (EnvironmentB x) = x

type E e b = ReaderT (Environment e) b

data Type = Type [String]  -- some return type

mainMonad :: (MonadRandom b) => E e b Type
mainMonad = do
  env <- ask
  case env of
    EnvironmentA _ -> monadA
    EnvironmentB _ -> monadB

monadA :: (MonadRandom b) => E 'A b Type
monadA = do
  result1 <- helperA
  result2 <- helper
  return $ Type [result1,  result2]

monadB :: (MonadRandom b) => E 'B b Type
monadB = do
  Type start <- withReaderT envBtoA monadA
  result <- helper
  return $ Type $ start ++ [result]
envBtoA :: Environment 'B -> Environment 'A
envBtoA (EnvironmentB x) = EnvironmentA x

helperA :: (Monad b) => E 'A b String -- we don't need MonadRandom on this one
helperA = do
  n <- asks getData
  return $ show n

helper :: (MonadRandom b) => E e b String
helper = do
  n <- asks getData
  x <- getRandomR (0,n)
  return $ show x

а вот его вариант:

{-# OPTIONS_GHC -Wall -Wincomplete-uni-patterns #-}
{-# LANGUAGE FlexibleContexts #-}

import Control.Monad.Reader
import Control.Monad.Random

data EnvType = A | B
data EnvironmentMain = EnvironmentMain EnvType Integer
data EnvironmentA = EnvironmentA Integer
data EnvironmentB = EnvironmentB Integer

class Environment e where getData :: e -> Integer
instance Environment EnvironmentA where getData (EnvironmentA n) = n
instance Environment EnvironmentB where getData (EnvironmentB n) = n

convertAToB :: EnvironmentA -> EnvironmentB
convertAToB (EnvironmentA x) = EnvironmentB x
convertBToA :: EnvironmentB -> EnvironmentA
convertBToA (EnvironmentB x) = EnvironmentA x

data Type = Type [String]  -- some return type

mainMonad :: (MonadReader EnvironmentMain m, MonadRandom m) => m Type
mainMonad = do
  env <- ask
  case env of
    EnvironmentMain A n -> runReaderT monadA (EnvironmentA n)
    EnvironmentMain B n -> runReaderT monadB (EnvironmentB n)

monadA :: (MonadReader EnvironmentA m, MonadRandom m) => m Type
monadA = do
  result1 <- helperA
  result2 <- helper
  return $ Type $ [result1] ++ [result2]

monadB :: (MonadReader EnvironmentB m, MonadRandom m) => m Type
monadB = do
  env <- ask
  Type start <- runReaderT monadA (convertBToA env)
  result <- helper
  return $ Type $ start ++ [result]

helperA :: (MonadReader EnvironmentA m) => m String
helperA = do
  EnvironmentA n <- ask
  return $ show n

helper :: (Environment e, MonadReader e m, MonadRandom m) => m String
helper = do
  n <- asks getData
  x <- getRandomR (0,n)
  return $ show x
person K. A. Buhr    schedule 25.07.2019