Home

24 dias de Hackage, 2015 - dia 15 - IOSpec: Testando IO e algumas dicas para o QuickCheck

por Franklin Chen

traduzido por Pedro Yamada

Esse é um artigo escrito por Franklin Chen e traduzido para o português. Ler original.

Índice de toda a série

O índice de toda a série está no topo do artigo para o dia 1.

Encontro HaskellBR São Paulo

Vamos nos encontrar em São Paulo em 25 de Janeiro de 2016. Marque sua presença e comente se não puder vir.

Dia 15

No dia 11 mencionei tangencialmente me sentir envergonhado de que ao contrário dos meus outros exemplos, porque estava usando IO, eu não comecei escrevendo testes de cara. Eu não queria desviar do tópico em questão, que não tinha nada a ver com IO mas com combinadores monádicos genéricos.

Agora estou de volta para preencher esse vazio, mostrando uma forma de “mockar” o IO, a biblioteca IOSpec. Não é uma solução perfeita e gostaria que houvesse uma forma padrão de testar IO no ecossistema do Haskell, ao invés dos muitos métodos diferentes disponíveis por aí (comumente inventados de novo). Mas pelo menos ela dá um gostinho de uma forma de atacar o problema.

Ironicamente, eu encontrei e consertei um bug no dia 11 no processo de escrever um teste para ele! As vezes, eu acho que sou muito asarado, porque quando faço edições de último minúto no código sem testes (nesse caso transformar uma expressão print em duas), eu inevitavelmente introduzo bugs. Isso é precisamente porque eu gosto de escrever testes. Eu não confio em mim mesmo sem testes.

O código do dia 11 para testar

Abaixo está uma cópia da descrição introdutória da tarefa no dia 11, com a primeira versão do código em Haskell. Nós vamos testar essa primeira versão.

Vamos escrever uma função para simular um login, no qual:

logIn :: IO ()
logIn = do
  putStrLn "% Enter password:"
  go
  putStrLn "$ Congratulations!"

  where
    -- Use recursion for loop
    go = do
      guess <- getLine
      if guess /= "secret"
        then do
          putStrLn "% Wrong password!"
          putStrLn "% Try again:"
          go
        else
          return ()

Mudanças necessárias para usar o IOSpec

Copiei todo o módulo do dia 11 para um novo módulo IOSpecExample.hs, com algumas modificações:

Modando o nome do módulo

-- | Copied with modification from MonadLoopsExample
module IOSpecExample where

Escondendo e adicionando imports relacionados a IO

import Prelude hiding (getLine, putStrLn)
import Test.IOSpec (IOSpec, Teletype, getLine, putStrLn)

Mudando assinaturas de tipo

Nós mudamos IO a para IOSpec Teletype a em todos os lugares, para configurar o uso do simulador “teletype” para ler e imprimir caracteres, já que acontece que nosso logIn só usa essas operações. Se usássemos outras operações, há outros simuladores disponíveis e o IOSpec usa um mecânismo de tipo de “tipos de dados a la carte” para encontrar e misturar soluções.

Como IOSpec funciona

O IOSpec funciona fazendo tudo contruir uma estrutura de dados que é então rodada com um Scheduler para produzir um Effect. Nós usamos evalIOSpec:

evalIOSpec :: Executable f => IOSpec f a -> Scheduler -> Effect a

Nós (os testadores) podemos então interpretar o Effect da forma que quisermos. Nós vamos só usar um “scheduler” básico single-threaded para esse exemplo.

O teste

Para o nosso teste, nós vamos usar o QuickCheck para gerar input randômico do usuário. Como estamos simulando um teletype, nós assumimos que o usuário entra com uma stream (que representamos como uma lista) de strings que não contém quebras de linha. Nós queremos verificar que uma delas é a palavra "secret", então o output total deve ser só uma prompt introdutória, o número de prompts mostradas se o input estiver incorreto e o “parabéns” no final.

Aqui está o spec, com código auxiliar discutido abaixo:

