diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Revision history for tinycheck
 
-## 0.1.0.0 -- YYYY-mm-dd
+## 0.2.0
 
-* First version. Released on an unsuspecting world.
+* Improved Tasty integration
+
+## 0.1.0.0
+
+* First version.
diff --git a/bench/Main.hs b/bench/Main.hs
new file mode 100644
--- /dev/null
+++ b/bench/Main.hs
@@ -0,0 +1,85 @@
+{-# LANGUAGE AllowAmbiguousTypes #-}
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE TypeApplications #-}
+
+{- | Benchmarks for tinycheck's test-case generators.
+
+Each benchmark measures how quickly @take N (getTestCases gen)@ can produce
+and fully force @N@ values, for a representative set of generators.
+This captures both the enumeration cost and the cost of building the values.
+-}
+module Main where
+
+-- base
+import GHC.Generics (Generic, Generically (..))
+
+-- deepseq
+import Control.DeepSeq (NFData)
+
+-- tasty-bench
+import Test.Tasty.Bench
+
+-- tinycheck
+
+import Data.Functor ((<&>))
+import Data.TestCases (Arbitrary (..), getTestCases)
+import Test.Tasty (localOption, mkTimeout)
+
+-- * Real-life data structure: binary search tree
+
+data Tree a = Leaf | Node (Tree a) a (Tree a)
+  deriving stock (Show, Eq, Foldable, Generic)
+  deriving anyclass (NFData)
+  deriving (Arbitrary) via Generically (Tree a)
+
+-- * Benchmark helpers
+
+-- | Counts used for generators.
+counts :: [Int]
+counts = [100, 1000, 10_000]
+
+{- | Benchmark a single generator at a given list of counts.
+The group name is the type/description; each leaf is labelled by the count.
+The generator is instantiated fresh for every individual benchmark run
+so that no list spine is shared or retained between runs.
+-}
+benchGenWith :: forall a. (Arbitrary a, NFData a) => [Int] -> String -> Benchmark
+benchGenWith ns name =
+  bgroup name $
+    ns <&> \n ->
+      localOption (mkTimeout 5_000_000) $
+        bench (show n) $
+          nf (\n' -> take n' $ getTestCases $ arbitrary @a) n
+
+-- | 'benchGenWith' at 'counts'
+benchGen :: forall a. (Arbitrary a, NFData a) => String -> Benchmark
+benchGen = benchGenWith @a counts
+
+-- * Main
+
+main :: IO ()
+main =
+  defaultMain
+    [ bgroup
+        "primitives"
+        [ benchGen @Int "Int"
+        , benchGen @Char "Char"
+        , benchGen @Integer "Integer"
+        ]
+    , bgroup
+        "composites"
+        [ benchGen @(Int, Int) "(Int, Int)"
+        , benchGen @(Maybe Int) "Maybe Int"
+        , benchGen @(Either Int Bool) "Either Int Bool"
+        ]
+    , bgroup
+        "lists"
+        [ benchGen @[Int] "[Int]"
+        , benchGen @[Bool] "[Bool]"
+        ]
+    , bgroup
+        "Tree Int"
+        [ benchGen @(Tree Int) "Tree Int"
+        ]
+    ]
diff --git a/src/Data/TestCases.hs b/src/Data/TestCases.hs
--- a/src/Data/TestCases.hs
+++ b/src/Data/TestCases.hs
@@ -62,7 +62,7 @@
 where
 
 -- base
-import Control.Monad (ap, forM_, replicateM, unless)
+import Control.Monad (forM_, replicateM, unless)
 import Data.Char (GeneralCategory (..), chr, generalCategory)
 import Data.Coerce (coerce)
 import Data.Complex (Complex (..))
@@ -229,7 +229,16 @@
 
 instance Applicative TestCases where
   pure = testCase
-  (<*>) = ap
+
+  --  Apply each function to the entire argument generator and interleave results.
+  --
+  --  Written as @'foldMap' ('fmap' f) fs@ rather than @'ap'@, this avoids the
+  --  inner @'foldMap' ('pure' . f)@ that @'ap'@ would generate: instead of
+  --  wrapping each @f x@ in a singleton and then immediately unwrapping it
+  --  via @'<>'@, we apply @f@ to the whole @xs@ list in one @'map'@ pass.
+  --  The outer @'foldMap'@ then interleaves those result lists with @'<>'@.
+  --
+  TestCases fs <*> xs = foldMap (`fmap` xs) fs
 
 {- | Bind via 'foldMap', which accumulates results with the interleaving '<>'.
 
diff --git a/src/Test/Tasty/TinyCheck.hs b/src/Test/Tasty/TinyCheck.hs
--- a/src/Test/Tasty/TinyCheck.hs
+++ b/src/Test/Tasty/TinyCheck.hs
@@ -11,6 +11,10 @@
     \(xs :: [Int]) -> reverse (reverse xs) == xs
 , testProperty "length . nub <= length" $
     \(xs :: [Int]) -> length (nub xs) <= length xs
+, testProperty "manual generators" $ do
+    xs <- forAllShow "xs" (arbitrary :: TestCases [Int])
+    ys <- forAllShow "ys" (arbitrary :: TestCases [Int])
+    assert $ length (xs ++ ys) == length xs + length ys
 ]
 :}
 -}
