Testy jednostkowe i mockowanie wyścia-wejścia w Haskellu

8 minut(y)

Testy pisać trzeba, to oczywiste. Pozostaje pytanie jednostkowe czy integracyjne? Ja na razie zdecydowałem się na jednostkowe, co postawiło mnie przed kolejnym pytaniem jak przetestować kod używający wyjścia-wejścia w Haskellu? To znowu sprowadza się do pytania jak zamockować monadę IO w Haskellu?

Poglądowo spójrzmy na kod zawierający odczyt i zapis ze standardowych strumieni napisany w języku Haskell:

pipe :: IO ()
pipe = do
  char <- getChar
  putChar char
  pipe

Ten program to proste echo. Odczytuje jeden znak ze standardowego wejścia i wypisuje go na standardowe wyjście, po czym ponawia w nieskończoność za pomocą rekurencji. Za odczyt odpowiedzialna jest funkcja getChar a za wypisanie - putChar. Co ciekawe na EsoLangs nazywają taki program Cat.

Ponieważ nie ma tu za wiele do testowania, spójrzmy na bardziej skomplikowaną wersję.

filterIf0 :: IO ()
filterIf0 = do
  char <- getChar
  if char == '0'
    then putChar '\n'
    else do
      putChar char
      filterIf0

Tutaj odczyt jest przerywany, jeśli zostanie wczytany znak ‘0’. Dodatkowo jest na końcu dodawany znak nowej linii dla lepszego działania w konsoli.

Niestety powyższy kod jest nietestowalny w sposób jednostkowy. Dla testów jednostkowych najlepiej by było, gdyby kod wyglądał następująco:

listFilterIf0 :: [Char] -> [Char]
listFilterIf0 []          = []
listFilterIf0 (char:rest) =
  if char == '0'
    then ['\n']
    else char : listFilterIf0 rest

Niestety takiego kodu nie można używać w sposób interaktywny.

Funkcje wyższego rzędu (ang. Higher-order functions)

Rozwiązaniem jest przekazywanie funkcji odczytujących i zapisujących jako parametry. Dzięki czemu dla testów będziemy mogli przekazać zamockowaną implementację.

Najpierw sprawdźmy jaki typ mają funkcje które nas interesują:

getChar :: IO Char
putChar :: Char -> IO ()

Następnie utwórzmi aliasy dla tych typów:

type IOGetChar = IO Char
type IOPutChar = Char -> IO ()

Nasz produkcyjny kod będzie wyglądać następująco:

ioFilterIf0 :: IO ()
ioFilterIf0 = ioFilterIf0' getChar putChar

A funkcja, którą będziemy testować jednostkowy, będzie miała postać:

ioFilterIf0' :: IOGetChar -> IOPutChar -> IO ()
ioFilterIf0' ioGetChar ioPutChar = do
  char <- ioGetChar
  if char == '0'
    then ioPutChar '\n'
    else do
      ioPutChar char
      ioFilterIf0' ioGetChar ioPutChar

Niestety powyższy kod dalej jest nietestowalny jednostkowo. Ponieważ nie jesteśmy w stanie napisać zamockowanych implementacji getChar i putChar.

Kod zależny od interfejsu, a nie od implementacji

Na początek musimy rozluźnić trochę typy, żeby zależały od interfejsu a nie od implementacji.

type MGetChar m = m Char
type MPutChar m = Char -> m ()

W naszym przypadku interfejsem jest klasa typów Monad czyli [monada], a implementacją - monada IO.

Teraz nasza funkcja, którą będziemy, testować wygląda następująco:

mFilterIf0 :: Monad m => MGetChar m -> MPutChar m -> m ()
mFilterIf0 mGetChar mPutChar = do
  char <- mGetChar
  if char == '0'
    then mPutChar '\n'
    else do
      mPutChar char
      mFilterIf0 mGetChar mPutChar

Kod produkcyjny dużo się nie zmienił:

ioMFilterIf0 :: IO ()
ioMFilterIf0 = mFilterIf0 getChar putChar

Testy jednostkowe i monada State

Na potrzeby testów potrzebujemy strukturę, która będzie zastępować wejście-wyjście:

data MockIO = MockIO { input :: String, output :: String }
  deriving (Eq, Show)

createMockIO :: String -> MockIO
createMockIO input = MockIO (input) []

getOutput :: MockIO -> String
getOutput (MockIO input output) = reverse output

Następnie możemy stworzyć nasze zamockowane funkcje:

mockGetChar :: MockIO Char
mockGetChar = do
  state <- get
  let char = head $ input state
  put $ state { input = tail $ input state }
  return char

mockPutChar :: Char -> MockIO ()
mockPutChar char = do
  state <- get
  put $ state { output = char : output state }

Przyda się też funkcja konwertująca naszą monadę State na funkcję String -> String:

execMockIO :: MockIO () -> String -> String
execMockIO mockIO input = getOutput $ execState mockIO $ createMockIO input

Ostatecznie nasz test wygląda następująco:

testsOfFilterIf0 :: Test
testsOfFilterIf0 = test
  [ "testFilter0"  ~: "test FilterIf0"  ~: "qwerty\n" ~=? execMockIO (mFilterIf0 mockGetChar mockPutChar) "qwerty0uiop"
  ]

Klasy typów

W tym przykładzie mieliśmy do zamokowania tylko dwie funkcje. Jeśli jednak byłoby ich więcej, np. sześć jak poniżej, mogłoby to zacząć być problematyczne. Na szczęście w Haskellu można grupować funkcje za pomocą interfejsów a dokładniej za pomocą [klas typów].

class Monad m => WrapperIO m where
  wGetChar  :: m Char
  wPutChar  :: Char -> m ()
  wGetLine  :: m String
  wPutStr   :: String -> m ()
  wPutStrLn :: String -> m ()
  wFlush    :: m ()
  wPutStrLn s = wPutStr $ s ++ "\n"
  wFlush = return ()

Implementacja produkcyjna:

instance WrapperIO IO where
  wGetChar  = getChar
  wPutChar  = putChar
  wGetLine  = getLine
  wPutStr   = putStr
  wPutStrLn = putStrLn
  wFlush    = hFlush stdout

Implementacja zamockowana (na potrzeby testów):

instance WrapperIO MockIO where
  wGetChar = mockGetChar
  wPutChar = mockPutChar
  wGetLine = mockGetLine
  wPutStr  = mockPutStr

Kod produkcyjny:

main :: IO ()
main = do
  putStrLn "Hello, Eta!"
  ioWFilterIf0

Nowe testy nie różnią się wiele od poprzednich. Jednak tym razem nie musimy przekazywać funkcji jako parametrów:

testsOfFilterIf0 :: Test
testsOfFilterIf0 = test
  [ "testWFilter0" ~: "test WFilterIf0" ~: "qwerty\n" ~=? execMockIO wFilterIf0 "qwerty0uiop"
  ]

Podsumowanie

Nie chciałem poznawać monady State tak wcześnie podczas mojej nauki Haskella, ale zostałem do tego zmuszony przez chęć napisania testów jednostkowych do mojego interpretera. Monada State umożliwia modyfikowanie zmiennych w języku który raczej słynie z niemodyfikowalnych zmiennych. Myślę, że tej monady nie należy nadużywać.

Kod jest dostępny na GitHubie.