diff --git a/IHP/Hspec.hs b/IHP/Hspec.hs
new file mode 100644
--- /dev/null
+++ b/IHP/Hspec.hs
@@ -0,0 +1,109 @@
+module IHP.Hspec (withIHPApp) where
+
+import IHP.Prelude
+import qualified Hasql.Connection as Hasql
+import qualified Hasql.Connection.Settings as HasqlSettings
+import qualified Hasql.Session as Session
+import qualified Hasql.Statement as Statement
+import qualified Hasql.Decoders as Decoders
+import qualified Hasql.Encoders as Encoders
+import qualified Data.UUID.V4 as UUID
+import qualified Data.UUID as UUID
+import qualified Data.Text as Text
+import qualified Control.Exception as Exception
+import IHP.IDE.CodeGen.MigrationGenerator (findIHPSchemaSql)
+
+import Network.Wai
+import Network.Wai.Internal (ResponseReceived (..))
+
+import IHP.ControllerSupport (InitControllerContext)
+import IHP.FrameworkConfig (ConfigBuilder (..), FrameworkConfig (..))
+import qualified IHP.FrameworkConfig as FrameworkConfig
+import qualified IHP.ModelSupport as ModelSupport
+import IHP.Log.Types
+
+import qualified System.Process as Process
+import IHP.Test.Mocking (MockContext(..), runTestMiddlewares)
+import qualified IHP.PGListener as PGListener
+
+withConnection :: ByteString -> (Hasql.Connection -> IO a) -> IO a
+withConnection databaseUrl action = do
+    connResult <- Hasql.acquire (HasqlSettings.connectionString (cs databaseUrl))
+    case connResult of
+        Right conn -> action conn `Exception.finally` Hasql.release conn
+        Left err -> error (show err)
+
+runSessionOnConnection :: Hasql.Connection -> Session.Session a -> IO ()
+runSessionOnConnection conn session = do
+    result <- Hasql.use conn session
+    case result of
+        Left err -> error (show err)
+        Right _ -> pure ()
+
+-- | Create contexts that can be used for mocking
+withIHPApp :: (InitControllerContext application) => application -> ConfigBuilder -> (MockContext application -> IO ()) -> IO ()
+withIHPApp application configBuilder hspecAction = do
+    FrameworkConfig.withFrameworkConfig configBuilder \frameworkConfig -> do
+        logger <- newLogger def { level = Warn } -- don't log queries
+
+        withTestDatabase frameworkConfig.databaseUrl \testDatabaseUrl -> do
+            ModelSupport.withModelContext testDatabaseUrl logger \modelContext -> do
+                PGListener.withPGListener testDatabaseUrl logger \pgListener' -> do
+                    let baseRequest = defaultRequest
+                    let pgListener = Just pgListener'
+                    mockRequest <- runTestMiddlewares frameworkConfig modelContext pgListener baseRequest
+                    let mockRespond = const (pure ResponseReceived)
+
+                    hspecAction MockContext { .. }
+
+withTestDatabase :: ByteString -> (ByteString -> IO ()) -> IO ()
+withTestDatabase masterDatabaseUrl callback = do
+    testDatabaseName <- randomDatabaseName
+
+    withConnection masterDatabaseUrl \masterConnection ->
+        Exception.bracket_
+            (runSessionOnConnection masterConnection (execDDL ("CREATE DATABASE " <> quoteIdentifier testDatabaseName)))
+            (
+                -- The WITH FORCE is required to force close open connections
+                runSessionOnConnection masterConnection (execDDL ("DROP DATABASE " <> quoteIdentifier testDatabaseName <> " WITH (FORCE)"))
+            )
+            do
+                let testDatabaseUrl = injectDatabaseName testDatabaseName masterDatabaseUrl
+                importSql testDatabaseUrl
+                callback testDatabaseUrl
+    pure ()
+
+-- | Execute a DDL statement (CREATE DATABASE, DROP DATABASE, etc.)
+execDDL :: Text -> Session.Session ()
+execDDL sql = Session.statement () (Statement.unpreparable (cs sql) Encoders.noParams Decoders.noResult)
+
+-- | Quote a SQL identifier (e.g., database name) with double quotes
+quoteIdentifier :: Text -> Text
+quoteIdentifier name = "\"" <> Text.replace "\"" "\"\"" name <> "\""
+
+-- | Imports the IHP Schema.sql and Application/Schema.sql
+importSql :: ByteString -> IO ()
+importSql databaseUrl = do
+    -- Import IHP Schema
+    ihpSchemaSql <- findIHPSchemaSql
+    Process.callCommand ("psql " <> cs databaseUrl <> " < " <> cs (osPathToText ihpSchemaSql))
+
+    -- Import Application Schema
+    Process.callCommand ("psql " <> cs databaseUrl <> " < Application/Schema.sql")
+
+    -- Import Application Fixtures (if any)
+    Process.callCommand ("psql " <> cs databaseUrl <> " < Application/Fixtures.sql")
+
+randomDatabaseName :: IO Text
+randomDatabaseName = do
+    uuid <- UUID.nextRandom
+    let name = "test_db_" <> (uuid |> UUID.toText |> Text.replace "-" "_")
+    pure name
+
+injectDatabaseName :: Text -> ByteString -> ByteString
+injectDatabaseName databaseName databaseUrl =
+        databaseUrl
+        |> cs
+        -- Remove database name from url so we can connect to the default database
+        |> Text.replace "/app" ("/" <> databaseName)
+        |> cs
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2020 digitally induced GmbH
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/changelog.md b/changelog.md
new file mode 100644
--- /dev/null
+++ b/changelog.md
@@ -0,0 +1,11 @@
+# Changelog for `ihp-hspec`
+
+## v1.5.0
+
+- Migrate from postgresql-simple to hasql
+- Run full middleware stack for each test request
+- Add bracket-style `withModelContext`
+
+## v1.4.0
+
+- Initial release as a standalone package
diff --git a/ihp-hspec.cabal b/ihp-hspec.cabal
new file mode 100644
--- /dev/null
+++ b/ihp-hspec.cabal
@@ -0,0 +1,41 @@
+cabal-version:       2.2
+name:                ihp-hspec
+version:             1.5.0
+synopsis:            Test helpers for IHP apps
+description:         Provides the withIHPApp function for hspec tests
+license:             MIT
+license-file:        LICENSE
+author:              digitally induced GmbH
+maintainer:          support@digitallyinduced.com
+homepage:            https://ihp.digitallyinduced.com/
+bug-reports:         https://github.com/digitallyinduced/ihp/issues
+copyright:           (c) digitally induced GmbH
+category:            Testing
+build-type:          Simple
+extra-source-files:  changelog.md
+
+source-repository head
+    type:     git
+    location: https://github.com/digitallyinduced/ihp
+
+library
+    default-language: GHC2021
+    default-extensions:
+        NoImplicitPrelude
+        OverloadedLabels
+        OverloadedRecordDot
+        DuplicateRecordFields
+        DisambiguateRecordFields
+        TypeApplications
+        TemplateHaskell
+        QuasiQuotes
+        PackageImports
+        ScopedTypeVariables
+        BlockArguments
+        OverloadedStrings
+        ImplicitParams
+        RecordWildCards
+    ghc-options: -Werror=incomplete-patterns -Werror=unused-imports -Werror=missing-fields
+    build-depends: base >= 4.17.0 && < 4.22, ihp, ihp-log, wai, process, text, ihp-ide, vault, uuid, hasql, wai-request-params
+    hs-source-dirs: .
+    exposed-modules: IHP.Hspec
