Тестирование функций в Haskell, которые делают IO

Работаем в реальном мире на Haskell прямо сейчас. Вот решение очень раннего упражнения в книге:

-- | 4) Counts the number of characters in a file
numCharactersInFile :: FilePath -> IO Int
numCharactersInFile fileName = do
    contents <- readFile fileName
    return (length contents)

Мой вопрос: как бы вы протестировали эту функцию? Есть ли способ сделать "ложный" ввод вместо того, чтобы фактически взаимодействовать с файловой системой, чтобы проверить его? Haskell делает такой акцент на чистых функциях, что я должен представить, что это легко сделать.

5 ответов

Решение

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

Вот пример того, как вы можете добиться того, что вы пытаетесь сделать, и это выглядит как фальшивое поведение *:

import Test.QuickCheck
import Test.QuickCheck.Monadic(monadicIO,run,assert)
import System.Directory(removeFile,getTemporaryDirectory)
import System.IO
import Control.Exception(finally,bracket)

numCharactersInFile :: FilePath -> IO Int
numCharactersInFile fileName = do
    contents <- readFile fileName
    return (length contents)

Теперь предоставим альтернативную функцию (Тестирование на модели):

numAlternative ::  FilePath -> IO Integer
numAlternative p = bracket (openFile p ReadMode) hClose hFileSize

Укажите произвольный экземпляр для тестовой среды:

data TestFile = TestFile String deriving (Eq,Ord,Show)
instance Arbitrary TestFile where
  arbitrary = do
    n <- choose (0,2000)
    testString <- vectorOf n $ elements ['a'..'z'] 
    return $ TestFile testString

Тестирование свойства в отношении модели (с использованием быстрой проверки монадического кода):

prop_charsInFile (TestFile string) = 
  length string > 0 ==> monadicIO $ do
    (res,alternative) <- run $ createTmpFile string $
      \p h -> do
          alternative <- numAlternative p
          testRes <- numCharactersInFile p
          return (testRes,alternative)
    assert $ res == fromInteger alternative

И маленькая вспомогательная функция:

createTmpFile :: String -> (FilePath -> Handle -> IO a) -> IO a
createTmpFile content func = do
      tempdir <- catch getTemporaryDirectory (\_ -> return ".")
      (tempfile, temph) <- openTempFile tempdir ""
      hPutStr temph content
      hFlush temph
      hClose temph
      finally (func tempfile temph) 
              (removeFile tempfile)

Это позволит quickCheck создать несколько случайных файлов для вас и проверить вашу реализацию с помощью функции модели.

$ quickCheck prop_charsInFile 
+++ OK, passed 100 tests.

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


* Примечание по поводу использования термина " фиктивное поведение":
Термин mock в объектно-ориентированном смысле, возможно, не самый лучший здесь. Но какова цель насмешки?
Это позволит вам протестировать код, которому нужен доступ к ресурсу, который обычно

  • либо недоступен во время тестирования
  • или не легко контролируется и, следовательно, не легко проверить.

Снимая ответственность за предоставление такого ресурса на быструю проверку, внезапно становится возможным обеспечить среду для тестируемого кода, которую можно проверить после запуска теста.
Мартин Фаулер хорошо описывает это в статье о издевательствах:
"Моты - это... объекты, предварительно запрограммированные с ожиданиями, которые формируют спецификацию вызовов, которые они ожидают получить".
Для настройки быстрой проверки я бы сказал, что файлы, сгенерированные в качестве входных данных, "запрограммированы" так, что мы знаем об их размере (== ожидание). И затем они проверяются по нашей спецификации (== свойство).

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

class Monad m => FSMonad m where
    readFile :: FilePath -> m String

-- | 4) Counts the number of characters in a file
numCharactersInFile :: FSMonad m => FilePath -> m Int
numCharactersInFile fileName = do
    contents <- readFile fileName
    return (length contents)

Позже вы можете запустить его в IO:

instance FSMonad IO where
    readFile = Prelude.readFile

И проверить это тоже:

data MockFS = SingleFile FilePath String

instance FSMonad (State MockFS) where 
               -- ^ Reader would be enough in this particular case though
    readFile pathRequested = do
        (SingleFile pathExisting contents) <- get
        if pathExisting == pathRequested
            then return contents
            else fail "file not found"


testNumCharactersInFile :: Bool
testNumCharactersInFile = evalState
                                (numCharactersInFile "test.txt") 
                                (SingleFile "test.txt" "hello world")
                             == 11

Как видите, таким образом ваш тестируемый код требует наименьшего количества изменений.

Полный код можно найти здесь: http://hpaste.org/51210

Для этого вам нужно будет изменить функцию так, чтобы она стала:

numCharactersInFile :: (FilePath -> IO String) -> FilePath -> IO Int
numCharactersInFile reader fileName = do
                         contents <- reader fileName
                         return (length contents)

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

fakeFile :: FilePath -> IO String
fakeFile fileName = return "Fake content"

и передать эту функцию numCharactersInFile,

Функция состоит из двух частей: нечистый (чтение содержимого части в виде строки) и чистый (вычисление длины строки).

Нечистая часть не может быть проверена на "единицу" по определению. Чистая часть - это просто вызов функции библиотеки (и, конечно, вы можете проверить ее, если хотите:)).

Так что в этом примере нечего высмеивать и ничего тестировать.

Поместите это по-другому. Представьте себе, что у вас есть равная реализация на C++ или Java (*): чтение содержимого, а затем вычисление его длины. Что бы вы на самом деле хотели высмеять и что осталось бы для тестирования после этого?


(*) Это, конечно, не то, что вы будете делать в C++ или Java, но это оффтоп.

Основываясь на понимании Хаскеллом моего непрофессионала, я пришел к следующим выводам:

  1. Если функция использует монаду ввода-вывода, пробное тестирование будет невозможно. Избегайте жесткого кодирования монады ввода-вывода в вашей функции.

  2. Сделайте вспомогательную версию вашей функции, которая принимает другие функции, которые могут выполнять IO. Результат будет выглядеть так:

numCharactersInFile' :: Monad m => (FilePath -> m String) -> FilePath -> m Int
numCharactersInFile' f filePath = do
    contents <- f filePath
    return (length contents)

numCharactersInFile' теперь тестируем с насмешками!

mockFileSystem :: FilePath -> Identity String
mockFileSystem "fileName" = return "mock file contents"

Теперь вы можете проверить, что numCharactersInFile'возвращает ожидаемые результаты без ввода-вывода:

18 == (runIdentity .  numCharactersInFile' mockFileSystem $ "fileName")

Наконец, экспортируйте версию вашей оригинальной сигнатуры функции для использования с IO

numCharactersInFile :: IO Int
numCharactersInFile = NumCharactersInFile' readFile

Итак, в конце дня numCharactersInFile'можно тестировать с помощью макетов. numCharactersInFile - это всего лишь разновидность numCharactersInFile'.

Другие вопросы по тегам