@@ -18,6 +22,12 @@
   -- * Defining properties
   Property,
   property,
+  assert,
+  assertEqual,
+  forAll,
+  forAllWith,
+  forAllShow,
+  debug,
   (==>),
 
   -- * Checking properties
@@ -35,10 +45,13 @@
 where
 
 -- base
-import Control.Monad (void)
 import Data.List (isInfixOf)
 import Data.Proxy (Proxy (..))
 
+-- transformers
+import Control.Monad.Trans.Except (ExceptT (..), runExceptT)
+import Control.Monad.Trans.Writer.Strict (WriterT (..), runWriter, writer)
+
 -- tagged
 import Data.Tagged (Tagged (..))
 
@@ -53,49 +66,117 @@
 
 -- * Property
 
-{- | A testable property: a 'TestCases' of outcomes, each being either
-'Right ()' for a pass or 'Left msg' for a failure with a message.
+{- | Enumerate test cases, log, and assert conditions.
 
-Build one with 'property' (or its operator alias '==>') or simply
-use a function @a -> Bool@ / @a -> Property@ directly via 'testProperty'.
+Build properties with @do@-notation:
+
+> testProperty "commutativity" $ do
+>   -- Enumerate test cases
+>   x <- forAll generatorX
+>   -- Log the generated case
+>   y <- forAllShow "y" generatorY
+>   -- Log debug information
+>   debug $ "x + y = " <> show (x + y)
+>   -- Assert the property holds for the generated case
+>   assert $ x + y == (y :: Int) + x
+
+For a plain 'Bool' or a function, you can pass it directly to 'testProperty' via the 'Testable' class.
 -}
-newtype Property = Property (TestCases (Either String ()))
+newtype Property a = Property (TestCases (Either String a, [String]))
+  deriving (Functor, Applicative, Monad) via (ExceptT String (WriterT [String] TestCases))
 
--- | Promote a 'Bool' to a 'Property'.  Failures carry no extra message.
-property :: Bool -> Property
-property True = Property $ TestCases [Right ()]
-property False = Property $ TestCases [Left "falsified"]
+instance MonadFail Property where
+  fail = Property . pure . (,[]) . Left
 
+-- | Promote a 'Bool' to a 'Property ()'.  Failures carry no extra message.
+property :: Bool -> Property ()
+property True = pure ()
+property False = fail "falsified"
+
+{- | Assert a boolean condition within a 'Property' @do@-block.
+Fails with @\"falsified\"@ if the condition is 'False'.
+
+> testProperty "commutativity" $ do
+>   x <- forAll arbitrary
+>   y <- forAll arbitrary
+>   assert $ x + y == (y :: Int) + x
+-}
+assert :: Bool -> Property ()
+assert = property
+
+-- | Like 'assert', logging the expected and actual values on failure.
+assertEqual :: (Eq a, Show a) => a -> a -> Property ()
+assertEqual expected actual = do
+  debug $ "Expected: " <> show expected
+  debug $ "Actual: " <> show actual
+  assert (expected == actual)
+
+{- | Sample from a 'TestCases' generator in the 'Property' monad.
+
+> testProperty "my test" $ do
+>   x <- forAll generator1
+>   y <- forAll generator2
+>   assert $ somePredicate x y
+-}
+forAll :: TestCases a -> Property a
+forAll gen = Property $ fmap ((,[]) . Right) gen
+
+{- | Like 'forAll', but prepend the rendered value to any failure message produced by the continuation.
+
+Useful when the generator is not 'Show'-able or when you want a custom rendering:
+
+> testProperty "sorted" $ do
+>   xs <- forAllWith (\xs -> "First 3 generated elements = " <> show (take 3 xs)) (arbitrary :: TestCases [Int])
+>   assert $ sort xs == xs
+-}
+forAllWith :: (a -> String) -> TestCases a -> Property a
+forAllWith showFn gen = Property $ fmap (\a -> (Right a, [showFn a])) gen
+
+{- | Like 'forAllWith', specialised to 'show', with a tag prepended to the rendered value.
+
+In a @do@-block, the tag and value appear in the failure log as @"tag: value"@:
+
+> testProperty "sorted" $ do
+>   xs <- forAllShow "xs" (arbitrary :: TestCases [Int])
+>   assert $ sort xs == xs
+-}
+forAllShow :: (Show a) => String -> TestCases a -> Property a
+forAllShow tag = forAllWith (\a -> tag <> ": " <> show a)
+
+{- | Append a line of debug information to the log for the current test case.
+The line appears in the failure message if the property fails; it is discarded on success.
+
+> do
+>   x <- forAll arbitrary
+>   debug $ "x = " <> show (x :: Int)
+>   assert $ x > 0
+-}
+debug :: String -> Property ()
+debug msg = Property $ pure (Right (), [msg])
+
 {- | Conditional property: if the precondition is 'False' the test case is
 /discarded/ (treated as a pass), just like QuickCheck's @==>@.
 -}
-(==>) :: Bool -> Property -> Property
+(==>) :: Bool -> Property () -> Property ()
 True ==> p = p
-False ==> _ = Property $ TestCases [Right ()]
+False ==> _ = pure ()
 
 infixr 0 ==>
 
 -- * Testable class
 
--- | Types that can be converted to a 'Property'.
+-- | Types that can be converted to a 'Property ()'.
 class Testable a where
-  toProperty :: a -> Property
+  toProperty :: a -> Property ()
 
 instance Testable Bool where
   toProperty = property
 
-instance Testable Property where
+instance Testable (Property ()) where
   toProperty = id
 
 instance (Arbitrary a, Show a, Testable prop) => Testable (a -> prop) where
-  toProperty f = Property $ do
-    a <- arbitrary
-    let Property outcomes = toProperty (f a)
-    -- Annotate failures with the offending input.
-    fmap (void . prependInput a) outcomes
-    where
-      prependInput a (Left msg) = Left $ show a <> "\n" <> msg
-      prependInput _ r = r
+  toProperty f = forAllWith show arbitrary >>= toProperty . f
 
 -- * Option: number of test cases
 
@@ -123,9 +204,9 @@
     let TinyCheckTests n = lookupOption opts
         Property cases = toProperty prop
         results = take n (getTestCases cases)
-    case sequence_ results of
-      Right () -> pure $ testPassed $ "OK, checked " <> show (length results) <> " cases"
-      Left msg -> pure $ testFailed msg
+    case runWriter $ runExceptT $ mapM_ (ExceptT . writer) results of
+      (Right (), _) -> pure $ testPassed $ "OK, checked " <> show (length results) <> " cases"
+      (Left msg, logs) -> pure $ testFailed $ unlines logs <> msg
 
 -- * Public API
 
diff --git a/test/Test/Combinators.hs b/test/Test/Combinators.hs
--- a/test/Test/Combinators.hs
+++ b/test/Test/Combinators.hs
@@ -74,6 +74,42 @@
               /= ((,) <$> "abc" <*> [1, 2, 3, 4, 5, 6])
         ]
     , testGroup
