पाठ 19 / 25

Testing with Hspec and QuickCheck

Write example-based tests and property-based tests.

Types plus tests

Types rule out many bugs, but not wrong logic, so Haskell projects still test thoroughly. Hspec provides readable example-based tests (describe, it, shouldBe, shouldSatisfy, shouldThrow) and tasty is an alternative test runner that combines providers. QuickCheck, which originated in Haskell, pioneered property-based testing: you state properties that should hold for all inputs, such as "reversing a list twice gives the original", and QuickCheck generates hundreds of random inputs and shrinks any failure to a minimal counterexample. Custom types get generators by implementing Arbitrary, and modifiers like NonNegative, Positive and NonEmptyList constrain inputs. Hedgehog is a newer alternative with integrated shrinking. Pure functions make testing simple: no mocks, no setup, just inputs and outputs. Doctests check examples in Haddock comments. Configure test suites in the .cabal file (test-suite spec) and run them with cabal test. Combined with -Wall, a type checker and HLint, this catches most mistakes before code runs in production.

Property-based testing

Random inputs test a property; failures shrink to the smallest counterexample.

A dice icon feeding many small input cards into a function box with a checkmark, and one failing card being shrunk step by step to a tiny card.
Figure 7.1 — Generate, check and shrink.

Hspec examples and QuickCheck properties

Testing a pricing function and a round-trip property.

-- test/Spec.hs
import Test.Hspec
import Test.QuickCheck

withGstPaise :: Int -> Int
withGstPaise p = p + (p * 18 + 50) `div` 100

discount :: Int -> Int -> Int
discount percent p = p * (100 - percent) `div` 100

encodeCsv :: [String] -> String
encodeCsv = foldr1 (\a b -> a ++ "," ++ b)

decodeCsv :: String -> [String]
decodeCsv s = case break (== ',') s of
  (field, [])       -> [field]
  (field, _ : rest) -> field : decodeCsv rest

main :: IO ()
main = hspec $ do
  describe "withGstPaise" $ do
    it "adds 18% GST" $
      withGstPaise 10000 `shouldBe` 11800
    it "never decreases a price" $ property $
      \(NonNegative p) -> withGstPaise p >= p

  describe "discount" $
    it "never increases a price" $ property $
      \(NonNegative p) -> forAll (choose (0, 100)) $ \pct -> discount pct p <= p

  describe "CSV round trip" $
    it "decode . encode is identity for comma-free fields" $ property $
      forAll (listOf1 (listOf (elements ['a' .. 'z']))) $ \fields ->
        decodeCsv (encodeCsv fields) == fields

Properties find edge cases you would not write

QuickCheck quickly tries empty strings, zero, negative numbers and huge values. If the CSV generator above allowed commas, it would immediately report a minimal failing example, revealing that the format needs escaping.

त्वरित जाँच: What does QuickCheck do when it finds a failing input?

  • Shrinks it to a smaller counterexample that still fails
  • Stops without information
  • Ignores it
  • Retries until it passes
Answer

Shrinks it to a smaller counterexample that still fails — Shrinking makes failures much easier to understand.