diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,25 @@
+The following license covers this documentation, and the source code, except
+where otherwise indicated.
+
+Copyright 2010, Michael Snoyman. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* 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 COPYRIGHT HOLDERS "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 COPYRIGHT HOLDERS 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.
diff --git a/Network/Wai/Test.hs b/Network/Wai/Test.hs
new file mode 100644
--- /dev/null
+++ b/Network/Wai/Test.hs
@@ -0,0 +1,144 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Network.Wai.Test
+    ( -- * Session
+      Session
+    , runSession
+      -- * Requests
+    , request
+    , srequest
+    , SRequest (..)
+    , SResponse (..)
+      -- * Assertions
+    , assertStatus
+    , assertContentType
+    , assertBody
+    , assertHeader
+    , assertNoHeader
+    ) where
+import Network.Wai
+import qualified Test.HUnit.Base as H
+import Control.Monad.IO.Class (liftIO)
+import Control.Monad.Trans.Class (lift)
+import Control.Monad.Trans.State (StateT, evalStateT, get, put)
+import Control.Monad.Trans.Reader (ReaderT, runReaderT, ask)
+import Data.Map (Map)
+import qualified Data.Map as Map
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as S
+import qualified Data.ByteString.Char8 as S8
+import Data.Enumerator (joinI, consume, ($$), run_, enumList)
+import Blaze.ByteString.Builder.Enumerator (builderToByteString)
+import qualified Data.ByteString.Lazy as L
+import qualified Data.ByteString.Lazy.Char8 as L8
+
+type Session = ReaderT Application (StateT ClientState IO)
+
+data ClientState = ClientState
+    { clientCookies :: Map ByteString ByteString
+    }
+
+initState :: ClientState
+initState = ClientState Map.empty
+
+runSession :: Session a -> Application -> IO a
+runSession session app = evalStateT (runReaderT session app) initState
+
+data SRequest = SRequest
+    { simpleRequest :: Request
+    , simpleRequestBody :: L.ByteString
+    }
+data SResponse = SResponse
+    { simpleStatus :: Status
+    , simpleHeaders :: [(CIByteString, ByteString)]
+    , simpleBody :: L.ByteString
+    }
+    deriving (Show, Eq)
+request :: Request -> Session SResponse
+request = srequest . flip SRequest L.empty
+
+srequest :: SRequest -> Session SResponse
+srequest (SRequest req bod) = do
+    app <- ask
+    res <- liftIO $ run_ $ enumList 4 (L.toChunks bod) $$ app req
+    sres <- liftIO $ runResponse res
+    -- FIXME cookie processing
+    return sres
+
+runResponse :: Response -> IO SResponse
+runResponse res =
+    responseEnumerator res go
+  where
+    go s h = do
+        bss <- joinI $ builderToByteString $$ consume
+        return $ SResponse s h $ L.fromChunks bss
+
+assertBool :: String -> Bool -> Session ()
+assertBool s b = liftIO $ H.assertBool s b
+
+assertString :: String -> Session ()
+assertString s = liftIO $ H.assertString s
+
+assertContentType :: ByteString -> SResponse -> Session ()
+assertContentType ct SResponse{simpleHeaders = h} =
+    case lookup "content-type" h of
+        Nothing -> assertString $ concat
+            [ "Expected content type "
+            , show ct
+            , ", but no content type provided"
+            ]
+        Just ct' -> assertBool (concat
+            [ "Expected content type "
+            , show ct
+            , ", but received "
+            , show ct'
+            ]) (go ct == go ct')
+  where
+    go = S8.takeWhile (/= ';')
+
+assertStatus :: Int -> SResponse -> Session ()
+assertStatus i SResponse{simpleStatus = s} = assertBool (concat
+    [ "Expected status code "
+    , show i
+    , ", but received "
+    , show sc
+    ]) $ i == sc
+  where
+    sc = statusCode s
+
+assertBody :: L.ByteString -> SResponse -> Session ()
+assertBody lbs SResponse{simpleBody = lbs'} = assertBool (concat
+    [ "Expected response body "
+    , show $ L8.unpack lbs
+    , ", but received "
+    , show $ L8.unpack lbs'
+    ]) $ lbs == lbs'
+
+assertHeader :: CIByteString -> S.ByteString -> SResponse -> Session ()
+assertHeader header value SResponse{simpleHeaders = h} =
+    case lookup header h of
+        Nothing -> assertString $ concat
+            [ "Expected header "
+            , show header
+            , " to be "
+            , show value
+            , ", but it was not present"
+            ]
+        Just value' -> assertBool (concat
+            [ "Expected header "
+            , show header
+            , " to be "
+            , show value
+            , ", but received "
+            , show value'
+            ]) (value == value')
+
+assertNoHeader :: CIByteString -> SResponse -> Session ()
+assertNoHeader header SResponse{simpleHeaders = h} =
+    case lookup header h of
+        Nothing -> return ()
+        Just s -> assertString $ concat
+            [ "Unexpected header "
+            , show header
+            , " containing "
+            , show s
+            ]
diff --git a/Setup.lhs b/Setup.lhs
new file mode 100644
--- /dev/null
+++ b/Setup.lhs
@@ -0,0 +1,8 @@
+#!/usr/bin/env runhaskell
+
+> module Main where
+> import Distribution.Simple
+> import System.Cmd (system)
+
+> main :: IO ()
+> main = defaultMain
diff --git a/wai-test.cabal b/wai-test.cabal
new file mode 100644
--- /dev/null
+++ b/wai-test.cabal
@@ -0,0 +1,30 @@
+name:            wai-test
+version:         0.0.0
+license:         BSD3
+license-file:    LICENSE
+author:          Michael Snoyman <michael@snoyman.com>
+maintainer:      Michael Snoyman <michael@snoyman.com>
+synopsis:        Unit test framework (built on HUnit) for WAI applications.
+category:        Testing, Web, Yesod
+stability:       Stable
+cabal-version:   >= 1.6
+build-type:      Simple
+homepage:        http://github.com/snoyberg/wai-test/
+
+library
+    build-depends:   base                      >= 4        && < 5
+                   , wai                       >= 0.3      && < 0.4
+                   , bytestring                >= 0.9.1.4  && < 0.10
+                   , blaze-builder             >= 0.2.1    && < 0.3
+                   , transformers              >= 0.2      && < 0.3
+                   , containers                >= 0.2      && < 0.5
+                   , enumerator                >= 0.4      && < 0.5
+                   , cookie                    >= 0.0      && < 0.1
+                   , blaze-builder-enumerator  >= 0.2      && < 0.3
+                   , HUnit                     >= 1.2      && < 1.3
+    exposed-modules: Network.Wai.Test
+    ghc-options:     -Wall
+
+source-repository head
+  type:     git
+  location: git://github.com/snoyberg/wai-test.git