{-# LANGUAGE ScopedTypeVariables #-}

module IOSpecExampleSpec where

import IOSpecExample (logIn)
import qualified Test.IOSpec as IO

import Test.Hspec (Spec, hspec, describe)
import Test.Hspec.QuickCheck (prop)
import Test.QuickCheck
import Data.Coerce (coerce)

-- | Required for auto-discovery.
spec :: Spec
spec =
  describe "IOSpec" $ do
    prop "logIn outputs prompts until secret is guessed" $
      \(notSecretLines :: [NotSecretString]) (anyLines :: [NotNewlineString]) ->
      let allLines = coerce notSecretLines
                     ++ ["secret"]
                     ++ coerce anyLines
          outputLines = ["% Enter password:"]
                        ++ concatMap
                           (const [ "% Wrong password!"
                                  , "% Try again:"])
                           notSecretLines
                        ++ ["$ Congratulations!"]
      in takeOutput (withInput (unlines allLines)
                               (IO.evalIOSpec logIn IO.singleThreaded))
         == unlines outputLines

newtypes para controlar o QuickCheck

Nós estamos usando um macete padrão do QuickCheck para gerar geradores randômicos de um tipo existênte para um domínio específico.

Um dos nossos tipos é NotSecretString, representando uma linha digitada pelo o usuário que não é igual a "secret"; ela também não deve conter uma quebra de linha. A instância de Arbitrary do QuickCheck é só boilerplate para manter as invariantes desejadas.

-- | User input without a newline, and not equal to "secret".
newtype NotSecretString =
  NotSecretString { getNotSecretString :: NotNewlineString }
  deriving (Show)

instance Arbitrary NotSecretString where
  arbitrary = NotSecretString <$>
              arbitrary `suchThat` ((/= "secret") . coerce)
  shrink = map NotSecretString
              . filter ((/= "secret") . coerce)
              . shrink
              . getNotSecretString

Nós refinamos ainda mais até o nível do Char, para gerar NotNewlineChar:

type NotNewlineString = [NotNewlineChar]

newtype NotNewlineChar =
  NotNewlineChar { getNotNewlineChar :: Char }
  deriving (Show)

-- | Quick hack. Ideally should write specific generator rather than
-- filtering off the default 'Char' generator.
instance Arbitrary NotNewlineChar where
  arbitrary = NotNewlineChar <$>
              arbitrary `suchThat` (/= '\n')
  shrink = map NotNewlineChar
              . filter (/= '\n')
              . shrink
              . getNotNewlineChar

Uma nota sobre strings randômicas

Nós usamos um hack rápido para conseguir uma string “arbitrária”. Não é tão arbitrária, como você pode ver a partir do código-fonte do QuickCheck. Para gerar strings Unicode de qualidade, use o quickcheck-unicode.

Uma nota sobre coerce: é só um hack de otimização

Se ainda não viu coerce de Data.Coerce, você pode estar se perguntando do que se trata. É só um hack de eficiência para tomar vantagem da garantia do Haskell que uma representação newtype é idêntica ao tipo que contém. coerce resolve o problema de garantir que algo que contém algo que contém (algo que contém…) alguma coisa ainda é reconhecido como tendo a mesma representação. Para que não haja nenhum custo durante runtime de tratar o valor como sendo de um tipo diferente. É um hack feio mas conveniente. Se algo não pode ser forçado para essa forma, temos um erro de tipagem, então isso é seguro.

Se você não gosta de usar coerce, terá que usar muito map e unwrapping e rewrapping para converter entre todos os tipos diferentes, como:

notSecretStringsAsStrings :: [NotSecretString] -> [String]
notSecretStringsAsStrings = map notSecretStringAsString

notSecretStringAsString :: NotSecretString -> String
notSecretStringAsString = map getNotNewlineChar . getNotSecretString

Seria mais fácil ainda que menos embasado para mim só pensar “eles são todos só Char e [Char] por baixo dos panos”. Se alguém tem guias sobre quando usar o coerce e quando não, ficaria feliz em as linkar aqui (e mudar meu código como apropriado).

Interpretando um Effect

Nós interpretamos um Effect da mesma forma de um exemplo vindo da biblioteca. Nós convertemos Print e ReadChar da forma que você esperaria se estivesse gerando uma stream de caracteres ou lendo uma.

Receber output é direto, interpretando Done e Print:

takeOutput :: IO.Effect () -> String
takeOutput (IO.Done _) = ""
takeOutput (IO.Print c xs) = c : takeOutput xs
takeOutput _ = error "takeOutput: expects only Done, Print"

Receber input é mais difícil, porque o que está acontecendo de fato é que uma stream de caracteres de input é usada para converter um Effect em outro. O construtor ReadChar tem tipo Char -> Effect a e então quando chamado em um Char, retorna “a próxima coisa que acontece”.

withInput :: [Char] -> IO.Effect a -> IO.Effect a
withInput _ (IO.Done x) = IO.Done x
withInput stdin (IO.Print c e) = IO.Print c (withInput stdin e)
withInput (char:stdin) (IO.ReadChar f) = withInput stdin (f char)
withInput _ _ = error "withInput: expects only Done, Print, ReadChar"

Com esse teste, encontrei um bug no meu código, no qual o output não era o que eu esperava, por causa de um sinal de porcentagem que desapareceu depois de uma edição.

Se você já sabe sobre monads livres, você provavelmente queria me dizer “e quanto ao monad livre?” desde o começo do artigo, mas eu não queria entrar nisso ainda. Talvez depois.

Conclusão

Hoje eu apresentei uma forma de adicionar uma camada de abstração a operações IO de forma que possamos interpretar elas de outra forma além de apenas as realizar; para escrever e rodar testes.

Todo o código

Todo o código para a série estará nesse repositório do GitHub.


Nota do tradutor

Se você quer ajudar com esse tipo de coisa, agora é a hora. Entre no Slack ou no IRC da HaskellBR e contribua. Esse blog e outros projetos associados estão na organização haskellbr no GitHub e em haskellbr.com/git.

Há um milestone no GitHub com tarefas esperando por você..

Share Comente no Twitter