Skip to content

Commit 071092c

Browse files
Introduce a QuickCheck-style MoreActions type modifier to make it easier to increase the number of actions on average in tests (#84)
* Initial stab at `getMoreActions` as a way to get bigger tests * Add a test to make sure MoreActions actually introduces a reasonable number of long sequences * Exports and documentation * simplify the design * Fix some warnings * Better name for property * refactor arbActions to make sure it's tail recursive * More tracking of action sequences + work on profiling to figure out what's slow * Correct log computation for bucketing * React to comments from Arnaud and Ulf * Nicer bucketing for action sequence lengths * Work-around for tabulate performance issue in quickcheck * Note in changelog * fourmoulu --------- Co-authored-by: Ulf Norell <ulf.norell@gmail.com>
1 parent 95b8ad1 commit 071092c

File tree

4 files changed

+158
-84
lines changed

4 files changed

+158
-84
lines changed

quickcheck-dynamic/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ changes.
2020
```
2121
* **BREAKING**: Moved `Error state` from `StateModel` to `RunModel` and indexed it on both the `state` and the monad `m`
2222
* **BREAKING**: Changed `PerformResult` from `PerformResult (Error state) a` to `PerformResult state m a`
23+
* Added a `moreActions` property modifier to allow controlling the length of action sequences.
2324
2425
## 3.4.1 - 2024-03-22
2526

quickcheck-dynamic/src/Test/QuickCheck/StateModel.hs

Lines changed: 121 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
{-# LANGUAGE AllowAmbiguousTypes #-}
2+
{-# LANGUAGE FunctionalDependencies #-}
23
{-# LANGUAGE QuantifiedConstraints #-}
4+
{-# LANGUAGE RecordWildCards #-}
35
{-# LANGUAGE UndecidableInstances #-}
46

57
-- | Model-Based Testing library for use with Haskell QuickCheck.
@@ -25,6 +27,7 @@ module Test.QuickCheck.StateModel (
2527
Env,
2628
Generic,
2729
IsPerformResult,
30+
Options (..),
2831
monitorPost,
2932
counterexamplePost,
3033
stateAfter,
@@ -37,6 +40,10 @@ module Test.QuickCheck.StateModel (
3740
computePrecondition,
3841
computeArbitraryAction,
3942
computeShrinkAction,
43+
generateActionsWithOptions,
44+
shrinkActionsWithOptions,
45+
defaultOptions,
46+
moreActions,
4047
) where
4148

4249
import Control.Monad
@@ -358,56 +365,100 @@ usedVariables (Actions as) = go initialAnnotatedState as
358365
<> go (computeNextState aState act var) steps
359366

360367
instance forall state. StateModel state => Arbitrary (Actions state) where
361-
arbitrary = do
362-
(as, rejected) <- arbActions initialAnnotatedState 1
363-
return $ Actions_ rejected (Smart 0 as)
364-
where
365-
arbActions :: Annotated state -> Int -> Gen ([Step state], [String])
366-
arbActions s step = sized $ \n ->
367-
let w = n `div` 2 + 1
368-
in frequency
369-
[ (1, return ([], []))
370-
,
371-
( w
372-
, do
373-
(mact, rej) <- satisfyPrecondition
374-
case mact of
375-
Just (Some act@ActionWithPolarity{}) -> do
376-
let var = mkVar step
377-
(as, rejected) <- arbActions (computeNextState s act var) (step + 1)
378-
return ((var := act) : as, rej ++ rejected)
379-
Nothing ->
380-
return ([], [])
381-
)
382-
]
383-
where
384-
satisfyPrecondition = sized $ \n -> go n (2 * n) [] -- idea copied from suchThatMaybe
385-
go m n rej
386-
| m > n = return (Nothing, rej)
387-
| otherwise = do
388-
a <- resize m $ computeArbitraryAction s
389-
case a of
390-
Some act ->
391-
if computePrecondition s act
392-
then return (Just (Some act), rej)
393-
else go (m + 1) n (actionName (polarAction act) : rej)
394-
395-
shrink (Actions_ rs as) =
396-
map (Actions_ rs) (shrinkSmart (map (prune . map fst) . concatMap customActionsShrinker . shrinkList shrinker . withStates) as)
397-
where
398-
shrinker :: (Step state, Annotated state) -> [(Step state, Annotated state)]
399-
shrinker (v := act, s) = [(unsafeCoerceVar v := act', s) | Some act'@ActionWithPolarity{} <- computeShrinkAction s act]
400-
401-
customActionsShrinker :: [(Step state, Annotated state)] -> [[(Step state, Annotated state)]]
402-
customActionsShrinker acts =
403-
let usedVars = mconcat [getAllVariables a <> getAllVariables (underlyingState s) | (_ := a, s) <- acts]
404-
binding (v := _, _) = Some v `Set.member` usedVars
405-
-- Remove at most one non-binding action
406-
go [] = [[]]
407-
go (p : ps)
408-
| binding p = map (p :) (go ps)
409-
| otherwise = ps : map (p :) (go ps)
410-
in go acts
368+
arbitrary = generateActionsWithOptions defaultOptions
369+
shrink = shrinkActionsWithOptions defaultOptions
370+
371+
data QCDProperty state = QCDProperty
372+
{ runQCDProperty :: Actions state -> Property
373+
, qcdPropertyOptions :: Options state
374+
}
375+
376+
instance StateModel state => Testable (QCDProperty state) where
377+
property QCDProperty{..} =
378+
forAllShrink
379+
(generateActionsWithOptions qcdPropertyOptions)
380+
(shrinkActionsWithOptions qcdPropertyOptions)
381+
runQCDProperty
382+
383+
class QCDProp state p | p -> state where
384+
qcdProperty :: p -> QCDProperty state
385+
386+
instance QCDProp state (QCDProperty state) where
387+
qcdProperty = id
388+
389+
instance Testable p => QCDProp state (Actions state -> p) where
390+
qcdProperty p = QCDProperty (property . p) defaultOptions
391+
392+
modifyOptions :: QCDProperty state -> (Options state -> Options state) -> QCDProperty state
393+
modifyOptions p f =
394+
let opts = qcdPropertyOptions p
395+
in p{qcdPropertyOptions = f opts}
396+
397+
moreActions :: QCDProp state p => Rational -> p -> QCDProperty state
398+
moreActions r p =
399+
modifyOptions (qcdProperty p) $ \opts -> opts{actionLengthMultiplier = actionLengthMultiplier opts * r}
400+
401+
-- NOTE: indexed on state for forwards compatibility, e.g. when we
402+
-- want to give an explicit initial state
403+
data Options state = Options {actionLengthMultiplier :: Rational}
404+
405+
defaultOptions :: Options state
406+
defaultOptions = Options{actionLengthMultiplier = 1}
407+
408+
-- | Generate arbitrary actions with the `GenActionsOptions`. More flexible than using the type-based
409+
-- modifiers.
410+
generateActionsWithOptions :: forall state. StateModel state => Options state -> Gen (Actions state)
411+
generateActionsWithOptions Options{..} = do
412+
(as, rejected) <- arbActions [] [] initialAnnotatedState 1
413+
return $ Actions_ rejected (Smart 0 as)
414+
where
415+
arbActions :: [Step state] -> [String] -> Annotated state -> Int -> Gen ([Step state], [String])
416+
arbActions steps rejected s step = sized $ \n -> do
417+
let w = round (actionLengthMultiplier * fromIntegral n) `div` 2 + 1
418+
continue <- frequency [(1, pure False), (w, pure True)]
419+
if continue
420+
then do
421+
(mact, rej) <- satisfyPrecondition
422+
case mact of
423+
Just (Some act@ActionWithPolarity{}) -> do
424+
let var = mkVar step
425+
arbActions
426+
((var := act) : steps)
427+
(rej ++ rejected)
428+
(computeNextState s act var)
429+
(step + 1)
430+
Nothing ->
431+
return (reverse steps, rejected)
432+
else return (reverse steps, rejected)
433+
where
434+
satisfyPrecondition = sized $ \n -> go n (2 * n) [] -- idea copied from suchThatMaybe
435+
go m n rej
436+
| m > n = return (Nothing, rej)
437+
| otherwise = do
438+
a <- resize m $ computeArbitraryAction s
439+
case a of
440+
Some act ->
441+
if computePrecondition s act
442+
then return (Just (Some act), rej)
443+
else go (m + 1) n (actionName (polarAction act) : rej)
444+
445+
shrinkActionsWithOptions :: forall state. StateModel state => Options state -> Actions state -> [Actions state]
446+
shrinkActionsWithOptions _ (Actions_ rs as) =
447+
map (Actions_ rs) (shrinkSmart (map (prune . map fst) . concatMap customActionsShrinker . shrinkList shrinker . withStates) as)
448+
where
449+
shrinker :: (Step state, Annotated state) -> [(Step state, Annotated state)]
450+
shrinker (v := act, s) = [(unsafeCoerceVar v := act', s) | Some act'@ActionWithPolarity{} <- computeShrinkAction s act]
451+
452+
customActionsShrinker :: [(Step state, Annotated state)] -> [[(Step state, Annotated state)]]
453+
customActionsShrinker acts =
454+
let usedVars = mconcat [getAllVariables a <> getAllVariables (underlyingState s) | (_ := a, s) <- acts]
455+
binding (v := _, _) = Some v `Set.member` usedVars
456+
-- Remove at most one non-binding action
457+
go [] = [[]]
458+
go (p : ps)
459+
| binding p = map (p :) (go ps)
460+
| otherwise = ps : map (p :) (go ps)
461+
in go acts
411462

412463
-- Running state models
413464

@@ -498,14 +549,25 @@ runActions
498549
=> Actions state
499550
-> PropertyM m (Annotated state, Env)
500551
runActions (Actions_ rejected (Smart _ actions)) = do
501-
(finalState, env) <- runSteps initialAnnotatedState [] actions
552+
let bucket = \n -> let (a, b) = go n in show a ++ " - " ++ show b
553+
where
554+
go n
555+
| n < 100 = (d * 10, d * 10 + 9)
556+
| otherwise = let (a, b) = go d in (a * 10, b * 10 + 9)
557+
where
558+
d = div n 10
559+
monitor $ tabulate "# of actions" [show $ bucket $ length actions]
560+
(finalState, env, names, polars) <- runSteps initialAnnotatedState [] actions
561+
monitor $ tabulate "Actions" names
562+
monitor $ tabulate "Action polarity" $ map show polars
502563
unless (null rejected) $
503564
monitor $
504565
tabulate "Actions rejected by precondition" rejected
505566
return (finalState, env)
506567

507-
-- | Core function to execute a sequence of `Step` given some initial `Env`ironment
508-
-- and `Annotated` state.
568+
-- | Core function to execute a sequence of `Step` given some initial `Env`ironment and `Annotated`
569+
-- state. Return the list of action names and polarities to work around
570+
-- https://github.com/nick8325/quickcheck/issues/416 causing repeated calls to tabulate being slow.
509571
runSteps
510572
:: forall state m e
511573
. ( StateModel state
@@ -516,23 +578,23 @@ runSteps
516578
=> Annotated state
517579
-> Env
518580
-> [Step state]
519-
-> PropertyM m (Annotated state, Env)
520-
runSteps s env [] = return (s, reverse env)
581+
-> PropertyM m (Annotated state, Env, [String], [Polarity])
582+
runSteps s env [] = return (s, reverse env, [], [])
521583
runSteps s env ((v := act) : as) = do
522584
pre $ computePrecondition s act
523585
ret <- run $ performResultToEither <$> perform (underlyingState s) action (lookUpVar env)
524586
let name = show polar ++ actionName action
525-
monitor $ tabulate "Actions" [name]
526-
monitor $ tabulate "Action polarity" [show polar]
527587
case (polar, ret) of
528588
(PosPolarity, Left err) ->
529589
positiveActionFailed err
530590
(PosPolarity, Right val) -> do
531591
(s', env') <- positiveActionSucceeded ret val
532-
runSteps s' env' as
592+
(s'', env'', names, polars) <- runSteps s' env' as
593+
pure (s'', env'', name : names, polar : polars)
533594
(NegPolarity, _) -> do
534595
(s', env') <- negativeActionResult ret
535-
runSteps s' env' as
596+
(s'', env'', names, polars) <- runSteps s' env' as
597+
pure (s'', env'', name : names, polar : polars)
536598
where
537599
polar = polarity act
538600

quickcheck-dynamic/test/Spec/DynamicLogic/RegistryModel.hs

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -55,30 +55,31 @@ instance StateModel RegState where
5555
validFailingAction _ _ = True
5656

5757
arbitraryAction ctx s =
58-
frequency $
59-
[
60-
( max 1 $ 10 - length (ctxAtType @ThreadId ctx)
61-
, return $ Some Spawn
62-
)
63-
,
64-
( 2 * Map.size (regs s)
65-
, Some <$> (Unregister <$> probablyRegistered s)
66-
)
67-
,
68-
( 10
69-
, Some <$> (WhereIs <$> probablyRegistered s)
70-
)
71-
]
72-
++ [ ( max 1 $ 3 - length (dead s)
73-
, Some <$> (KillThread <$> arbitraryVar ctx)
74-
)
75-
| not . null $ ctxAtType @ThreadId ctx
76-
]
77-
++ [ ( max 1 $ 10 - Map.size (regs s)
78-
, Some <$> (Register <$> probablyUnregistered s <*> arbitraryVar ctx)
79-
)
80-
| not . null $ ctxAtType @ThreadId ctx
81-
]
58+
let threadIdCtx = ctxAtType @ThreadId ctx
59+
in frequency $
60+
[
61+
( max 1 $ 10 - length threadIdCtx
62+
, return $ Some Spawn
63+
)
64+
,
65+
( 2 * Map.size (regs s)
66+
, Some <$> (Unregister <$> probablyRegistered s)
67+
)
68+
,
69+
( 10
70+
, Some <$> (WhereIs <$> probablyRegistered s)
71+
)
72+
]
73+
++ [ ( max 1 $ 3 - length (dead s)
74+
, Some <$> (KillThread <$> arbitraryVar ctx)
75+
)
76+
| not . null $ threadIdCtx
77+
]
78+
++ [ ( max 1 $ 10 - Map.size (regs s)
79+
, Some <$> (Register <$> probablyUnregistered s <*> arbitraryVar ctx)
80+
)
81+
| not . null $ threadIdCtx
82+
]
8283

8384
shrinkAction ctx _ (Register name tid) =
8485
[Some (Unregister name)]
@@ -267,6 +268,7 @@ tests =
267268
testGroup
268269
"registry model example"
269270
[ testProperty "prop_Registry" $ prop_Registry
271+
, testProperty "moreActions 10 $ prop_Registry" $ moreActions 10 prop_Registry
270272
, testProperty "canRegister" $ propDL canRegister
271273
, testProperty "canRegisterNoUnregister" $ expectFailure $ propDL canRegisterNoUnregister
272274
]

quickcheck-dynamic/test/Test/QuickCheck/StateModelSpec.hs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ import Control.Monad.Reader (lift)
77
import Data.IORef (newIORef)
88
import Data.List (isInfixOf)
99
import Spec.DynamicLogic.Counters (Counter (..), FailingCounter, SimpleCounter (..))
10-
import Test.QuickCheck (Property, Result (..), Testable, chatty, choose, counterexample, noShrinking, property, stdArgs)
10+
import Test.QuickCheck (Property, Result (..), Testable, chatty, checkCoverage, choose, counterexample, cover, noShrinking, property, stdArgs)
1111
import Test.QuickCheck.Extras (runPropertyReaderT)
1212
import Test.QuickCheck.Monadic (assert, monadicIO, monitor, pick)
1313
import Test.QuickCheck.StateModel (
1414
Actions,
1515
lookUpVarMaybe,
1616
mkVar,
17+
moreActions,
1718
runActions,
1819
underlyingState,
1920
viewAtType,
@@ -29,6 +30,7 @@ tests =
2930
testGroup
3031
"Running actions"
3132
[ testProperty "simple counter" $ prop_counter
33+
, testProperty "simple_counter_moreActions" $ moreActions 30 prop_counter
3234
, testProperty "returns final state updated from actions" prop_returnsFinalState
3335
, testProperty "environment variables indices are 1-based " prop_variablesIndicesAre1Based
3436
, testCase "prints distribution of actions and polarity" $ do
@@ -38,6 +40,9 @@ tests =
3840
, testCase "prints counterexample as sequence of steps when postcondition fails" $ do
3941
Failure{output} <- captureTerminal prop_failsOnPostcondition
4042
"do action $ Inc'" `isInfixOf` output @? "Output does not contain \"do action $ Inc'\": " <> output
43+
, testProperty
44+
"moreActions introduces long sequences of actions"
45+
prop_longSequences
4146
]
4247

4348
captureTerminal :: Testable p => p -> IO Result
@@ -79,3 +84,7 @@ prop_failsOnPostcondition actions =
7984
ref <- lift $ newIORef (0 :: Int)
8085
runPropertyReaderT (runActions actions) ref
8186
assert True
87+
88+
prop_longSequences :: Property
89+
prop_longSequences =
90+
checkCoverage $ moreActions 10 $ \(Actions steps :: Actions SimpleCounter) -> cover 50 (100 < length steps) "Long sequences" True

0 commit comments

Comments
 (0)