+        "do-notation / assert / forAll"
+        [ testProperty "assert True passes" $
+            assert True
+        , testProperty "forAll: all elements of a generator satisfy a predicate" $
+            forAll (TestCases [1 .. 10 :: Int])
+              >>= \n -> assert (n > 0)
+        , testProperty "do-block: two generators, Bool result" $ do
+            x <- forAll $ TestCases [1 .. 5 :: Int]
+            y <- forAll $ TestCases [1 .. 5 :: Int]
+            assert $ x + y > 0
+        , testProperty "do-block: two generators, Bool property" $ do
+            x <- forAll $ TestCases [1 .. 5 :: Int]
+            y <- forAll $ TestCases [1 .. 5 :: Int]
+            assert $ x * y == y * x
+        , testProperty "do-block: two arbitrary lists" $ do
+            xs <- forAll (arbitrary :: TestCases [Int])
+            ys <- forAll (arbitrary :: TestCases [Int])
+            assert $ length (xs <> ys) == length xs + length ys
+        , testProperty "do-block: sort is idempotent" $ do
+            xs <- forAll (arbitrary :: TestCases [Int])
+            assert $ sort (sort xs) == sort xs
+        , expectFailureWithN 1 "first 3:" "forAllWith: logs rendered value on failure" $ do
+            xs <- forAllWith (\xs -> "first 3: " <> show (take 3 xs)) (TestCases [[-1, -2, -3]] :: TestCases [Int])
+            assert $ all (> 0) xs
+        , expectFailureWithN 1 "xs:" "forAllShow: logs tag: value on failure" $ do
+            xs <- forAllShow "xs" (TestCases [[-1]] :: TestCases [Int])
+            assert $ all (> 0) xs
+        , expectFailureWithN 1 "debug info" "debug: log line appears in failure message" $ do
+            debug "debug info"
+            assert False
+        , testProperty "assertEqual: passes for equal values" $
+            assertEqual (42 :: Int) 42
+        , expectFailureWithN 1 "Expected: 1" "assertEqual: shows expected and actual on failure" $
+            assertEqual (1 :: Int) 2
+        ]
+    , testGroup
         "CoArbitrary newtype wrappers (nontrivial functions)"
         [ testProperty "IntegralCoArbitrary: some f distinguishes 0 from 1" $
             -- A constant function satisfies f 0 == f 1 for all inputs, but the
diff --git a/tinycheck.cabal b/tinycheck.cabal
--- a/tinycheck.cabal
+++ b/tinycheck.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.0
 name: tinycheck
-version: 0.1.0.0
+version: 0.2.0
 synopsis: A lightweight enumeration-based property testing library
 description:
   Tinycheck is a deterministic property testing library.
@@ -29,11 +29,27 @@
   default: False
   manual: True
 
+flag dump-core
+  description: Emit GHC Core output (.dump-simpl files) for inspection
+  default: False
+  manual: True
+
 common opts
   ghc-options: -Wall
 
   if flag(dev)
     ghc-options: -Werror
+
+  if flag(dump-core)
+    ghc-options:
+      -ddump-simpl
+      -ddump-to-file
+      -dsuppress-coercions
+      -dsuppress-type-applications
+      -dsuppress-idinfo
+      -dsuppress-module-prefixes
+      -dppr-case-as-let
+      -O2
   default-extensions:
     DataKinds
     DefaultSignatures
@@ -45,11 +61,9 @@
     TypeFamilies
 
   default-language: GHC2021
-  build-depends: base >=4.17 && <4.22
-
-common test-opts
-  import: opts
-  build-depends: tasty >=1.5 && <1.6
+  build-depends:
+    base >=4.17 && <4.22,
+    tasty >=1.5 && <1.6,
 
 library
   import: opts
@@ -60,12 +74,12 @@
   build-depends:
     generics-sop >=0.5 && <0.6,
     tagged >=0.8 && <0.9,
-    tasty >=1.5 && <1.6,
+    transformers >=0.5.6.2 && <0.7,
 
   hs-source-dirs: src
 
 test-suite tinycheck-test
-  import: test-opts
+  import: opts
   type: exitcode-stdio-1.0
   hs-source-dirs: test
   main-is: Main.hs
@@ -86,8 +100,18 @@
   build-depends: tinycheck
 
 test-suite example-tree
-  import: test-opts
+  import: opts
   type: exitcode-stdio-1.0
   hs-source-dirs: examples
   main-is: Tree.hs
   build-depends: tinycheck
+
+benchmark tinycheck-bench
+  import: opts
+  type: exitcode-stdio-1.0
+  hs-source-dirs: bench
+  main-is: Main.hs
+  build-depends:
+    deepseq >=1.4 && <1.6,
+    tasty-bench >=0.3 && <0.5,
+    tinycheck,
