Złote testy w Haskellu
Zainspirowany wpisem o złotych testach na 4programmers postanowiłem dodać je do swojego projektu w Haskellu.
Dlaczego w ogóle złote testy (ang. golden tests)? Złote testy są dobre dla legacy projektów, gdzie nie wiemy, co zwrócą testowane funkcje. Ja, pisząc od początku nowy kod, powinienem dobrze wiedzieć co i kiedy może zostać zwrócone. Jednak tak nie jest. O ile tak jest dla prostych przypadków, o tyle dla długich fragmentów kodu w ezoterycznych asemblerach jak EAS czy WSA nie mam pojęcia co zostanie wygenerowane. Tutaj idealnie sprawdzają się złote testy.
Niestety HUnit nie wspiera złotych testów, ale już wcześniej byłem zdecydowany na migrację do frameworka testowego HSpec. Jednak nie sądziłem, że zmiana będzie od razu tak radykalna. Oprócz HSpec, także framework Taste pozwala na używanie złotych testów. Jednak zdecydowałem się na HSpec ponieważ:
- HSpec posiada automatyczne generowanie agregatora testów.
- HSpec ma zagnieżdżoną składnię
describe
/context
/it
, którą można ładnie wypaczać.
Jeśli jednak ktoś wolałby złote testy we frameworku Taste znalazłem dwa teksty poświęcone temu zagadnieniu:
A jako przykład użycia polecam projekt Husk Schema.
Złote testy w projekcie HelPA
Jako prosty przykład do testów wybrałem asembler EAS z projektu HelPA.
Asembler EAS składa się z czterech głównych modułów:
AsmParser
- frontend asemblera, który parsuje plik z językiem asemblerowym.Reducer
- frontend backendu asemblera, który redukuje skomplikowane instrukcje do prostych instrukcji.CodeGenerator
- właściwy backend asemblera, który generuje kod w języku ezoterycznym.Assembler
- moduł, który składa to wszystko razem.
A więc po kolej.
ReducerSpec, czyli parametryzowane testy
Moduł ReducerSpec
testuje funkcję Reducer.reduce
. Funkcją Reducer.reduce :: InstructionList -> InstructionList
zamienia listę instrukcji w zredukowaną listę instrukcji. Redukcja polega na zamianie wysokopoziomowych
instrukcji na ich niskopoziomowe
odpowiedniki możliwe do zapisania w języku ETA.
Test wygląda następująco:
module HelVM.HelPA.Assemblers.EAS.ReducerSpec where
import HelVM.HelPA.Assemblers.EAS.Reducer
import HelVM.HelPA.Assemblers.EAS.TestData
import Test.Hspec
spec :: Spec
spec = do
describe "reduce" $ do
forM_ [ ("true" , trueIL , trueIL)
, ("hello" , helloIL , helloIL)
, ("pip" , pipIL , pipILReduced)
, ("pip2" , pip2IL , pip2ILReduced)
, ("reverse" , reverseIL , reverseILReduced)
, ("function" , functionIL , functionIL)
, ("add" , addILLinked , addILReduced)
, ("writestr" , writeStrIL , writeStrILReduced)
, ("hello2" , hello2ILLinked , hello2ILReduced)
, ("hello4" , hello4ILLinked , hello2ILReduced)
, ("writenum" , writeNumILLinked , writeNumILReduced)
, ("multiply" , multiplyIL , multiplyILReduced)
, ("readnum" , readNumILLinked , readNumILReduced)
, ("fact" , factILLinked , factILReduced)
, ("bottles" , bottlesILLinked , bottlesILReduced)
, ("euclid" , euclidIL , euclidILReduced)
] $ \(fileName , ilLinked, ilReduced) -> do
it fileName $ do reduce ilLinked `shouldBe` ilReduced
Funkcja forM_
zamienia nam zwykłe testy na testy parametryczne.
Następnie mamy listę krotek. Pierwszy element krotki zawiera nazwę testu, drugi — listę instrukcji do zredukowania, trzeci — zredukowaną listę instrukcji.
Funkcja shouldBe
to asercja. Dzięki grawisom funkcja może być użyta jak operator. Bez grawisów trzeba by zapisać:
it fileName $ do shouldBe (reduce ilLinked) ilReduced
AsmParser, czyli czytanie danych testowych z pliku
Moduł AsmParserSpec
testuje funkcję AsmParser.parseAssembler
. Funkcja parseAssembler :: Text -> Parsed InstructionList
parsuje plik w języku EAS i zwraca listę instrukcji. Ponieważ parsowanie może się nie udać to lista instrukcji opakowana jest w typ Parsed
, który ma postać:
type Parsed a = Either String a
Ponieważ jednak nie będziemy pracować ze zmiennej typu Text
, a zmienną typu IO Text
to naszym ostatecznym typem do porównania będzie IO (Parsed InstructionList)
, czyli dokładniej IO (Either String InstructionList)
. Który dla wygody nazwiemy ParsedIO
:
type ParsedIO a = IO (Parsed a)
Biblioteka HSpec nie posiada oczywiście asercji dla typu IO (Either String a)
, ale posiada asercję shouldReturn
dla typu IO a
:
shouldReturn :: (HasCallStack, Show a, Eq a) => IO a -> a -> Expectation
Jedyne co musimy zrobić to tylko zamienić IO (Either String a)
na IO a
.
Najpierw zamieniamy Either String a
na IO a
eitherToIO :: Parsed a -> IO a
eitherToIO (Right value) = pure value
eitherToIO (Left message) = fail message
A następnie możemy dodać do tego składanie monad (flatMapowanie) z IO (IO a)
na IO a
joinEitherToIO :: ParsedIO a -> IO a
joinEitherToIO io = eitherToIO =<< io
Teraz możemy wszystko opakować w nową asercję:
infix 1 `shouldParseReturn`
shouldParseReturn :: (Show a, Eq a) => ParsedIO a -> a -> Expectation
shouldParseReturn action = shouldReturn (joinEitherToIO action)
infix 1
pozwala ustalić priorytet operatora.
Ostatecznie test wygląda następująco:
module HelVM.HelPA.Assemblers.EAS.AsmParserSpec (spec) where
import HelVM.HelPA.Assemblers.EAS.AsmParser
import HelVM.HelPA.Assemblers.EAS.Instruction
import HelVM.HelPA.Assemblers.EAS.FileUtil
import HelVM.HelPA.Assemblers.EAS.TestData
import HelVM.HelPA.Assemblers.Expectations
import HelVM.HelPA.Common.Value
import Test.Hspec
import Test.Hspec.Attoparsec
spec :: Spec
spec = do
describe "parseFromFile" $ do
forM_ [ ("true" , trueIL)
, ("hello" , helloIL)
, ("pip" , pipIL)
, ("pip2" , pip2IL)
, ("reverse" , reverseIL)
, ("function" , functionIL)
, ("writestr" , writeStrIL)
, ("hello2" , hello2IL <> [D "writestr.eas"])
, ("hello3" , hello2IL <> [D "writestr.eas"])
, ("hello4" , hello4IL <> [D "writestr.eas"])
, ("writenum" , writeNumIL)
, ("multiply" , multiplyIL)
, ("readnum" , readNumIL)
, ("fact" , factIL <> [D "readnum.eas",D "writenum.eas",D "multiply.eas",D "writestr.eas"])
, ("bottles" , bottlesIL)
, ("euclid" , euclidIL)
] $ \(fileName , il) -> do
let parseFromFile = parseAssembler <$> readFileText (buildAbsolutePathToEasFile fileName)
it fileName $ do parseFromFile `shouldParseReturn` il
CodeGeneratorSpec, czyli złote testy
Moduł CodeGeneratorSpec
testuje funkcję CodeGenerator.generateCode
. Funkcja generateCode :: InstructionList -> String
generuje kod w języku ETA na podstawie listy instrukcji. Wyniku wygenerowanego z generateCode
nie będziemy porównywać z wartościami zapisanymi w testach tylko ze złotym plikiem.
Tym razem musimy samodzielnie napisać asercję:
infix 1 `goldenShouldBe`
goldenShouldBe :: String -> String -> Golden String
goldenShouldBe actualOutput fileName =
Golden {
output = actualOutput,
encodePretty = show,
writeToFile = writeFile,
readFromFile = readFile,
goldenFile = ".output" </> "golden" </> fileName,
actualFile = Just (".output" </> "actual" </> fileName),
failFirstTime = False
}
Kod testu wygląda następująco:
module HelVM.HelPA.Assemblers.EAS.CodeGeneratorSpec (spec) where
import HelVM.HelPA.Assemblers.EAS.CodeGenerator
import HelVM.HelPA.Assemblers.EAS.TestData
import HelVM.HelPA.Assemblers.EAS.FileUtil
import HelVM.HelPA.Assemblers.Expectations
import Test.Hspec
spec :: Spec
spec = do
describe "generateCode" $ do
forM_ [ ("true" , trueIL)
, ("pip" , pipILReduced)
, ("pip2" , pip2ILReduced)
, ("reverse" , reverseILReduced)
, ("function" , functionIL)
, ("add" , addILReduced)
, ("writestr" , writeStrILReduced)
, ("hello2" , hello2ILReduced)
, ("hello4" , hello2ILReduced)
, ("writenum" , writeNumILReduced)
, ("multiply" , multiplyILReduced)
, ("readnum" , readNumILReduced)
, ("fact" , factILReduced)
, ("bottles" , bottlesILReduced)
, ("euclid" , euclidILReduced)
] $ \(fileName , ilReduced) -> do
it fileName $ do generateCode ilReduced `goldenShouldBe` buildAbsolutePathToEtaFile fileName
Tablica zawiera krotki (tuple). Pierwszy element krotki to nazwa pliku, drugi element krotki to lista instrukcji ze zredukowanymi rozkazami. Po lewej stronie asercji mamy generowanie kodu. Po prawej stronie asercji mamy wczytanie pliku z kodem źródłowym w ETA.
AssemblerSpec, czyli złote testy z czytaniem danych testowych z pliku
Pora na przetestowanie wszystkiego razem. Moduł AssemblerSpec
testuje funkcję Assembler.assembleFile
. Funkcja assembleFile :: SourcePath -> ParsedIO String
generuje kod w języku ETA na podstawie zmiennej SourcePath
. Typ SourcePath
ma postać:
data SourcePath = SourcePath
{ dirPath :: String -- ścieżka do folderu z bibliotekami z kodem w EAS
, filePath :: String -- ścieżka do pliku z kodem w EAS
}
Ponieważ funkcja Assembler.assembleFile
zwraca monadę IO
potrzebujemy asercji działającej dla typu IO (Golden String)
.
W celu prostszego zapisu tworzymy alias typu:
type GoldenIO a = IO (Golden a)
A następnie asercję:
goldenShouldReturn' :: IO String -> String -> GoldenIO String
goldenShouldReturn' actualOutputIO fileName = flip goldenShouldBe fileName <$> actualOutputIO
Jednak ta asercja nie zadziała z powodu niezgodności typów:
hs/test/HelVM/HelPA/Assemblers/EAS/AssemblerSpec.hs:39:7: error:
• Couldn't match type ‘Arg
(HelVM.HelPA.Assemblers.Expectations.GoldenIO String)’
with ‘()’
Expected type: hspec-core-2.8.2:Test.Hspec.Core.Spec.Monad.SpecM
() ()
Actual type: SpecWith
(Arg (HelVM.HelPA.Assemblers.Expectations.GoldenIO String))
• In a stmt of a 'do' block:
it fileName
$ do assemble
`goldenShouldParseReturn`
buildAbsolutePathToEtaFile ("assembleFile" </> fileName)
In the expression:
do let assemble = assembleFile ...
it fileName
$ do assemble
`goldenShouldParseReturn`
buildAbsolutePathToEtaFile ("assembleFile" </> fileName)
In the second argument of ‘($)’, namely
‘\ fileName
-> do let ...
it fileName $ do ...’
|
39 | it fileName $ do assemble `goldenShouldParseReturn` buildAbsolutePathToEtaFile ("assembleFile" </> fileName)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Dlaczego? Otóż:
- Normalne asercje zwracają typ
Expectation
, który jest aliasem dla typuIO ()
. - Złote testy zwracają -
Golden a
. - Nasze testy zwracają -
Golden (IO String)
.
Problemem jest brak instancji (implementacji) klasy typu Example
dla Golden (IO String)
. Dlatego spróbujmy ją napisać:
instance Eq str => Example (GoldenIO str) where
type Arg (GoldenIO str) = ()
evaluateExample wrapped params action callback = evaluateExample' =<< unWrappedGoldenIO wrapped where
evaluateExample' golden = evaluateExample golden params action callback
BTW to, co widzimy powyżej to chyba rodziny typów (ang. Type Family)
Niestety to także nie zadziała i dostaniemy błąd:
hs/test/HelVM/HelPA/Assemblers/Expectations.hs:87:1: error: [-Worphans, -Werror=orphans]
Orphan instance: instance Eq str => Example (GoldenIO str)
To avoid this
move the instance declaration to the module of the class or of the type, or
wrap the type with a newtype and declare the instance on the new type.
|
87 | instance Eq str => Example (GoldenIO str) where
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^...
Wynika to z tego, że w tej chwili mamy osieroconą instancję klasy typu Example
. Instancje klas typów dla typu danych możemy tworzyć tylko w modułach:
- gdzie zdefiniowana jest klasa typu;
- gdzie zdefiniowany jest typ danych.
Ponieważ nie mamy wpływu na klasę typu to musimy utworzyć nowy typ danych opakowujący typ GoldenIO
:
newtype WrappedGoldenIO a = WrappedGoldenIO { unWrappedGoldenIO :: GoldenIO a }
Nową asercję:
infix 1 `goldenShouldReturn`
goldenShouldReturn :: IO String -> String -> WrappedGoldenIO String
goldenShouldReturn actualOutputIO = WrappedGoldenIO . goldenShouldReturn' actualOutputIO
Oraz nową instancji klasy typu Example
:
instance Eq str => Example (WrappedGoldenIO str) where
type Arg (WrappedGoldenIO str) = ()
evaluateExample wrapped params action callback = evaluateExample' =<< unWrappedGoldenIO wrapped where
evaluateExample' golden = evaluateExample golden params action callback
Dzięki temu możemy napisać złoty test end-to-end:
module HelVM.HelPA.Assemblers.EAS.AssemblerSpec where
import HelVM.HelPA.Assemblers.EAS.Assembler
import HelVM.HelPA.Assemblers.EAS.FileUtil
import HelVM.HelPA.Assemblers.Expectations
import HelVM.HelPA.Common.API
import Test.Hspec
spec :: Spec
spec = do
describe "assembleFile" $ do
forM_ [ "true"
, "hello"
, "pip"
, "pip2"
, "reverse"
, "function"
, "add"
, "writestr"
, "hello2"
, "hello3"
, "hello4"
, "multiply"
, "fact"
, "bottles"
, "euclid"
] $ \fileName -> do
let assembleFile = assembly SourcePath {dirPath = easDir, filePath = buildAbsolutePathToEasFile fileName}
it fileName $ do assembleFile `goldenShouldParseReturn` buildAbsolutePathToEtaFile fileName
Testy jednostkowe kontra testy integracyjne
Po tym wszystkim rodzą się dwa pytania:
- Co z piramidą testów i testami jednostkowymi?
- Na ile to jest szybkie?
Gdzie testy jednostkowe i piramida testów?
Trochę offtop, ale IMHO ta cała piramida testów (i odwrócona piramida testów) to pic na wodę. Dlaczego o tym mówimy? Bo pokazywali nam to na konferencjach. A czemu nam to pokazywali? Bo piramida ładnie wygląda na slajdach. Chyba widziałem wszystkie możliwe ułożenia testów (z wyjątkiem piramid):
- Pracowałem w firmach, gdzie istniały tylko testy manualne.
- Widziałem firmę, gdzie istniały tylko testy manualne i jednostkowe, bo testerzy nie mieli czasu pisać testów systemowo-akceptacyjnych.
- Pracowałem w firmie, gdzie nie dało się powiedzieć czy jest więcej jednostkowych czy systemowo-akceptacyjnych, bo programiści pisali swoje testy, a testerzy swoje.
- Pracowałem w firmie, gdzie była niechęć do testów jednostkowych, a programiści i testerzy wspólnie pisali testy integracyjno-akceptacyjne.
Co do samych definicji to nie widziałem żadnego porządnego papieru, który określałby co to jest jednostka. Metoda/funkcja? Klasa/moduł? Pakiet? Mikroserwis? Mikroserwis z własną bazą danych? Wszystkie te sprzeczne definicje można spotkać na konferencjach. Niektórzy wprost mówią, że definicje się zmieniły odkąd mamy mikroserwisy. Jeśli ktoś ma uznany papier z porządną definicją to z chęcią przeczytam.
Które testy osobiście uważam za najlepsze? Te które są szybkie, ale jednocześnie testują maksymalnie dużo kodu. Dla mnie takimi testami dla większości aplikacji webowych są testy na poziomie mikroserwisu z prawdziwą bazą danych postawioną w dockerze. Jeśli czegoś w prosty sposób nie da się postawić w dockerze to mockuję. Albo na poziomie http, albo dostarczam alternatywną implementację klienta.
Tutaj jednak, na szczęście, nie mamy aplikacji webowej z http i bazą danych. Mamy aplikację pracującą na plikach i to na plikach powinniśmy ją testować.
Nie mówię, że testy jednostkowe są całkiem złe. Testy jednostkowe były dla mnie przydatne na początku pisania. Ale teraz małe testy jednostkowe są spowalniaczem przy refaktoryzacji.
Czas, czyli czy to nie jest za wolne.
Jeśli rezygnujemy z testów jednostkowych na rzecz testów integracyjnych to najważniejsze jest pytanie o czas. Czy testy integracyjne nie wykonują się za wolno?
Z pomocą przychodzi nam tu biblioteka hspec-slow
. Pozwala ona mierzyć czas wykonywania pojedynczych przypadków testowych.
Przy założeniu, że punkt wejściowy dla testów był w pliku hs/test/Spec.hs
, tworzymy plik hs/test/Main.hs
, który będzie nowym punktem wejściowym dla testów:
module Main where
import qualified Spec
import Test.Hspec.Slow
import Test.Hspec (hspec)
main :: IO ()
main = do
config <- configure 1
hspec $ timeThese config Spec.spec
Jednocześnie, jeśli chcemy dalej automatycznie generować agregator dla wszystkich testów, musimy zmienić plik hs/test/Spec.hs
na
{-# OPTIONS_GHC -F -pgmF hspec-discover -optF --module-name=Spec #-}
W projekcie HelPA nie ma żadnych testów dłuższych niż jedna sekunda. Jednak w projekcie HelMA kilka takich testów się znalazło:
1.128178606s: hs/test/HelVM/HelMA/Automata/ETA/EvaluatorSpec.hs[70:9]
interact/ListStackType/bottles
1.083167682s: hs/test/HelVM/HelMA/Automata/ETA/EvaluatorSpec.hs[72:9]
monadic/ListStackType/bottles
1.04183862s: hs/test/HelVM/HelMA/Automata/ETA/EvaluatorSpec.hs[74:9]
logging/ListStackType/bottles
1.266628515s: hs/test/HelVM/HelMA/Automata/ETA/EvaluatorSpec.hs[70:9]
interact/SeqStackType/bottles
1.120756983s: hs/test/HelVM/HelMA/Automata/ETA/EvaluatorSpec.hs[72:9]
monadic/SeqStackType/bottles
1.118947099s: hs/test/HelVM/HelMA/Automata/ETA/EvaluatorSpec.hs[74:9]
logging/SeqStackType/bottles
W zasadzie jest to jeden test wywoływany sześciokrotnie z różnymi parametrami.
Całość testów trochę trwa:
Finished in 24.1430 seconds
Ilość testów jest jednak spora:
1252 examples, 0 failures
Rozwiązaniem może być pozbycie się części testów. W tej chwili testuję wszystkie kombinacje parametrów na wszystkich przykładowych programach w językach ezoterycznych. Drugim rozwiązaniem może być podzielenie testów na dwa zestawy:
- Szybko wykonujące się testy dymne (ang. smoke test)
- Pozostałe testy
Złote testy - czy warto?
Krótko - warto. Kod testów się skrócił, ponieważ wartości oczekiwane do testów zostały przeniesione do złotych plików. Jednocześnie zlikwidowało to przymus używania znaków ucieczki do zapisywania znaku końca linii. Oraz rozwiązało to problem, że niektóre skrypty w BrainFucku są zgodne z Windowsem, a nie Linuksem
Kod asemblera HelPA po zmianach znajduje się na githubie. Podobnie jak kod interpretera HelMA po zmianach znajduje się na githubie.