diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,9 @@
+# daytripper
+
+Helpers for round-trip tests
+
+You can control some parameters through Tasty:
+
+`--daydripper-write-missing` or `TASTY_DAYTRIPPER_WRITE_MISSING=True` will write golden files if not found.
+
+`--falsify-tests=N` or `TASTY_FALSIFY_TESTS=N` will set the number of tests per property.
diff --git a/daytripper.cabal b/daytripper.cabal
new file mode 100644
--- /dev/null
+++ b/daytripper.cabal
@@ -0,0 +1,114 @@
+cabal-version: 1.12
+
+-- This file has been generated from package.yaml by hpack version 0.35.2.
+--
+-- see: https://github.com/sol/hpack
+
+name:           daytripper
+version:        0.1.0
+synopsis:       Helpers for round-trip tests
+description:    Please see the README on GitHub at <https://github.com/ejconlon/daytripper#readme>
+homepage:       https://github.com/ejconlon/daytripper#readme
+bug-reports:    https://github.com/ejconlon/daytripper/issues
+author:         Eric Conlon
+maintainer:     ejconlon@gmail.com
+copyright:      (c) 2023 Eric Conlon
+license:        BSD3
+build-type:     Simple
+tested-with:
+    GHC == 9.2.7
+extra-source-files:
+    README.md
+
+source-repository head
+  type: git
+  location: https://github.com/ejconlon/daytripper
+
+library
+  exposed-modules:
+      Test.Daytripper
+  other-modules:
+      Paths_daytripper
+  hs-source-dirs:
+      src
+  default-extensions:
+      BangPatterns
+      ConstraintKinds
+      DataKinds
+      DeriveFunctor
+      DeriveFoldable
+      DeriveGeneric
+      DeriveTraversable
+      DerivingStrategies
+      DerivingVia
+      FlexibleContexts
+      FlexibleInstances
+      FunctionalDependencies
+      GADTs
+      GeneralizedNewtypeDeriving
+      LambdaCase
+      KindSignatures
+      MultiParamTypeClasses
+      Rank2Types
+      ScopedTypeVariables
+      StandaloneDeriving
+      TupleSections
+      TypeApplications
+      TypeOperators
+      TypeFamilies
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints -fno-warn-unused-top-binds
+  build-depends:
+      base >=4.12 && <5
+    , bytestring ==0.11.*
+    , directory ==1.3.*
+    , falsify ==0.1.*
+    , optparse-applicative ==0.17.*
+    , tagged ==0.8.*
+    , tasty ==1.4.*
+    , tasty-hunit ==0.10.*
+  default-language: GHC2021
+
+test-suite daytripper-test
+  type: exitcode-stdio-1.0
+  main-is: Main.hs
+  other-modules:
+      Paths_daytripper
+  hs-source-dirs:
+      test
+  default-extensions:
+      BangPatterns
+      ConstraintKinds
+      DataKinds
+      DeriveFunctor
+      DeriveFoldable
+      DeriveGeneric
+      DeriveTraversable
+      DerivingStrategies
+      DerivingVia
+      FlexibleContexts
+      FlexibleInstances
+      FunctionalDependencies
+      GADTs
+      GeneralizedNewtypeDeriving
+      LambdaCase
+      KindSignatures
+      MultiParamTypeClasses
+      Rank2Types
+      ScopedTypeVariables
+      StandaloneDeriving
+      TupleSections
+      TypeApplications
+      TypeOperators
+      TypeFamilies
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints -fno-warn-unused-top-binds -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      base >=4.12 && <5
+    , bytestring ==0.11.*
+    , daytripper
+    , directory ==1.3.*
+    , falsify ==0.1.*
+    , optparse-applicative ==0.17.*
+    , tagged ==0.8.*
+    , tasty ==1.4.*
+    , tasty-hunit ==0.10.*
+  default-language: GHC2021
diff --git a/src/Test/Daytripper.hs b/src/Test/Daytripper.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Daytripper.hs
@@ -0,0 +1,186 @@
+module Test.Daytripper
+  ( MonadExpect (..)
+  , Expect
+  , expectBefore
+  , expectDuring
+  , expectAfter
+  , mkExpect
+  , RT
+  , mkPropRT
+  , mkFileRT
+  , mkUnitRT
+  , testRT
+  , DaytripperWriteMissing (..)
+  , daytripperIngredients
+  , daytripperMain
+  )
+where
+
+import Control.Monad (void)
+import Data.ByteString (ByteString)
+import Data.ByteString qualified as BS
+import Data.Foldable (for_)
+import Data.Proxy (Proxy (..))
+import Data.Tagged (Tagged, untag)
+import Options.Applicative (flag', help, long)
+import System.Directory (doesFileExist)
+import System.IO.Unsafe (unsafePerformIO)
+import Test.Falsify.Generator (Gen)
+import Test.Falsify.Predicate qualified as FR
+import Test.Falsify.Property (Property)
+import Test.Falsify.Property qualified as FP
+import Test.Tasty (TestName, TestTree, askOption, defaultIngredients, defaultMainWithIngredients, includingOptions)
+import Test.Tasty.Falsify (testProperty)
+import Test.Tasty.HUnit (testCase, (@?=))
+import Test.Tasty.Ingredients (Ingredient)
+import Test.Tasty.Options (IsOption (..), OptionDescription (..), safeRead)
+
+-- | Interface for asserting and performing IO in tests.
+-- TODO Migrate to 'MonadIO' superclass when Falsify supports it.
+class MonadFail m => MonadExpect m where
+  expectLiftIO :: IO a -> m a
+  expectAssertEq :: (Eq a, Show a) => a -> a -> m ()
+
+instance MonadExpect IO where
+  expectLiftIO = id
+  expectAssertEq = (@?=)
+
+instance MonadExpect Property where
+  expectLiftIO = pure . unsafePerformIO
+  expectAssertEq x y = FP.assert (FR.eq FR..$ ("LHS", x) FR..$ ("RHS", y))
+
+-- | A general type of test expectation. Captures two stages of processing an input,
+-- first encoding, then decoding. The monad is typically something implementing
+-- 'MonadExpect', with assertions performed before returning values for further processing.
+-- The input is possibly missing, in which case we test decoding only.
+type Expect m a b c = Either b a -> m (b, m c)
+
+-- | Assert something before processing (before encoding and before decoding)
+expectBefore :: Monad m => (a -> m ()) -> Expect m a b c -> Expect m a b c
+expectBefore f ex i = for_ i f >> ex i
+
+-- | Assert something during processing (after encoding and before decoding)
+expectDuring :: Monad m => (a -> b -> m ()) -> Expect m a b c -> Expect m a b c
+expectDuring f ex i = ex i >>= \p@(b, _) -> p <$ for_ i (`f` b)
+
+-- | Asserting something after processing (after encoding and after decoding)
+expectAfter :: Monad m => (a -> b -> c -> m ()) -> Expect m a b c -> Expect m a b c
+expectAfter f ex i = ex i >>= \(b, end) -> end >>= \c -> (b, pure c) <$ for_ i (\a -> f a b c)
+
+-- | One way of definining expectations from a pair of encode/decode functions.
+-- Generalizes decoding in 'Maybe' or 'Either'.
+mkExpect
+  :: (MonadExpect m, Eq (f a), Show (f a), Applicative f)
+  => (a -> m b)
+  -> (b -> m (f a))
+  -> Expect m a b (f a)
+mkExpect f g i = do
+  b <- either pure f i
+  pure . (b,) $ do
+    fa <- g b
+    for_ i (expectAssertEq fa . pure)
+    pure fa
+
+-- | Simple way to run an expectation, ignoring the intermediate value.
+runExpect :: Monad m => Expect m a b c -> a -> m c
+runExpect f a = f (Right a) >>= snd
+
+data PropRT where
+  PropRT :: Show a => TestName -> Expect Property a b c -> Gen a -> PropRT
+
+-- | Create a property-based roundtrip test
+mkPropRT :: Show a => TestName -> Expect Property a b c -> Gen a -> RT
+mkPropRT name expec gen = RTProp (PropRT name expec gen)
+
+testPropRT :: PropRT -> TestTree
+testPropRT (PropRT name expec gen) =
+  testProperty name (FP.gen gen >>= void . runExpect expec)
+
+data FileRT where
+  FileRT
+    :: TestName
+    -> Expect IO a ByteString c
+    -> FilePath
+    -> Maybe a
+    -> FileRT
+
+-- | Create a file-based ("golden") roundtrip test
+mkFileRT
+  :: TestName
+  -> Expect IO a ByteString c
+  -> FilePath
+  -> Maybe a
+  -> RT
+mkFileRT name expec fn mval = RTFile (FileRT name expec fn mval)
+
+testFileRT :: FileRT -> TestTree
+testFileRT (FileRT name expec fn mval) = askOption $ \dwm ->
+  testCase name $ do
+    exists <- doesFileExist fn
+    (mcon, eval) <-
+      if exists
+        then do
+          con <- BS.readFile fn
+          pure (Just con, maybe (Left con) Right mval)
+        else case (dwm, mval) of
+          (DaytripperWriteMissing True, Just val) -> pure (Nothing, Right val)
+          _ -> fail ("File missing: " ++ fn)
+    (bs, end) <- expec eval
+    for_ mcon (bs @?=)
+    _ <- end
+    case mcon of
+      Nothing -> BS.writeFile fn bs
+      Just _ -> pure ()
+
+data UnitRT where
+  UnitRT :: TestName -> Expect IO a b c -> a -> UnitRT
+
+-- | Create a unit roundtrip test
+mkUnitRT :: TestName -> Expect IO a b c -> a -> RT
+mkUnitRT name expec val = RTUnit (UnitRT name expec val)
+
+testUnitRT :: UnitRT -> TestTree
+testUnitRT (UnitRT name expec val) =
+  testCase name (void (runExpect expec val))
+
+data RT
+  = RTProp !PropRT
+  | RTFile !FileRT
+  | RTUnit !UnitRT
+
+-- | Run a roundtrip test
+testRT :: RT -> TestTree
+testRT = \case
+  RTProp x -> testPropRT x
+  RTFile x -> testFileRT x
+  RTUnit x -> testUnitRT x
+
+-- | By passing the appropriate arguments to Tasty (`--daytripper-write-missing` or
+-- `TASTY_DAYTRIPPER_WRITE_MISSING=True`) we can fill in the contents of missing files
+-- with the results of running tests.
+newtype DaytripperWriteMissing = DaytripperWriteMissing {unDaytripperWriteMissing :: Bool}
+  deriving stock (Show)
+  deriving newtype (Eq, Ord)
+
+instance IsOption DaytripperWriteMissing where
+  defaultValue = DaytripperWriteMissing False
+  parseValue = fmap DaytripperWriteMissing . safeRead
+  optionName = return "daytripper-write-missing"
+  optionHelp = return "Write missing test files"
+  optionCLParser =
+    DaytripperWriteMissing
+      <$> flag'
+        True
+        ( long (untag (optionName :: Tagged DaytripperWriteMissing String))
+            <> help (untag (optionHelp :: Tagged DaytripperWriteMissing String))
+        )
+
+-- | Tasty ingredients with write-missing support
+daytripperIngredients :: [Ingredient]
+daytripperIngredients =
+  includingOptions [Option (Proxy :: Proxy DaytripperWriteMissing)]
+    : defaultIngredients
+
+-- | Tasty main with write-missing support
+daytripperMain :: TestTree -> IO ()
+daytripperMain = defaultMainWithIngredients daytripperIngredients
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,36 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+module Main
+  ( main
+  )
+where
+
+import Data.ByteString (ByteString)
+import Data.ByteString qualified as BS
+import Test.Daytripper (Expect, MonadExpect (..), daytripperMain, mkExpect, mkFileRT, mkPropRT, mkUnitRT, testRT)
+import Test.Falsify.Generator qualified as Gen
+import Test.Tasty (testGroup)
+
+expec :: MonadExpect m => Expect m ByteString ByteString (Maybe ByteString)
+expec = mkExpect enc dec
+ where
+  enc a = pure (a <> a)
+  dec b =
+    pure $
+      let a = BS.take (div (BS.length b) 2) b
+      in  if b == a <> a
+            then Just a
+            else Nothing
+
+main :: IO ()
+main =
+  daytripperMain $
+    testGroup "Daytripper" $
+      fmap
+        testRT
+        [ mkPropRT "prop" expec (Gen.choose (pure "a") (pure "b"))
+        , mkUnitRT "unit" expec "a"
+        , mkFileRT "file just" expec "testdata/b.txt" (Just "b")
+        , mkFileRT "file nothing" expec "testdata/c.txt" Nothing
+        ]
