Тестирование функций в 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, но это оффтоп.
Основываясь на понимании Хаскеллом моего непрофессионала, я пришел к следующим выводам:
Если функция использует монаду ввода-вывода, пробное тестирование будет невозможно. Избегайте жесткого кодирования монады ввода-вывода в вашей функции.
Сделайте вспомогательную версию вашей функции, которая принимает другие функции, которые могут выполнять 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'.