diff --git a/CHANGES.md b/CHANGES.md
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,25 @@
+## 0.3.0 (3/6/2019)
+
+* Switch backend of the library to build on the hedgehog library
+* Change `Test` from kind `* -> *` to kind `*`. See [my note](https://github.com/joelburget/easytest/issues/22#issuecomment-469039853) for motivation. In short, this fixes a lot of bugs and we now support property testing.
+
+Upgrading:
+
+`Test` now has kind `*`. It's no longer a functor, applicative, monad, etc.
+- You can build an atomic test with `unitTest` (/ `example`) or `propertyTest`.
+- Also see `bracket`, `bracket_`, and `finally` for tests with setup / teardown.
+- `tests` and `scope` work as before. I mention them here because they're the
+  other way to build tests.
+
+Removed:
+- `expect b`          -> `assert b`
+- `expectEq a b`      -> `a === b`
+- `expectJust`        -> `matches _Just`
+- `expectRight`       -> `matches _Right`
+- `expectRightNoShow` -> `matches _Right`
+- `expectLeft`        -> `matches _Left`
+- `expectLeftNoShow`  -> `matches _Left`
+
 ## 0.2.1 (10/24/2018)
 
 * [Fix build errors for GHC 8.6](https://github.com/joelburget/easytest/commit/9bb30ec16671c0ec74835a52290b6508143a368f), [prevent building on GHC before 7.10](https://github.com/joelburget/easytest/pull/15/commits/f6d0ac50fa5a351a30b576567306121d67c0973a)
diff --git a/easytest.cabal b/easytest.cabal
--- a/easytest.cabal
+++ b/easytest.cabal
@@ -1,6 +1,6 @@
 name:          easytest
 category:      Testing
-version:       0.2.1
+version:       0.3
 license:       MIT
 cabal-version: >= 1.8
 license-file:  LICENSE
@@ -9,108 +9,89 @@
 stability:     provisional
 homepage:      https://github.com/joelburget/easytest
 bug-reports:   https://github.com/joelburget/easytest/issues
-copyright:     Copyright (C) 2017-2018 Joel Burget, Copyright (C) 2016 Paul Chiusano and contributors
+copyright:     Copyright (C) 2017-2019 Joel Burget, Copyright (C) 2016 Paul Chiusano and contributors
 synopsis:      Simple, expressive testing library
 description:
-  EasyTest is a simple testing toolkit, meant to replace most uses of QuickCheck, SmallCheck, HUnit, and frameworks like Tasty, etc. Here's an example usage:
+  EasyTest is a simple testing toolkit for unit- and property-testing. It's based on the hedgehog property-testing system. Here's an example usage:
   .
   > module Main where
   >
-  > import EasyTest
-  > import Control.Applicative
-  > import Control.Monad
+  > import           EasyTest
+  > import qualified Hedgehog.Gen   as Gen
+  > import qualified Hedgehog.Range as Range
   >
-  > suite :: Test ()
+  > suite :: Test
   > suite = tests
-  >   [ scope "addition.ex1" $ expect (1 + 1 == 2)
-  >   , scope "addition.ex2" $ expect (2 + 3 == 5)
-  >   , scope "list.reversal" . fork $ do
-  >       -- generate lists from size 0 to 10, of Ints in (0,43)
-  >       -- shorthand: listsOf [0..10] (int' 0 43)
-  >       ns <- [0..10] `forM` \n -> replicateM n (int' 0 43)
-  >       ns `forM_` \ns -> expect (reverse (reverse ns) == ns)
+  >   [ scope "addition.ex1" $ unitTest $ 1 + 1 === 2
+  >   , scope "addition.ex2" $ unitTest $ 2 + 3 === 5
+  >   , scope "list.reversal" $ property $ do
+  >       ns <- forAll $
+  >         Gen.list (Range.singleton 10) (Gen.int Range.constantBounded)
+  >       reverse (reverse ns) === ns
   >   -- equivalent to `scope "addition.ex3"`
-  >   , scope "addition" . scope "ex3" $ expect (3 + 3 == 6)
-  >   , scope "always passes" $ do
-  >       note "I'm running this test, even though it always passes!"
-  >       ok -- like `pure ()`, but records a success result
-  >   , scope "failing test" $ crash "oh noes!!" ]
+  >   , scope "addition" . scope "ex3" $ unitTest $ 3 + 3 === 6
+  >   , scope "always passes" $ unitTest success -- record a success result
+  >   , scope "failing test" $ crash "oh noes!!"
+  >   ]
   >
   > -- NB: `run suite` would run all tests, but we only run
   > -- tests whose scopes are prefixed by "addition"
+  > main :: IO Summary
   > main = runOnly "addition" suite
   .
   This generates the output:
   .
-  > Randomness seed for this run is 5104092164859451056
-  > Raw test output to follow ...
-  > ------------------------------------------------------------
-  > OK addition.ex1
-  > OK addition.ex2
-  > OK addition.ex3
-  > ------------------------------------------------------------
-  > ✅  3 tests passed, no failures! 👍 🎉
-  The idea here is to write tests with ordinary Haskell code, with control flow explicit and under programmer control.
+  > ━━━ runOnly "addition" ━━━
+  >   ✓ addition.ex1 passed 1 test.
+  >   ✓ addition.ex2 passed 1 test.
+  >   ⚐ list.reversal gave up after 1 discard, passed 0 tests.
+  >   ✓ addition.ex3 passed 1 test.
+  >   ⚐ always passes gave up after 1 discard, passed 0 tests.
+  >   ⚐ failing test gave up after 1 discard, passed 0 tests.
+  >   ⚐ 3 gave up, 3 succeeded.
+  We write tests with ordinary Haskell code, with control flow explicit and under programmer control.
 
-build-type:    Simple
+build-type:         Simple
 extra-source-files: CHANGES.md
-data-files:
-tested-with: GHC == 7.10.3, GHC == 8.0.2, GHC == 8.2.2, GHC == 8.4.3, GHC == 8.6.1
+tested-with:        GHC == 7.10.3, GHC == 8.0.2, GHC == 8.2.2, GHC == 8.4.3, GHC == 8.4.4, GHC == 8.6.3
 
 source-repository head
   type: git
   location: git@github.com:joelburget/easytest.git
 
--- `cabal install -foptimized` enables optimizations
-flag optimized
-  manual: True
-  default: False
-
-flag quiet
-  manual: True
-  default: False
-
 library
   hs-source-dirs: src
 
   exposed-modules:
     EasyTest
     EasyTest.Internal
-
-  other-modules:
-    EasyTest.Generators
-    EasyTest.Porcelain
+    EasyTest.Internal.Hedgehog
+    EasyTest.Prism
 
-  -- these bounds could probably be made looser
   build-depends:
-    async                     >= 2.1      && <= 2.3,
-    base                      >= 4.8      && <= 5,
-    mtl                       >= 2.0.1    && < 2.3,
-    containers                >= 0.4.0    && < 0.7,
-    stm                       >= 2.4      && < 3,
-    random                    >= 1.1      && < 2,
-    text                      >= 1.2      && < 1.3,
-    transformers              >= 0.4.2,
-    call-stack                >= 0.1
+    base                      >= 4.5      && <= 5,
+    call-stack                >= 0.1,
+    -- since we rely on a hedgehog internal module we need to maintain tight
+    -- bounds (and test with all possible versions, ideally)
+    hedgehog                  >= 0.6      && <= 0.6.1,
+    stm,
+    -- for splitOn:
+    split                     >= 0.2.3,
+    -- we need mtl and transformers only for the breaking down / building up we
+    -- do of hedgehog properties. we leave their versions completely
+    -- unconstrained so we can rely just on the relevant hedgehog version
+    -- bounds.
+    mtl,
+    transformers,
+    profunctors, tagged
 
   if !impl(ghc >= 8.0)
     build-depends: semigroups == 0.18.*
 
-  ghc-options: -Wall -fno-warn-name-shadowing
-
-  if flag(optimized)
-    ghc-options: -funbox-strict-fields -O2
-
-  if flag(quiet)
-    ghc-options: -v0
+  ghc-options: -Wall
 
--- I really have no idea why you'd ever use this, just use an executable as above
 test-suite tests
   type:           exitcode-stdio-1.0
   main-is:        Suite.hs
-  ghc-options:    -w -threaded -rtsopts -with-rtsopts=-N -v0
   hs-source-dirs: tests
-  other-modules:
-  build-depends:
-    base,
-    easytest
+  build-depends:  base, easytest, hedgehog, unix, directory, profunctors, transformers
diff --git a/src/EasyTest.hs b/src/EasyTest.hs
--- a/src/EasyTest.hs
+++ b/src/EasyTest.hs
@@ -1,194 +1,113 @@
 {-|
 Module      : EasyTest
-Copyright   : (c) Joel Burget, 2018
+Copyright   : (c) Joel Burget, 2018-2019
 License     : MIT
 Maintainer  : joelburget@gmail.com
 Stability   : provisional
 
-EasyTest is a simple testing toolkit, meant to replace most uses of QuickCheck, SmallCheck, HUnit, and frameworks like Tasty, etc. Here's an example usage:
+EasyTest is a simple testing toolkit for unit- and property-testing. It's based on the <http://hackage.haskell.org/package/hedgehog hedgehog> property-testing system. Here's an example usage:
 
 @
 module Main where
 
-import EasyTest
-import Control.Applicative
-import Control.Monad
+import           EasyTest
+import qualified Hedgehog.Gen   as Gen
+import qualified Hedgehog.Range as Range
 
-suite :: Test ()
-suite = tests
-  [ scope "addition.ex1" $ expect (1 + 1 == 2)
-  , scope "addition.ex2" $ expect (2 + 3 == 5)
-  , scope "list.reversal" . fork $ do
-      -- generate lists from size 0 to 10, of Ints in (0,43)
-      -- shorthand: listsOf [0..10] (int' 0 43)
-      ns @<-@ [0..10] @`@forM@`@ \\n -> replicateM n (int' 0 43)
-      ns @`@forM_@`@ \\ns -> expect (reverse (reverse ns) == ns)
-  -- equivalent to `scope "addition.ex3"`
-  , scope "addition" . scope "ex3" $ expect (3 + 3 == 6)
-  , scope "always passes" $ do
-      note "I'm running this test, even though it always passes!"
-      ok -- like `pure ()`, but records a success result
-  , scope "failing test" $ crash "oh noes!!" ]
+suite :: 'Test'
+suite = 'tests'
+  [ 'scope' "addition.ex" $ 'unitTest' $ 1 + 1 '===' 2
+  , 'scope' "list.reversal" $ 'property' $ do
+      ns @<-@ 'forAll' $
+        Gen.list (Range.singleton 10) (Gen.int Range.constantBounded)
+      reverse (reverse ns) '===' ns
+  -- equivalent to `'scope' "addition.ex3"`
+  , 'scope' "addition" . 'scope' "ex3" $ 'unitTest' $ 3 + 3 '===' 6
+  , 'scope' "always passes" $ 'unitTest' 'success' -- record a success result
+  , 'scope' "failing test" $ 'unitTest' $ 'crash' "oh noes!!"
+  ]
 
--- NB: `run suite` would run all tests, but we only run
+-- NB: `'run' suite` would run all tests, but we only run
 -- tests whose scopes are prefixed by "addition"
-main = runOnly "addition" suite
+main :: IO 'Summary'
+main = 'runOnly' "addition" suite
 @
 
 This generates the output:
 
-@
-Randomness seed for this run is 5104092164859451056
-Raw test output to follow ...
-------------------------------------------------------------
-OK addition.ex1
-OK addition.ex2
-OK addition.ex3
-------------------------------------------------------------
-✅  3 tests passed, no failures! 👍 🎉
-@
-
-The idea here is to write tests with ordinary Haskell code, with control flow explicit and under programmer control. Tests are values of type @Test a@, and @Test@ forms a monad with access to:
-
-* repeatable randomness (the @random@ and @random'@ functions for @random@ and bounded random values, or handy specialized @int@, @int'@, @double@, @double'@, etc)
-
-* I/O (via @liftIO@ or @EasyTest.io@, which is an alias for @liftIO@)
-
-* failure (via @crash@, which yields a stack trace, or @fail@, which does not)
-
-* logging (via @note@, @noteScoped@, or @note'@)
-
-* hierarchically-named subcomputations which can be switched on and off (in the above code, notice that only the tests scoped under "addition" are run, and we could do @run@ instead of @runOnly@ if we wanted to run the whole suite)
-
-* parallelism (note the fork which runs that subtree of the test suite in a parallel thread).
-
-* conjunction of tests via @MonadPlus@ (the @<|>@ operation runs both tests, even if the first test fails, and the tests function used above is just @msum@).
-
-* Using any or all of these capabilities, you assemble @Test@ values into a "test suite" (just another @Test@ value) using ordinary Haskell code, not framework magic. Notice that to generate a list of random values, we just @replicateM@ and @forM@ as usual. If this gets tedious... we can factor this logic out into helper functions! For instance:
-
-@
-listOf :: Int -> Test a -> Test [a]
-listOf = replicateM
-
-listsOf :: [Int] -> Test a -> Test [[a]]
-listsOf sizes gen = sizes @`@forM@`@ \\n -> listOf n gen
+> ━━━ runOnly "addition" ━━━
+>   ✓ addition.ex1 passed 1 test.
+>   ✓ addition.ex2 passed 1 test.
+>   ⚐ list.reversal gave up after 1 discard, passed 0 tests.
+>   ✓ addition.ex3 passed 1 test.
+>   ⚐ always passes gave up after 1 discard, passed 0 tests.
+>   ⚐ failing test gave up after 1 discard, passed 0 tests.
+>   ⚐ 3 gave up, 3 succeeded.
 
-ex :: Test ()
-ex = do
-  ns <- listsOf [0..100] int
-  ns @`@forM_@`@ \\ns -> expect (reverse (reverse ns) == ns)
-This library is opinionated and might not be for everyone. If you're curious about any of the design decisions made, see my rationale for writing it.
-@
+We write tests with ordinary Haskell code, with control flow explicit and under programmer control.
 
 = User guide
 
-The simplest tests are @ok@, @crash@, and @expect@:
-
-@
--- Record a success
-ok :: Test ()
-
--- Record a failure
-crash :: String -> Test a
-
--- Record a success if True, otherwise record a failure
-expect :: Bool -> Test ()
-@
-
-NB: @fail@ is equivalent to @crash@, but doesn't provide a stack trace on failure.
-
-We can lift I/O into @Test@ using @io@ (or @liftIO@, but I always forget where to import that from):
-
-@
-io :: IO a -> Test a
-@
-
-@Test@ is also a @Monad@. Note that @return@ and @pure@ do not record a result. Use @ok@, @expect@, or @crash@ for that purpose.
+EasyTest supports two types of tests -- property tests and unit tests. Both are expressed as hedgehog properties ('PropertyT' 'IO' @()@). Unit tests, built with 'unitTest' (or 'example') are run once. Property tests, built with 'property', are run with many random values.
 
-We often want to label tests so we can see when they succeed or fail. For that we use @scope@:
+We often want to label tests so we can see when they succeed or fail. For that we use 'scope':
 
 @
 -- | Label a test. Can be nested. A `'.'` is placed between nested
 -- scopes, so `scope "foo" . scope "bar"` is equivalent to `scope "foo.bar"`
-scope :: String -> Test a -> Test a
+'scope' :: String -> 'Test' -> 'Test'
 @
 
-Here's an example usage, putting all these primitives together:
+Here's an example usage:
 
 @
 module Main where
 
-import EasyTest (ok, scope, crash, expect, run)
-
-suite :: Test ()
-suite = do
-  ok
-  scope "test-crash" $ crash "oh noes!"
-  expect (1 + 1 == 2)
-
-main = run suite
-@
-
-This example is sequencing the @ok@, @crash@, and @expect@ monadically, so the test halts at the first failure. The output is:
-
-@
-Randomness seed for this run is 1830293182471192517
-Raw test output to follow ...
-------------------------------------------------------------
-test-crash FAILURE oh noes! CallStack (from HasCallStack):
-  crash, called at @/@Users@/@pchiusano@/@code@/@easytest@/@tests@/@Suite.hs:10:24 in main:Main
-OK
-FAILED test-crash
-------------------------------------------------------------
-
-
-  1 passed
-  1 FAILED (failed scopes below)
-    "test-crash"
-
-  To rerun with same random seed:
-
-    EasyTest.rerun 1830293182471192517
-    EasyTest.rerunOnly 1830293182471192517 "test-crash"
+import EasyTest
+  ('Test', 'scope', 'crash', 'run', 'tests', 'example', 'success', ('==='), 'Summary')
 
+suite :: 'Test'
+suite = 'tests'
+  [ 'example' 'success'
+  , 'scope' "test-crash" $ 'example' $ 'crash' "oh noes!"
+  , 'example' $ 1 + 1 === 2
+  ]
 
-------------------------------------------------------------
-❌
+main :: IO 'Summary'
+main = 'run' suite
 @
 
-In the output (which is streamed to the console), we get a stack trace pointing to the line where crash was called (@..tests/Suite.hs:10:24@), information about failing tests, and instructions for rerunning the tests with an identical random seed (in this case, there's no randomness, so @rerun@ would work fine, but if our test generated random data, we might want to rerun with the exact same random numbers).
-
-The last line of the output always indicates success or failure of the overall suite... and information about any failing tests is immediately above that. You should NEVER have to scroll through a bunch of test output just to find out which tests actually failed! Also, the streaming output always has @OK@ or @FAILED@ as the leftmost text for ease of scanning.
+This example runs the three examples in order so that they're all tested. The output is:
 
-If you try running a test suite that has no results recorded (like if you have a typo in a call to runOnly, or you forget to use ok or expect to record a test result), you'll see a warning like this:
+> ━━━ run ━━━
+>   ✓ (unnamed) passed 1 test.
+>   ✗ test-crash failed after 1 test.
+>
+>        ┏━━ tests/Suite.hs ━━━
+>      6 ┃ suite :: Test
+>      7 ┃ suite = tests
+>      8 ┃   [ example success
+>      9 ┃   , scope "test-crash" $ example $ crash "oh noes!"
+>        ┃   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+>     10 ┃   , example $ 1 + 1 === 2
+>     11 ┃   ]
+>
+>     oh noes!
+>
+>     This failure can be reproduced by running:
+>     > recheck (Size 0) (Seed 12444749623322829837 10053881125821732685) test-crash
+>
+>   ✓ (unnamed) passed 1 test.
+>   ✗ 1 failed, 2 succeeded.
 
-@
-😶  hmm ... no test results recorded
-Tip: use @`@ok@`@, @`@expect@`@, or @`@crash@`@ to record results
-Tip: if running via @`@runOnly@`@ or @`@rerunOnly@`@, check for typos
-@
+In the output, we get a stack trace pointing to the line where crash was called (@..tests/Suite.hs:9@), information about failing tests, and instructions for rerunning the tests with an identical random seed (in this case, there's no randomness, so @rerun@ would work fine, but if our test generated random data, we might want to rerun with the exact same random numbers). Note that, somewhat embarrassingly, the error message currently gives bad instructions and the correct way to rerun the tests is with @'rerun' (Seed 12444749623322829837 10053881125821732685) suite@.
 
-The various run functions (@run@, @runOnly@, @rerun@, and @rerunOnly@) all exit the process with a nonzero status in the event of a failure, so they can be used for continuous integration or test running tools that key off the process exit code to determine whether the suite succeeded or failed. For instance, here's the relevant portion of a typical cabal file:
+The various run functions ('run', 'runOnly', 'rerun', and 'rerunOnly') all return a hedgehog 'Summary'. Use 'cabalTestSuite' to exit the process with a nonzero status in the event of a failure, for use with @exitcode-stdio-1.0@ cabal @test-suite@s. Here's an example cabal file:
 
 @
--- Preferred way to run EasyTest-based test suite
-executable runtests
-  main-is:        NameOfYourTestSuite.hs
-  ghc-options:    -w -threaded -rtsopts -with-rtsopts=-N -v0
-  hs-source-dirs: tests
-  other-modules:
-  build-depends:
-    base,
-    easytest
-
--- I really have no idea why you'd ever use this, unless you
--- really feel the need to run your tests via cabal's "test runner"
--- which "conveniently" hides all output unless you pass it some
--- random flag I never remember
 test-suite tests
   type:           exitcode-stdio-1.0
   main-is:        NameOfYourTestSuite.hs
-  ghc-options:    -w -threaded -rtsopts -with-rtsopts=-N -v0
   hs-source-dirs: tests
   other-modules:
   build-depends:
@@ -196,77 +115,110 @@
     easytest
 @
 
-For tests that are logically separate, we usually combine them into a suite using @tests@ (which is just @msum@), as in:
+For tests that are logically separate, we usually combine them into a suite using 'tests', as in:
 
 @
-suite = tests
-  [ scope "ex1" $ expect (1 + 1 == 2)
-  , scope "ex2" $ expect (2 + 2 == 4) ]
-
--- equivalently
-suite =
-  (scope "ex1" $ expect (1 + 1 == 2)) '<|>'
-  (scope "ex2" $ expect (2 + 2 == 4))
+suite = 'tests'
+  [ 'scope' "ex1" $ 'example' $ 1 + 1 === 2
+  , 'scope' "ex2" $ 'example' $ 2 + 2 === 4
+  ]
 @
 
-Importantly, each branch of a '<|>' or tests gets its own copy of the randomness source, so even when branches of the test suite are switched on or off, the randomness received by a branch is the same. This is important for being able to quickly iterate on a test failure!
+== Property tests
 
-Sometimes, tests take a while to run and we want to make use of parallelism. For that, use @EasyTest.fork@ or @fork'@:
+We can also create property tests (via hedgehog). As an example, we can express the property that reversing a list twice results in the original list:
 
 @
--- | Run a test in a separate thread, not blocking for its result.
-fork :: Test a -> Test ()
-
--- | Run a test in a separate thread, not blocking for its result, but
--- return a future which can be used to block on the result.
-fork' :: Test a -> Test (Test a)
+reverseTest :: Test ()
+reverseTest = 'scope' "list reversal" $ 'property' $ do
+  nums <- 'forAll' $ Gen.list (Range.linear 0 100) (Gen.int (Range.linear 0 99))
+  reverse (reverse nums) '===' nums
 @
 
-Note: There's no "framework global" parallelism configuration setting.
+The above code generates lists of sizes between 0 and 100, consisting of @Int@ values in the range 0 through 99.
 
-We often want to generate random data for testing purposes:
+If our list reversal test failed, we might use @'runOnly' "list reversal"@ or @'rerunOnly' "list reversal" \<randomseed\>@ to rerun just that subtree of the test suite, and we might add some additional diagnostics to see what was going on:
 
 @
+import           EasyTest
+import qualified Hedgehog.Gen   as Gen
+import qualified Hedgehog.Range as Range
+
 reverseTest :: Test ()
-reverseTest = scope "list reversal" $ do
-  nums <- listsOf [0..100] (int' 0 99)
-  nums `forM_` \nums -> expect (reverse (reverse nums) == nums)
+reverseTest = 'property' $ do
+  nums <- 'forAll' $
+    Gen.list (Range.linear 0 100) (Gen.int (Range.linear 0 99))
+  'footnote' $ "nums: " ++ show nums
+  let r = reverse (reverse nums)
+  'footnote' $ "reverse (reverse nums): " ++ show r
+  r '===' nums
 @
 
-Tip: generate your test cases in order of increasing size. If you get a failure, your test case is closer to "minimal".
-
-The above code generates lists of sizes 0 through 100, consisting of @Int@ values in the range 0 through 99. @int' :: Int -> Int -> Test Int@, and there are analogous functions for @Double@, @Word@, etc. The most general functions are:
+See the <http://hackage.haskell.org/package/hedgehog hedgehog docs> for more on writing good property tests.
 
-@
-random :: Random a => Test a
-random' :: Random a => a -> a -> Test a
-@
+== Bracketed tests
 
-The functions @int@, @char@, @bool@, @double@, etc are just specialized aliases for @random@, and @int'@, @char'@, etc are just aliases for @random'@. The aliases are sometimes useful in situations where use of the generic @random@ or @random'@ would require type annotations.
+EasyTest also supports /bracketed/ tests requiring setup and teardown.
 
-If our list reversal test failed, we might use @runOnly "list reversal"@ or @rerunOnly \<randomseed\> "list reversal"@ to rerun just that subtree of the test suite, and we might add some additional diagnostics to see what was going on:
+For example, we could open a temporary file:
 
 @
-reverseTest :: Test ()
-reverseTest = scope "list reversal" $ do
-  nums <- listsOf [0..100] (int' 0 99)
-  nums `forM_` \nums -> do
-    note $ "nums: " ++ show nums
-    let r = reverse (reverse nums)
-    note $ "reverse (reverse nums): " ++ show r
-    expect (r == nums)
+'scope' "bracket-example" $ 'example' $ 'bracket'
+  (mkstemp "temp")
+  (\(filepath, handle) -> hClose handle >> removeFile filepath)
+  (\(_filepath, handle) -> do
+    liftIO $ hPutStrLn handle "this temporary file will be cleaned up"
+    'success')
 @
 
-The idea is that these sorts of detailed diagnostics are added lazily (and temporarily) to find and fix failing tests. You can also add diagnostics via @io (putStrLn "blah")@, but if you have tests running in parallel this can sometimes get confusing.
-
-That's it! Just use ordinary monadic code to generate any testing data and to run your tests.
+'bracket' ensures that the resource is cleaned up, even if the test throws an
+exception. You can write either property- or unit- tests in this style.
 
 -}
 
 module EasyTest (
-  module EasyTest.Porcelain,
-  module EasyTest.Generators
+  -- * Structuring tests
+    Test
+  , tests
+  , scope
+  , example
+  , unitTest
+  , property
+  , propertyWith
+  -- * Assertions for unit tests
+  , matches
+  , doesn'tMatch
+  , skip
+  , pending
+  , crash
+  -- * Running tests
+  , run
+  , runOnly
+  , rerun
+  , rerunOnly
+  -- * Bracketed tests (requiring setup / teardown)
+  , bracket
+  , bracket_
+  , finally
+  -- * Cabal test suite
+  , cabalTestSuite
+  -- * Hedgehog re-exports
+  -- | These common functions are included as a convenience for writing
+  -- 'propertyTest's. See "Hedgehog" for more.
+  , success
+  , failure
+  , assert
+  , (===)
+  , (/==)
+  , Seed(..)
+  , Summary
+  , footnote
+  , annotate
+  , forAll
+  , PropertyT
+  , PropertyConfig(..)
+  , defaultConfig
   ) where
 
-import EasyTest.Generators
-import EasyTest.Porcelain
+import           EasyTest.Internal
+import           Hedgehog          hiding (Test, property)
diff --git a/src/EasyTest/Generators.hs b/src/EasyTest/Generators.hs
deleted file mode 100644
--- a/src/EasyTest/Generators.hs
+++ /dev/null
@@ -1,144 +0,0 @@
-{-# language Rank2Types #-}
-{-# language ScopedTypeVariables #-}
-module EasyTest.Generators
-  ( -- * Generators
-    random
-  , random'
-  , bool
-  , word8
-  , char
-  , int
-  , double
-  , word
-  , int'
-  , char'
-  , double'
-  , word'
-  , word8'
-  , pick
-  , listOf
-  , listsOf
-  , pair
-  , mapOf
-  , mapsOf
-  ) where
-
-import Control.Applicative
-import Control.Concurrent.STM
-import Control.Monad
-import Control.Monad.IO.Class
-import Control.Monad.Reader
-import Data.Map (Map)
-import Data.Maybe ( fromJust )
-import Data.Word
-import System.Random (Random)
-import qualified Data.Map as Map
-import qualified System.Random as Random
-
-import EasyTest.Internal
-
--- | Generate a random value
-random :: forall a. Random a => Test a
-random = do
-  rng <- asks envRng
-  liftIO . atomically $ do
-    rng0 <- readTVar rng
-    let (a :: a, rng1) = Random.random rng0
-    writeTVar rng rng1
-    pure a
-
--- | Generate a bounded random value. Inclusive on both sides.
-random' :: Random a => a -> a -> Test a
-random' lower upper = do
-  rng <- asks envRng
-  liftIO . atomically $ do
-    rng0 <- readTVar rng
-    let (a, rng1) = Random.randomR (lower,upper) rng0
-    writeTVar rng rng1
-    pure a
-
-bool :: Test Bool
-bool = random
-
-word8 :: Test Word8
-word8 = random
-
--- | Generate a random 'Char'
-char :: Test Char
-char = random
-
--- | Generate a random 'Int'
-int :: Test Int
-int = random
-
--- | Generate a random 'Double'
-double :: Test Double
-double = random
-
--- | Generate a random 'Word'
-word :: Test Word
-word = random
-
--- | Generate a random 'Int' in the given range
--- Note: @int' 0 5@ includes both @0@ and @5@
-int' :: Int -> Int -> Test Int
-int' = random'
-
--- | Generate a random 'Char' in the given range
--- Note: @char' 'a' 'z'@ includes both @'a'@ and @'z'@.
-char' :: Char -> Char -> Test Char
-char' = random'
-
--- | Generate a random 'Double' in the given range
--- Note: @double' 0 1@ includes both @0@ and @1@.
-double' :: Double -> Double -> Test Double
-double' = random'
-
--- | Generate a random 'Double' in the given range
--- Note: @word' 0 10@ includes both @0@ and @10@.
-word' :: Word -> Word -> Test Word
-word' = random'
-
--- | Generate a random 'Double' in the given range
--- Note: @word8' 0 10@ includes both @0@ and @10@.
-word8' :: Word8 -> Word8 -> Test Word8
-word8' = random'
-
--- | Sample uniformly from the given list of possibilities
-pick :: [a] -> Test a
-pick as = let n = length as; ind = picker n as in do
-  i <- int' 0 (n - 1)
-  a <- pure (ind i)
-  pure (fromJust a)             -- TODO: fromJust is not a total function
-
-picker :: Int -> [a] -> (Int -> Maybe a)
-picker _ [] = const Nothing
-picker _ [a] = \i -> if i == 0 then Just a else Nothing
-picker size as = go where
-  lsize = size `div` 2
-  rsize = size - lsize
-  (l,r) = splitAt lsize as
-  lpicker = picker lsize l
-  rpicker = picker rsize r
-  go i = if i < lsize then lpicker i else rpicker (i - lsize)
-
--- | Alias for 'replicateM'
-listOf :: Int -> Test a -> Test [a]
-listOf = replicateM
-
--- | Generate a list of lists of the given sizes,
--- an alias for @sizes \`forM\` \\n -> listOf n gen@
-listsOf :: [Int] -> Test a -> Test [[a]]
-listsOf sizes gen = sizes `forM` \n -> listOf n gen
-
--- | Alias for @liftA2 (,)@.
-pair :: Test a -> Test b -> Test (a,b)
-pair = liftA2 (,)
-
--- | Generate a @Data.Map k v@ of the given size.
-mapOf :: Ord k => Int -> Test k -> Test v -> Test (Map k v)
-mapOf n k v = Map.fromList <$> listOf n (pair k v)
-
--- | Generate a @[Data.Map k v]@ of the given sizes.
-mapsOf :: Ord k => [Int] -> Test k -> Test v -> Test [Map k v]
-mapsOf sizes k v = sizes `forM` \n -> mapOf n k v
diff --git a/src/EasyTest/Internal.hs b/src/EasyTest/Internal.hs
--- a/src/EasyTest/Internal.hs
+++ b/src/EasyTest/Internal.hs
@@ -1,228 +1,360 @@
-{-# language BangPatterns #-}
-{-# language CPP #-}
-{-# language FlexibleContexts #-}
-{-# language FlexibleInstances #-}
-{-# language MultiParamTypeClasses #-}
-{-# language NamedFieldPuns #-}
-{-# language OverloadedStrings #-}
-{-# language ScopedTypeVariables #-}
-
+{-# LANGUAGE LambdaCase        #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE Rank2Types        #-}
+-- |
+-- Module      : EasyTest.Internal
+-- Copyright   : (c) Joel Burget, 2018-2019
+-- License     : MIT
+-- Maintainer  : joelburget@gmail.com
+-- Stability   : experimental
+--
+-- This module defines the core internals and interface of easytest.
 module EasyTest.Internal
-  ( -- * Core
-    crash
-  , note
+  (
+  -- * Structuring tests
+    tests
   , scope
-  , -- * Internal
-    Status(..)
-  , Env(..)
+  , skip
+  , example
+  , unitTest
+  , property
+  , propertyWith
+  -- * Assertions for unit tests
+  , matches
+  , doesn'tMatch
+  , pending
+  , crash
+  -- * Running tests
+  , run
+  , runOnly
+  , rerun
+  , rerunOnly
+  -- * Bracketed tests (requiring setup / teardown)
+  , bracket
+  , bracket_
+  , finally
+  -- * Cabal test suite
+  , cabalTestSuite
+  -- * Internal
+  , Prism
+  , Prism'
+  , TestType(..)
   , Test(..)
-  , actionAllowed
-  , putResult
-  , runWrap
-  , combineStatus
+  -- * Hedgehog re-exports
+  , Property
+  , PropertyT
+  , MonadTest
+  , (===)
+  , (/==)
+  , Seed
+  , Summary(..)
+  , PropertyConfig(..)
+  , defaultConfig
   ) where
 
-import Control.Applicative
-import Control.Concurrent.STM
-import Control.Exception
-import Control.Monad
-import Control.Monad.IO.Class
-import Control.Monad.Reader
-import Data.List (isPrefixOf)
-#if !(MIN_VERSION_base(4,11,0))
-import Data.Semigroup
-#endif
-import Data.String (IsString(..))
-import Data.Text (Text)
-import qualified Data.Text as T
-import GHC.Exts (fromList, toList)
-#if MIN_VERSION_base(4,9,0)
-import GHC.Stack
-#else
-import Data.CallStack
-#endif
-import qualified System.Random as Random
+import           Control.Applicative        (Const(..))
+import qualified Control.Exception          as Ex
+import           Control.Monad.Except
+import           Control.Monad.Trans.Maybe
+import           Control.Monad.Writer
+import           Data.List                  (intercalate)
+import           Data.List.Split            (splitOn)
+import           Data.Monoid (First(..))
+import           Data.Profunctor.Choice
+import           Data.Profunctor.Unsafe
+import           Data.String                (fromString)
+import           System.Exit
 
--- | Status of a test
-data Status = Failed | Passed !Int | Skipped
+import           Hedgehog                   hiding (Test, test, property)
+import           Hedgehog.Internal.Gen      hiding (discard)
+import           Hedgehog.Internal.Property hiding (Test, property, test)
+import           Hedgehog.Internal.Report   (Summary (..))
+import           Hedgehog.Internal.Seed     (random)
+import           Hedgehog.Internal.Source   (HasCallStack, withFrozenCallStack)
+import qualified Hedgehog.Internal.Tree     as HT
 
-combineStatus :: Status -> Status -> Status
-combineStatus Skipped s = s
-combineStatus s Skipped = s
-combineStatus Failed _ = Failed
-combineStatus _ Failed = Failed
-combineStatus (Passed n) (Passed m) = Passed (n + m)
+import           EasyTest.Internal.Hedgehog
 
-instance Semigroup Status where
-  (<>) = combineStatus
+-- | A prism embodies one constructor of a sum type (as a lens embodies one
+-- part of a product type). See 'EasyTest.Prism._Just', 'EasyTest.Prism._Nothing', 'EasyTest.Prism._Left', and 'EasyTest.Prism._Right' for examples. See <http://hackage.haskell.org/package/lens-4.17/docs/Control-Lens-Prism.html Control.Lens.Prism> for more explanation.
+type Prism     s t a b = forall p f. (Choice p, Applicative f) => p a (f b) -> p s (f t)
 
-instance Monoid Status where
-  mempty  = Passed 0
-#if !MIN_VERSION_base(4,11,0)
-  -- This is redudant starting with base-4.11 / GHC 8.4.
-  mappend = combineStatus
-#endif
+-- | A type-restricted prism. See <http://hackage.haskell.org/package/lens-4.17/docs/Control-Lens-Prism.html Control.Lens.Prism> for more explanation.
+type Prism'    s   a   = Prism s s a a
 
-data Env =
-  Env { envRng :: TVar Random.StdGen
-      , envMessages :: [Text]
-      , envResults :: TBQueue (Maybe (TMVar ([Text], Status)))
-      , envNote :: Text -> IO ()
-      , envAllow :: [Text] }
+type Getting r s   a   = (a -> Const r a) -> s -> Const r s
 
--- | Tests are values of type @Test a@, and 'Test' forms a monad with access to:
---
---     * repeatable randomness (the 'EasyTest.random' and 'EasyTest.random'' functions for random and bounded random values, or handy specialized 'EasyTest.int', 'EasyTest.int'', 'EasyTest.double', 'EasyTest.double'', etc)
---
---     * I/O (via 'liftIO' or 'EasyTest.io', which is an alias for 'liftIO')
+-- | Unit- or property- test.
+data TestType = Unit | Prop PropertyConfig
+
+-- | A set of unit- and property-tests
+data Test
+  = NamedTests ![(String, Test)]
+  -- ^ A set of named (scoped) tests
+  | Sequence ![Test]
+  -- ^ A sequence of tests
+  | Leaf !TestType !(PropertyT IO ())
+  -- ^ An atomic unit- or property-test
+  | Skipped !Test
+  -- ^ A set of tests marked to skip
+
+-- | Run a list of tests
+tests :: [Test] -> Test
+tests = Sequence
+
+-- | Label a test. Can be nested. A "." is placed between nested
+-- scopes, so @scope "foo" . scope "bar"@ is equivalent to @scope "foo.bar"@
+scope :: String -> Test -> Test
+scope msg tree =
+  let newScopes = splitSpecifier msg
+  in foldr (\scope' test -> NamedTests [(scope', test)]) tree newScopes
+
+-- | Run a unit test (same as 'unitTest'). Example:
 --
---     * failure (via 'crash', which yields a stack trace, or 'fail', which does not)
+-- >>> run $ example $ 1 === 2
+-- > ━━━ run ━━━
+-- >   ✗ (unnamed) failed after 1 test.
+-- >
+-- >        ┏━━ tests/Suite.hs ━━━
+-- >     26 ┃ main :: IO ()
+-- >     27 ┃ main = do
+-- >     28 ┃   run $ example $ 1 === (2 :: Int)
+-- >        ┃   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+-- >        ┃   │ Failed (- lhs =/= + rhs)
+-- >        ┃   │ - 1
+-- >        ┃   │ + 2
+-- >
+-- >     This failure can be reproduced by running:
+-- >     > recheck (Size 0) (Seed 2914818620245020776 12314041441884757111) (unnamed)
+-- >
+-- >   ✗ 1 failed.
+example :: HasCallStack => PropertyT IO () -> Test
+example = Leaf Unit
+
+-- | Run a unit test (same as 'example'). Example:
 --
---     * logging (via 'EasyTest.note', 'EasyTest.noteScoped', or 'EasyTest.note'')
+-- >>> run $ unitTest $ 1 === 2
+-- > ━━━ run ━━━
+-- >   ✗ (unnamed) failed after 1 test.
+-- >
+-- >        ┏━━ tests/Suite.hs ━━━
+-- >     26 ┃ main :: IO ()
+-- >     27 ┃ main = do
+-- >     28 ┃   run $ unitTest $ 1 === (2 :: Int)
+-- >        ┃   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+-- >        ┃   │ Failed (- lhs =/= + rhs)
+-- >        ┃   │ - 1
+-- >        ┃   │ + 2
+-- >
+-- >     This failure can be reproduced by running:
+-- >     > recheck (Size 0) (Seed 2914818620245020776 12314041441884757111) (unnamed)
+-- >
+-- >   ✗ 1 failed.
+unitTest :: HasCallStack => PropertyT IO () -> Test
+unitTest = Leaf Unit
+
+-- | Run a property test. Example:
 --
---     * hierarchically-named subcomputations (under 'EasyTest.scope') which can be switched on and off via 'EasyTest.runOnly'
+-- >>> run $ scope "list reversal" $ property $ do
+-- >..   list <- forAll $ Gen.list @_ @Int (Range.linear 0 100)
+-- >..     (Gen.element [0..100])
+-- >..   reverse (reverse list) === list
+-- > ━━━ run ━━━
+-- >   ✓ list reversal passed 100 tests.
+-- >   ✓ 1 succeeded.
+property :: HasCallStack => PropertyT IO () -> Test
+property = Leaf (Prop defaultConfig)
+
+-- | Run a property test with a custom configuration. This allows you to configure the 'propertyTestLimit', 'propertyDiscardLimit', 'propertyShrinkLimit', or 'propertyShrinkRetries'. Example:
 --
---     * parallelism (via 'EasyTest.fork')
+-- >>> run $ scope "list reversal" $ propertyWith (defaultConfig { propertyTestLimit = 500 }) $ do
+-- >..   list <- forAll $ Gen.list @_ @Int (Range.linear 0 100)
+-- >..     (Gen.element [0..100])
+-- >..   reverse (reverse list) === list
+-- > ━━━ run ━━━
+-- >   ✓ list reversal passed 500 tests.
+-- >   ✓ 1 succeeded.
+propertyWith :: HasCallStack => PropertyConfig -> PropertyT IO () -> Test
+propertyWith = Leaf . Prop
+
+-- | Make a test with setup and teardown steps.
+bracket :: IO a -> (a -> IO ()) -> (a -> PropertyT IO ()) -> PropertyT IO ()
+bracket setup teardown test
+  = PropertyT $ TestT $ ExceptT $ WriterT $ GenT $ \size seed ->
+      HT.Tree $ MaybeT $ do
+        a <- setup
+        case test a of
+          PropertyT (TestT (ExceptT (WriterT (GenT innerTest)))) -> Ex.finally
+            (runMaybeT $ HT.runTree $ innerTest size seed)
+            (teardown a)
+
+-- | A variant of 'bracket' where the return value from the setup step is not
+-- required.
+bracket_ :: IO a -> IO b -> PropertyT IO () -> PropertyT IO ()
+bracket_ before after thing
+  = bracket before (\_ -> do { _ <- after; pure () }) (const thing)
+
+-- | A specialised variant of 'bracket' with just a teardown step.
+finally :: PropertyT IO () -> IO a -> PropertyT IO ()
+finally test after = bracket_ (pure ()) after test
+
+-- | Split a test specifier into parts
+splitSpecifier :: String -> [String]
+splitSpecifier str = case splitOn "." str of
+  [""] -> []
+  lst  -> lst
+
+foldMapOf :: Getting r s a -> (a -> r) -> s -> r
+foldMapOf l f = getConst #. l (Const #. f)
+{-# INLINE foldMapOf #-}
+
+preview :: Getting (First a) s a -> s -> Maybe a
+preview l = getFirst #. foldMapOf l (First #. Just)
+{-# INLINE preview #-}
+
+-- | Test whether a 'Prism' matches. Example:
 --
---     * conjunction of tests via 'MonadPlus' (the '<|>' operation runs both tests, even if the first test fails, and the tests function used above is just 'msum').
+-- >>> main
+-- > ━━━ run ━━━
+-- >   ✓ (unnamed) passed 1 test.
+-- >   ✗ (unnamed) failed after 1 test.
+-- >
+-- >        ┏━━ tests/Suite.hs ━━━
+-- >     48 ┃ main :: IO ()
+-- >     49 ┃ main = do
+-- >     50 ┃   _ <- run $ tests
+-- >     51 ┃     [ example $ matches _Left (Left 1   :: Either Int ())
+-- >     52 ┃     , example $ matches _Left (Right () :: Either Int ())
+-- >        ┃     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+-- >     53 ┃     ]
+-- >     54 ┃   pure ()
+-- >
+-- >     Prism failed to match
+-- >
+-- >     This failure can be reproduced by running:
+-- >     > recheck (Size 0) (Seed 14003809197113786240 2614482618840800713) (unnamed)
+-- >
+-- >   ✗ 1 failed, 1 succeeded.
 --
--- Using any or all of these capabilities, you assemble 'Test' values into a "test suite" (just another 'Test' value) using ordinary Haskell code, not framework magic. Notice that to generate a list of random values, we just 'replicateM' and 'forM' as usual.
-newtype Test a = Test (ReaderT Env IO (Maybe a))
-
-#if !MIN_VERSION_base(4,9,0)
-prettyCallStack :: CallStack -> String
-prettyCallStack = show
-#endif
+-- Use with 'EasyTest.Prism._Just', 'EasyTest.Prism._Nothing', 'EasyTest.Prism._Left', 'EasyTest.Prism._Right', or <http://hackage.haskell.org/package/lens-4.17/docs/Control-Lens-Prism.html Control.Lens.Prism>
+matches :: HasCallStack => Prism' s a -> s -> PropertyT IO ()
+matches p s = withFrozenCallStack $ case preview p s of
+  Just _  -> success
+  Nothing -> do { footnote "Prism failed to match"; failure }
 
--- | Record a failure at the current scope
-crash :: HasCallStack => Text -> Test a
-crash msg = do
-  let trace = callStack
-      trace' = fromList $ filter
-        (\(_msg, loc) -> srcLocFile loc /= "src/EasyTest/Porcelain.hs")
-        $ toList trace
-      msg' = msg <> " " <> T.pack (prettyCallStack trace')
-  Test (Just <$> putResult Failed)
-  noteScoped ("FAILURE " <> msg')
-  Test (pure Nothing)
+-- | Test whether a 'Prism' doesn't match. Compare with 'matches'.
+doesn'tMatch :: HasCallStack => Prism' s a -> s -> PropertyT IO ()
+doesn'tMatch p s = withFrozenCallStack $ case preview p s of
+  Nothing -> success
+  Just _  -> do { footnote "Prism matched"; failure }
 
-putResult :: Status -> ReaderT Env IO ()
-putResult passed = do
-  msgs <- asks envMessages
-  allow <- asks envAllow
-  r <- liftIO . atomically $ newTMVar
-    (msgs, if allow `isPrefixOf` msgs then passed else Skipped)
-  q <- asks envResults
-  lift . atomically $ writeTBQueue q (Just r)
+-- | Make a 'Hedgehog.Group' from a list of tests.
+mkGroup :: GroupName -> [([String], TestType, PropertyT IO ())] -> Group
+mkGroup name props = Group name $ props <&> \(path, ty, test) ->
+  let name' = case path of
+        [] -> "(unnamed)"
+        _  -> fromString (intercalate "." path)
+      propConf = case ty of
+        Unit      -> PropertyConfig 1 1 0 0
+        Prop conf -> conf
+  in (name', Property propConf test)
 
--- | Label a test. Can be nested. A "." is placed between nested
--- scopes, so @scope "foo" . scope "bar"@ is equivalent to @scope "foo.bar"@
-scope :: Text -> Test a -> Test a
-scope msg (Test t) = Test $ do
-  env <- ask
-  let msg' = T.splitOn "." msg
-      messages' = envMessages env <> msg'
-      env' = env { envMessages = messages' }
-      passes = actionAllowed env'
+-- | Flatten a test tree. Use with 'mkGroup'
+runTree :: Test -> [([String], TestType, PropertyT IO ())]
+runTree = runTree' []
 
-  if passes
-    then liftIO $ runReaderT t env'
-    else putResult Skipped >> pure Nothing
+runTree' :: [String] -> Test -> [([String], TestType, PropertyT IO ())]
+runTree' stack = \case
+  Leaf ty prop     -> [(reverse stack, ty, prop)]
+  Sequence trees   -> concatMap (runTree' stack) trees
+  NamedTests trees -> concatMap go trees
+  Skipped test     -> skipTree' stack test
+  where go (name, tree) = runTree' (name:stack) tree
 
--- | Prepend the current scope to a logging message
-noteScoped :: Text -> Test ()
-noteScoped msg = do
-  s <- currentScope
-  note (T.intercalate "." s <> (if null s then "" else " ") <> msg)
+-- | Flatten a subtree of tests. Use with 'mkGroup'
+runTreeOnly :: [String] -> Test -> [([String], TestType, PropertyT IO ())]
+runTreeOnly = runTreeOnly' [] where
 
--- | Log a message
-note :: Text -> Test ()
-note msg = do
-  note_ <- asks envNote
-  liftIO $ note_ msg
-  pure ()
+  -- Note: In this first case, we override a skip if this test is specifically
+  -- run
+  runTreeOnly' stack []              tree           = runTree' stack tree
+  runTreeOnly' stack (_:_)           tree@Leaf{}    = skipTree' stack tree
+  runTreeOnly' stack scopes          (Sequence trees)
+    = concatMap (runTreeOnly' stack scopes) trees
+  runTreeOnly' stack _               (Skipped tree) = skipTree' stack tree
+  runTreeOnly' stack (scope':scopes) (NamedTests trees)
+    = concatMap go trees
+    where go (name, tree) =
+            if name == scope'
+            then runTreeOnly' (name:stack) scopes tree
+            else skipTree'    (name:stack)        tree
 
--- | The current scope
-currentScope :: Test [Text]
-currentScope = asks envMessages
+-- | Skip this test tree (mark all properties as skipped).
+skipTree' :: [String] -> Test -> [([String], TestType, PropertyT IO ())]
+skipTree' stack = \case
+  -- As a minor hack, we set any skipped test to type 'Unit' so it'll only run
+  -- once.
+  Leaf _ty _test   -> [(reverse stack, Unit, discard)]
+  Sequence trees   -> concatMap (skipTree' stack) trees
+  NamedTests trees -> concatMap go trees
+  Skipped test     -> skipTree' stack test
 
--- | Catch all exceptions that could occur in the given `Test`
-wrap :: Test a -> Test a
-wrap (Test t) = Test $ do
-  env <- ask
-  lift $ runWrap env t
+  where go (name, tree) = skipTree' (name:stack) tree
 
-runWrap :: Env -> ReaderT Env IO (Maybe a) -> IO (Maybe a)
-runWrap env t = do
-  result <- try $ runReaderT t env
-  case result of
-    Left e -> do
-      envNote env $
-           T.intercalate "." (envMessages env)
-        <> " EXCEPTION: "
-        <> T.pack (show (e :: SomeException))
-      runReaderT (putResult Failed) env
-      pure Nothing
-    Right a -> pure a
+-- | Run all tests whose scope starts with the given prefix.
+--
+-- >>> runOnly "components.a" tests
+runOnly :: String -> Test -> IO Summary
+runOnly prefix t = do
+  let props = runTreeOnly (splitSpecifier prefix) t
+      group = mkGroup (fromString $ "runOnly " ++ show prefix) props
 
--- * @allow' `isPrefixOf` messages'@: we're messaging within the allowed range
--- * @messages' `isPrefixOf` allow'@: we're still building a prefix of the
---   allowed range but could go deeper
-actionAllowed :: Env -> Bool
-actionAllowed Env{envMessages = messages, envAllow = allow}
-  = allow `isPrefixOf` messages || messages `isPrefixOf` allow
+  seed <- random
+  recheckSeed seed group
 
-instance MonadReader Env Test where
-  ask = Test $ do
-    allowed <- asks actionAllowed
-    if allowed
-      then Just <$> ask
-      else pure Nothing
-  local f (Test t) = Test (local f t)
-  reader f = Test (Just <$> reader f)
+-- | Rerun all tests with the given seed and whose scope starts with the given
+-- prefix
+--
+-- >>> rerunOnly "components.a" (Seed 2914818620245020776 12314041441884757111) tests
+rerunOnly :: String -> Seed -> Test -> IO Summary
+rerunOnly prefix seed t = do
+  let props = runTreeOnly (splitSpecifier prefix) t
+      name = fromString $ "rerunOnly " ++ show prefix
+  recheckSeed seed $ mkGroup name props
 
-instance Monad Test where
-  fail = crash . T.pack
-  return a = Test $ do
-    allowed <- asks actionAllowed
-    pure $ if allowed
-      then Just a
-      else Nothing
-  Test a >>= f = Test $ do
-    a' <- a
-    case a' of
-      Nothing -> pure Nothing
-      Just a'' -> let Test t = f a'' in t
+-- | Run all tests
+run :: Test -> IO Summary
+run t = do
+  seed <- random
+  recheckSeed seed $ mkGroup "run" $ runTree t
 
-instance Functor Test where
-  fmap = liftM
+-- | Rerun all tests with the given seed
+--
+-- >>> rerun (Seed 2914818620245020776 12314041441884757111) tests
+rerun :: Seed -> Test -> IO Summary
+rerun = rerunOnly ""
 
-instance Applicative Test where
-  pure = return
-  (<*>) = ap
+-- | Explicitly skip this set of tests.
+skip :: Test -> Test
+skip = Skipped
 
-instance MonadIO Test where
-  liftIO action = do
-    allowed <- asks actionAllowed
-    if allowed
-      then wrap $ Test (Just <$> liftIO action)
-      else Test (pure Nothing)
+-- | Mark a test as pending.
+pending :: String -> PropertyT IO ()
+pending msg = do { footnote msg; discard }
 
-instance Alternative Test where
-  empty = Test (pure Nothing)
-  Test t1 <|> Test t2 = Test $ do
-    env <- ask
-    (rng1, rng2) <- liftIO . atomically $ do
-      currentRng <- readTVar (envRng env)
-      let (rng1, rng2) = Random.split currentRng
-      (,) <$> newTVar rng1 <*> newTVar rng2
-    lift $ do
-      _ <- runWrap (env { envRng = rng1 }) t1
-      runWrap (env { envRng = rng2 }) t2
+-- | Record a failure with a given message
+crash :: HasCallStack => String -> PropertyT IO ()
+crash msg = withFrozenCallStack $ do { footnote msg; failure }
 
-instance MonadPlus Test where
-  mzero = empty
-  mplus = (<|>)
+-- | Make this a cabal test suite for use with @exitcode-stdio-1.0@
+-- @test-suite@s.
+--
+-- This simply checks to see if any tests failed and if so exits with
+-- 'exitFailure'.
+cabalTestSuite :: IO Summary -> IO ()
+cabalTestSuite getSummary = do
+  summary <- getSummary
+  if summaryFailed summary > 0 then exitFailure else pure ()
 
-instance IsString (Test a -> Test a) where
-  fromString str = scope (T.pack str)
+(<&>) :: Functor f => f a -> (a -> b) -> f b
+(<&>) = flip fmap
diff --git a/src/EasyTest/Internal/Hedgehog.hs b/src/EasyTest/Internal/Hedgehog.hs
new file mode 100644
--- /dev/null
+++ b/src/EasyTest/Internal/Hedgehog.hs
@@ -0,0 +1,126 @@
+{-# LANGUAGE CPP #-}
+-- |
+-- Module      : EasyTest.Internal.Hedgehog
+-- Copyright   : (c) Joel Burget, 2018-2019
+-- License     : MIT
+-- Maintainer  : joelburget@gmail.com
+-- Stability   : experimental
+--
+-- This module defines 'recheckSeed', which just checks a 'Group' using a given
+-- seed.
+module EasyTest.Internal.Hedgehog (recheckSeed) where
+
+import           Control.Monad.IO.Class
+
+import           Control.Concurrent.STM.TVar (TVar, modifyTVar', newTVar,
+                                              readTVar)
+import           Control.Monad.STM           (atomically)
+#if !(MIN_VERSION_base(4,11,0))
+import           Data.Semigroup
+#endif
+
+import           Hedgehog                    hiding (Test)
+import           Hedgehog.Internal.Config
+import           Hedgehog.Internal.Property
+import           Hedgehog.Internal.Queue
+import           Hedgehog.Internal.Region
+import           Hedgehog.Internal.Report
+import           Hedgehog.Internal.Runner    hiding (checkNamed)
+
+-- | 'Hedgehog.Internal.Runner.checkNamed' modified to take a 'Seed'
+checkNamed ::
+     MonadIO m
+  => Region
+  -> Maybe UseColor
+  -> Maybe PropertyName
+  -> Seed
+  -> Property
+  -> m (Report Result)
+checkNamed region mcolor name seed prop
+  = checkRegion region mcolor name 0 seed prop
+
+-- | 'Hedgehog.Internal.Runner.updateSummary' exposed.
+updateSummary :: Region -> TVar Summary -> Maybe UseColor -> (Summary -> Summary) -> IO ()
+updateSummary sregion svar mcolor f = do
+  summary <- atomically (modifyTVar' svar f >> readTVar svar)
+  setRegion sregion =<< renderSummary mcolor summary
+
+-- | 'Hedgehog.Internal.Runner.checkGroupWith' modified to take a 'Seed'
+checkGroupWith ::
+     WorkerCount
+  -> Verbosity
+  -> Maybe UseColor
+  -> Seed
+  -> [(PropertyName, Property)]
+  -> IO Summary
+checkGroupWith n verbosity mcolor seed props =
+  displayRegion $ \sregion -> do
+    svar <- atomically . newTVar $ mempty { summaryWaiting = PropertyCount (length props) }
+
+    let
+      start (TasksRemaining tasks) _ix (name, prop) =
+        liftIO $ do
+          updateSummary sregion svar mcolor $ \x -> x {
+              summaryWaiting =
+                PropertyCount tasks
+            , summaryRunning =
+                summaryRunning x + 1
+            }
+
+          atomically $ do
+            region <-
+              case verbosity of
+                Quiet ->
+                  newEmptyRegion
+                Normal ->
+                  newOpenRegion
+
+            moveToBottom sregion
+
+            pure (name, prop, region)
+
+      finish (_name, _prop, _region) =
+        updateSummary sregion svar mcolor $ \x -> x {
+            summaryRunning =
+              summaryRunning x - 1
+          }
+
+      finalize (_name, _prop, region) =
+        finishRegion region
+
+    summary <-
+      fmap (mconcat . fmap (fromResult . reportStatus)) $
+        runTasks n props start finish finalize $ \(name, prop, region) -> do
+          result <- checkNamed region mcolor (Just name) seed prop
+          updateSummary sregion svar mcolor
+            (<> fromResult (reportStatus result))
+          pure result
+
+    updateSummary sregion svar mcolor (const summary)
+    pure summary
+
+-- | 'Hedgehog.checkSequential' modified to take a seed and exit on failure
+recheckSeed :: MonadIO m => Seed -> Group -> m Summary
+recheckSeed seed (Group group props) = liftIO $ do
+  let config = RunnerConfig {
+        runnerWorkers =
+          Just 1
+      , runnerColor =
+          Nothing
+      , runnerVerbosity =
+          Nothing
+      }
+  n <- resolveWorkers (runnerWorkers config)
+
+  -- ensure few spare capabilities for concurrent-output, it's likely that
+  -- our tests will saturate all the capabilities they're given.
+  updateNumCapabilities (n + 2)
+
+#if mingw32_HOST_OS
+    hSetEncoding stdout utf8
+    hSetEncoding stderr utf8
+#endif
+  putStrLn $ "━━━ " ++ unGroupName group ++ " ━━━"
+
+  verbosity <- resolveVerbosity (runnerVerbosity config)
+  checkGroupWith n verbosity (runnerColor config) seed props
diff --git a/src/EasyTest/Porcelain.hs b/src/EasyTest/Porcelain.hs
deleted file mode 100644
--- a/src/EasyTest/Porcelain.hs
+++ /dev/null
@@ -1,249 +0,0 @@
-{-# language BangPatterns #-}
-{-# language CPP #-}
-{-# language FlexibleContexts #-}
-{-# language OverloadedStrings #-}
-
-module EasyTest.Porcelain
-  ( -- * Tests
-    Test
-  , expect
-  , expectJust
-  , expectRight
-  , expectRightNoShow
-  , expectLeft
-  , expectLeftNoShow
-  , expectEq
-  , tests
-  , using
-  , runOnly
-  , rerunOnly
-  , run
-  , rerun
-  , scope
-  , note'
-  , ok
-  , skip
-  , fork
-  , fork'
-  , crash
-  , note
-  , io
-  ) where
-
-import Control.Applicative
-import Control.Concurrent
-import Control.Concurrent.STM
-import Control.Exception
-import Control.Monad
-import Control.Monad.IO.Class
-import Control.Monad.Reader
-#if !(MIN_VERSION_base(4,11,0))
-import Data.Semigroup
-#endif
-import Data.Text (Text)
-import qualified Data.Text as T
-import qualified Data.Text.IO as T
-import Data.CallStack
-import System.Exit
-import System.IO
-import qualified Control.Concurrent.Async as A
-import qualified Data.Map as Map
-import qualified System.Random as Random
-
-import EasyTest.Internal
-
--- | Convenient alias for 'liftIO'
-io :: IO a -> Test a
-io = liftIO
-
-expect :: HasCallStack => Bool -> Test ()
-expect False = crash "unexpected"
-expect True = ok
-
-expectJust :: HasCallStack => Maybe a -> Test ()
-expectJust Nothing = crash "expected Just, got Nothing"
-expectJust (Just _) = ok
-
-expectRight :: (Show e, HasCallStack) => Either e a -> Test ()
-expectRight (Left e)  = crash $ "expected Right, got (Left " <> T.pack (show e) <> ")"
-expectRight (Right _) = ok
-
-expectRightNoShow :: HasCallStack => Either e a -> Test ()
-expectRightNoShow (Left _)  = crash $ "expected Right, got Left"
-expectRightNoShow (Right _) = ok
-
-expectLeft :: (Show a, HasCallStack) => Either e a -> Test ()
-expectLeft (Right a) = crash $ "expected Left, got (Right " <> T.pack (show a) <> ")"
-expectLeft (Left _)  = ok
-
-expectLeftNoShow :: HasCallStack => Either e a -> Test ()
-expectLeftNoShow (Right _) = crash $ "expected Left, got Right"
-expectLeftNoShow (Left _)  = ok
-
-expectEq :: (Eq a, Show a, HasCallStack) => a -> a -> Test ()
-expectEq x y = if x == y then ok else crash $
-  "expected to be equal: (" <> show' x <> "), (" <> show' y <> ")"
-
--- | Run a list of tests
---
--- This specializes 'msum', 'Data.Foldable.asum', and 'sequence_'.
-tests :: [Test ()] -> Test ()
-tests = msum
-
-atomicLogger :: IO (Text -> IO ())
-atomicLogger = do
-  lock <- newMVar ()
-  pure $ \msg ->
-    -- force msg before acquiring lock
-    let dummy = T.foldl' (\_ ch -> ch == 'a') True msg
-    in dummy `seq` bracket (takeMVar lock) (\_ -> putMVar lock ()) (\_ -> T.putStrLn $ sanitize msg)
-
-sanitize :: Text -> Text
-sanitize msg = if isUnicodeLocale
-    then msg
-    else T.replace "✅" "!"
-       . T.replace "❌" "X"
-       . T.replace "😶" ":/"
-       . T.replace "👍" ":D"
-       . T.replace "🎉" ":P"
-       $ msg
-
-isUnicodeLocale :: Bool
-isUnicodeLocale = elem (show localeEncoding) $ map show [utf8, utf8_bom, utf16, utf16le, utf16be, utf32, utf32le, utf32be]
-
--- | A test with a setup and teardown
-using :: IO r -> (r -> IO ()) -> (r -> Test a) -> Test a
-using r cleanup use = Test $ do
-  r' <- liftIO r
-  env <- ask
-  let Test t = use r'
-  a <- liftIO (runWrap env t)
-  liftIO (cleanup r')
-  pure a
-
--- | Run all tests whose scope starts with the given prefix
-runOnly :: Text -> Test a -> IO ()
-runOnly prefix t = do
-  logger <- atomicLogger
-  seed <- abs <$> Random.randomIO :: IO Int
-  let allowed = filter (not . T.null) $ T.splitOn "." prefix
-  run' seed logger allowed t
-
--- | Rerun all tests with the given seed and whose scope starts with the given prefix
-rerunOnly :: Int -> Text -> Test a -> IO ()
-rerunOnly seed prefix t = do
-  logger <- atomicLogger
-  let allowed = filter (not . T.null) $ T.splitOn "." prefix
-  run' seed logger allowed t
-
--- | Run all tests
-run :: Test a -> IO ()
-run = runOnly ""
-
--- | Rerun all tests with the given seed
-rerun :: Int -> Test a -> IO ()
-rerun seed = rerunOnly seed ""
-
-run' :: Int -> (Text -> IO ()) -> [Text] -> Test a -> IO ()
-run' seed note_ allowed (Test t) = do
-  let !rng_ = Random.mkStdGen seed
-  resultsQ <- atomically (newTBQueue 50)
-  rngVar <- newTVarIO rng_
-  note_ $ "Randomness seed for this run is " <> show' seed <> ""
-  results <- atomically $ newTVar Map.empty
-  rs <- A.async . forever $ do
-    -- note, totally fine if this bombs once queue is empty
-    Just result <- atomically $ readTBQueue resultsQ
-    (msgs, passed) <- atomically $ takeTMVar result
-    let msgs' = T.intercalate "." msgs
-    atomically $ modifyTVar results (Map.insertWith combineStatus msgs' passed)
-    resultsMap <- readTVarIO results
-    case Map.findWithDefault Skipped msgs' resultsMap of
-      Skipped  -> pure ()
-      Passed n -> note_ $ "OK " <> (if n <= 1 then msgs' else "(" <> show' n <> ") " <> msgs')
-      Failed   -> note_ $ "FAILED " <> msgs'
-  let line = "------------------------------------------------------------"
-  note_ "Raw test output to follow ... "
-  note_ line
-  result <- try (runReaderT (void t) (Env rngVar [] resultsQ note_ allowed))
-    :: IO (Either SomeException ())
-  case result of
-    Left e -> note_ $ "Exception while running tests: " <> show' e
-    Right () -> pure ()
-  atomically $ writeTBQueue resultsQ Nothing
-  _ <- A.waitCatch rs
-  resultsMap <- readTVarIO results
-  let
-    resultsList = Map.toList resultsMap
-    succeededList = [ n | (_, Passed n) <- resultsList ]
-    succeeded = length succeededList
-    -- totalTestCases = foldl' (+) 0 succeededList
-    failures = [ a | (a, Failed) <- resultsList ]
-    failed = length failures
-  case failures of
-    [] -> do
-      note_ line
-      case succeeded of
-        0 -> do
-          note_ $ T.unlines
-            [ "😶  hmm ... no test results recorded"
-            , "Tip: use `ok`, `expect`, or `crash` to record results"
-            , "Tip: if running via `runOnly` or `rerunOnly`, check for typos"
-            ]
-        1 -> note_   "✅  1 test passed, no failures! 👍 🎉"
-        _ -> note_ $ "✅  " <> show' succeeded <> " tests passed, no failures! 👍 🎉"
-    hd:_ -> do
-      note_ $ T.unlines
-        [ line
-        , "\n"
-        , "  " <> show' succeeded <> (if failed == 0 then " PASSED" else " passed")
-        , "  " <> show' (length failures) <> (if failed == 0 then " failed" else " FAILED (failed scopes below)")
-        , "    " <> T.intercalate "\n    " (map show' failures)
-        , ""
-        , "  To rerun with same random seed:\n"
-        , "    EasyTest.rerun " <> show' seed
-        , "    EasyTest.rerunOnly " <> show' seed <> " " <> "\"" <> hd <> "\""
-        , "\n"
-        , line
-        , "❌"
-        ]
-      exitWith (ExitFailure 1)
-
--- TODO: replace with show-text?
-show' :: Show a => a -> Text
-show' = T.pack . show
-
--- | Log a showable value
-note' :: Show s => s -> Test ()
-note' = note . show'
-
--- | Record a successful test at the current scope
-ok :: Test ()
-ok = Test (Just <$> putResult (Passed 1))
-
--- | Explicitly skip this test
-skip :: Test ()
-skip = Test (Nothing <$ putResult Skipped)
-
--- | Run a test in a separate thread, not blocking for its result.
-fork :: Test a -> Test ()
-fork t = void (fork' t)
-
--- | Run a test in a separate thread, return a future which can be used
--- to block on its result.
-fork' :: Test a -> Test (Test a)
-fork' (Test t) = do
-  env <- ask
-  tmvar <- liftIO newEmptyTMVarIO
-  liftIO . atomically $ writeTBQueue (envResults env) (Just tmvar)
-  r <- liftIO . A.async $ runWrap env t
-  waiter <- liftIO . A.async $ do
-    e <- A.waitCatch r
-    _ <- atomically $ tryPutTMVar tmvar (envMessages env, Skipped)
-    case e of
-      Left _ -> pure Nothing
-      Right a -> pure a
-  pure $ do
-    a <- liftIO (A.wait waiter)
-    case a of Nothing -> empty
-              Just a' -> pure a'
diff --git a/src/EasyTest/Prism.hs b/src/EasyTest/Prism.hs
new file mode 100644
--- /dev/null
+++ b/src/EasyTest/Prism.hs
@@ -0,0 +1,111 @@
+{-# LANGUAGE Rank2Types        #-}
+-- |
+-- Module      : EasyTest.Prism
+-- Copyright   : (c) Joel Burget, 2018-2019; Edward Kmett 2012-2016
+-- License     : MIT
+-- Maintainer  : joelburget@gmail.com
+-- Stability   : experimental
+--
+-- This module defines lens-style prisms for use with 'EasyTest.matches' /
+-- 'EasyTest.doesn'tMatch'. These are equivalent and compatible with the
+-- definitions in <http://hackage.haskell.org/package/lens-4.17/docs/Control-Lens-Prism.html Control.Lens.Prism>.
+
+-- Copyright 2012-2016 Edward Kmett
+-- All rights reserved.
+--
+-- Redistribution and use in source and binary forms, with or without
+-- modification, are permitted provided that the following conditions
+-- are met:
+--
+-- 1. Redistributions of source code must retain the above copyright
+--    notice, this list of conditions and the following disclaimer.
+--
+-- 2. Redistributions in binary form must reproduce the above copyright
+--    notice, this list of conditions and the following disclaimer in the
+--    documentation and/or other materials provided with the distribution.
+--
+-- THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR
+-- IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+-- WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+-- DISCLAIMED.  IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR
+-- ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+-- DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+-- OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+-- HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+-- STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+-- ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+-- POSSIBILITY OF SUCH DAMAGE.
+module EasyTest.Prism
+  ( _Left
+  , _Right
+  , _Just
+  , _Nothing
+  , only
+  , nearly
+  , Prism
+  , Prism'
+  ) where
+
+import           Control.Monad          (guard)
+import           Data.Profunctor        (dimap, right')
+import           EasyTest.Internal      (Prism, Prism')
+
+prism :: (b -> t) -> (s -> Either t a) -> Prism s t a b
+prism bt seta = dimap seta (either pure (fmap bt)) . right'
+{-# INLINE prism #-}
+
+prism' :: (b -> s) -> (s -> Maybe a) -> Prism s s a b
+prism' bs sma = prism bs (\s -> maybe (Left s) Right (sma s))
+{-# INLINE prism' #-}
+
+-- | 'Prism' to the 'Left' half of an 'Either'
+_Left :: Prism (Either a c) (Either b c) a b
+_Left = prism Left $ either Right (Left . Right)
+{-# INLINE _Left #-}
+
+-- | 'Prism' to the 'Right' half of an 'Either'
+_Right :: Prism (Either c a) (Either c b) a b
+_Right = prism Right $ either (Left . Left) Right
+{-# INLINE _Right #-}
+
+-- | 'Prism' to the 'Just' in a 'Maybe'
+_Just :: Prism (Maybe a) (Maybe b) a b
+_Just = prism Just $ maybe (Left Nothing) Right
+{-# INLINE _Just #-}
+
+-- | 'Prism' to the 'Nothing' in a 'Maybe'
+_Nothing :: Prism' (Maybe a) ()
+_Nothing = prism' (const Nothing) $ maybe (Just ()) (const Nothing)
+{-# INLINE _Nothing #-}
+
+-- | This 'Prism' compares for exact equality with a given value.
+--
+-- >>> only 4 # ()
+-- 4
+--
+-- >>> 5 ^? only 4
+-- Nothing
+only :: Eq a => a -> Prism' a ()
+only a = prism' (\() -> a) $ guard . (a ==)
+{-# INLINE only #-}
+
+-- | This 'Prism' compares for approximate equality with a given value and a predicate for testing,
+-- an example where the value is the empty list and the predicate checks that a list is empty (same
+-- as 'Control.Lens.Empty._Empty' with the 'Control.Lens.Empty.AsEmpty' list instance):
+--
+-- >>> nearly [] null # ()
+-- []
+-- >>> [1,2,3,4] ^? nearly [] null
+-- Nothing
+--
+-- @'nearly' [] 'Prelude.null' :: 'Prism'' [a] ()@
+--
+-- To comply with the 'Prism' laws the arguments you supply to @nearly a p@ are somewhat constrained.
+--
+-- We assume @p x@ holds iff @x ≡ a@. Under that assumption then this is a valid 'Prism'.
+--
+-- This is useful when working with a type where you can test equality for only a subset of its
+-- values, and the prism selects such a value.
+nearly :: a -> (a -> Bool) -> Prism' a ()
+nearly a p = prism' (\() -> a) $ guard . p
+{-# INLINE nearly #-}
diff --git a/tests/Suite.hs b/tests/Suite.hs
--- a/tests/Suite.hs
+++ b/tests/Suite.hs
@@ -1,45 +1,77 @@
-{-# language OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
 module Main where
 
-import EasyTest
-import Control.Monad
+import           Control.Monad.IO.Class (liftIO)
+import           Hedgehog               (forAll, (===))
+import qualified Hedgehog.Gen           as Gen
+import qualified Hedgehog.Range         as Range
+import           System.Directory       (removeFile)
+import           System.IO              (hClose, hPutStrLn)
+import           System.Posix.Temp
 
-suite1 :: Test ()
-suite1 = tests
-  [ scope "a" ok
-  , scope "b.c" ok
-  , scope "b" ok
-  , scope "b" . scope "c" . scope "d" $ ok
+import           EasyTest
+import           EasyTest.Prism
 
-  -- you can also drop the "scope"
-  , "c" ok
+suite1 :: Test
+suite1 = tests
+  [ scope "a" $ unitTest success
+  , scope "b.c" $ unitTest success
+  , scope "b" $ unitTest success
+  , scope "b" . scope "c" . scope "d" $ unitTest success
   ]
 
-reverseTest :: Test ()
-reverseTest = scope "list reversal" $ do
-  lists <- listsOf [0..100] (int' 0 99)
-  forM_ lists $ \nums -> expect (reverse (reverse nums) == nums)
+reverseTest :: Test
+reverseTest = scope "list reversal" $ propertyWith (defaultConfig { propertyTestLimit = 500 }) $ do
+  (list :: [Int]) <- forAll $ Gen.list (Range.linear 0 100)
+    (Gen.element [0..100])
+  reverse (reverse list) === list
 
 main :: IO ()
 main = do
-  run suite1
-  runOnly "a" suite1
-  runOnly "b" suite1
-  runOnly "b" $ tests [suite1, scope "xyz" (crash "never run")]
-  runOnly "b.c" $ tests [suite1, scope "b" (crash "never run")]
-  runOnly "x.go" $ tests
-    [ scope "x.go to" (crash "never run")
-    , scope "x.go" ok
+  _ <- run $ example $ 1 === (1 :: Int)
+  _ <- run suite1
+  _ <- runOnly "a" suite1
+  _ <- runOnly "b" suite1
+  _ <- runOnly "b" $ tests [suite1, scope "xyz" (unitTest (crash "never run"))]
+  _ <- runOnly "b.c" $ tests [suite1, scope "b" (unitTest (crash "never run"))]
+  _ <- runOnly "x.go" $ tests
+    [ scope "x.go to" $ unitTest $ crash "never run"
+    , scope "x.go" $ unitTest success
     ]
-  runOnly "x.go to" $ tests
-    [ scope "x.go to" ok
-    , scope "x.go" (crash "never run")
+  _ <- runOnly "x.go to" $ tests
+    [ scope "x.go to" $ unitTest success
+    , scope "x.go" $ unitTest $ crash "never run"
     ]
-  run reverseTest
+  _ <- run reverseTest
 
-  run $ tests
-    [ expectLeft        (Left 1   :: Either Int ())
-    , expectLeftNoShow  (Left 2   :: Either Int ())
-    , expectRight       (Right () :: Either Int ())
-    , expectRightNoShow (Right () :: Either Int ())
-    ]
+  _ <- run $
+    let l1, ru :: Either Int ()
+        l1 = Left 1
+        ru = Right ()
+        nullP, length2P :: Prism' [Int] ()
+        nullP    = nearly [] null
+        length2P = nearly [1, 2] $ \lst -> length lst == 2
+    in scope "prisms" $ tests
+         [ example $ matches      _Left             l1
+         , example $ doesn'tMatch _Right            l1
+         , example $ matches      (_Left . only 1)  l1
+         , example $ doesn'tMatch (_Left . only 2)  l1
+         , example $ matches      _Right            ru
+         , example $ doesn'tMatch _Left             ru
+         , example $ matches      nullP             []
+         , example $ matches      length2P          [1, 2]
+         , example $ matches      length2P          [2, 2]
+         , example $ doesn'tMatch length2P          []
+
+         -- Uncomment for an example diff:
+         -- , expectEq          "foo\nbar\nbaz" ("foo\nquux\nbaz" :: String)
+         ]
+
+  _ <- run $ scope "bracket" $ example $ bracket
+    (mkstemp "temp")
+    (\(filepath, handle) -> hClose handle >> removeFile filepath)
+    (\(_filepath, handle) -> do
+      liftIO $ hPutStrLn handle "this temporary file will be cleaned up"
+      success)
+
+  pure ()
