Тестирование: hspec, QuickCheck и golden-тесты
Генератор собран и оформлен пакетом. Теперь докажем, что он работает, и поймаем регрессии заранее. Три инструмента и три разных вопроса к коду. hspec и table-driven отвечают “на этих конкретных входах результат такой”. QuickCheck отвечает “вот свойство, которое должно держаться на любом входе”. Golden-тесты отвечают “вывод не изменился незаметно”. Вместе они закрывают чистое ядро генератора почти целиком, не трогая файловую систему.
Идея
Чистое ядро, которое мы выделили в уроке 28, окупается именно здесь. parse, convert, escape, process это чистые функции, а чистую функцию проверять одно удовольствие: дал вход, сравнил выход, никаких моков, никакой подготовки окружения. Это прямое продолжение раздела про тестирование, только теперь на Haskell, где чистота это не дисциплина, а свойство типа.
Три подхода дополняют друг друга:
- Table-driven на hspec. Таблица пар вход-выход. Точно, читаемо, легко добавить случай. Ловит конкретные баги и служит документацией поведения.
- Property-based на QuickCheck. Формулируешь свойство, библиотека сама генерирует сотни случайных входов и ищет контрпример. Ловит то, о чём ты не подумал.
- Golden-тесты. Эталонный вывод лежит файлом на диске; тест сравнивает с ним. Ловит незаметные изменения вывода: рефакторинг, который случайно поменял один тег.
hspec: каркас и table-driven
hspec описывает тесты деревом describe/it. Точка входа собирает все спеки:
module Main (main) where
import Test.Hspec (hspec)
import qualified MarkupSpec
import qualified ConvertSpec
import qualified HtmlSpec
import qualified GoldenSpec
main :: IO ()
main = hspec $ do
MarkupSpec.spec
ConvertSpec.spec
HtmlSpec.spec
GoldenSpec.spec
Теперь самое полезное для парсера: таблица случаев. Вместо десяти почти одинаковых it заводим тип строки таблицы и прогоняем все одним mapM_.
module MarkupSpec (spec) where
import Test.Hspec
import Test.QuickCheck
import Press.Markup (Document, Structure (..), parse)
data Case = Case
{ caseName :: String
, caseInput :: String
, caseExpected :: Document
}
cases :: [Case]
cases =
[ Case "empty input is an empty document" "" []
, Case "a single paragraph" "hello world" [Paragraph "hello world"]
, Case "lines of one paragraph are joined" "hello\nworld" [Paragraph "hello world"]
, Case "a blank line closes a paragraph" "one\n\ntwo" [Paragraph "one", Paragraph "two"]
, Case "a heading" "* Title" [Heading 1 "Title"]
, Case "adjacent dash lines collapse into one list"
"- a\n- b\n- c" [UnorderedList ["a", "b", "c"]]
, Case "a code block keeps its lines verbatim"
"> line one\n> line two" [CodeBlock ["line one", "line two"]]
, Case "a paragraph then a list"
"intro\n\n- a\n- b" [Paragraph "intro", UnorderedList ["a", "b"]]
]
И прогон таблицы:
spec :: Spec
spec = describe "Press.Markup.parse" $ do
describe "table-driven cases" $
mapM_
( \(Case name input expected) ->
it name $ parse input `shouldBe` expected
)
cases
Добавить случай теперь это одна строка в cases, а не новый it с копипастой. Таблица заодно читается как спецификация формата: вот что разметка обещает.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.