diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for hslua-module-json
+
+## 0.1.0 -- YYYY-mm-dd
+
+* First version. Released on an unsuspecting world.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2019 Albert Krewinkel
+
+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/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/src/Test/Tasty/Lua.hs b/src/Test/Tasty/Lua.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Tasty/Lua.hs
@@ -0,0 +1,118 @@
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+{-# LANGUAGE TemplateHaskell #-}
+{-|
+Module      : Test.Tasty.Lua
+Copyright   : © 2019 Albert Krewinkel
+License     : MIT
+Maintainer  : Albert Krewinkel <albert+hslua@zeitkraut.de>
+Stability   : alpha
+Portability : Requires TemplateHaskell
+
+Convert Lua test results into a tasty test trees.
+-}
+module Test.Tasty.Lua
+  ( -- * Lua module
+    pushModule
+    -- * Running tests
+  , testsFromFile
+    -- * Helpers
+  , pathFailure
+  )
+where
+
+import Control.Monad (void)
+import Data.ByteString (ByteString)
+import Data.FileEmbed
+import Foreign.Lua (Lua, NumResults, Peekable, StackIndex)
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as Text.Encoding
+import qualified Foreign.Lua as Lua
+import qualified Test.Tasty as Tasty
+import qualified Test.Tasty.Providers as Tasty
+
+
+-- | Tasty Lua script
+tastyScript :: ByteString
+tastyScript = $(embedFile "tasty.lua")
+
+-- | Push the Aeson module on the Lua stack.
+pushModule :: Lua NumResults
+pushModule = do
+  result <- Lua.dostring tastyScript
+  if result == Lua.OK
+    then return 1
+    else Lua.throwTopMessage
+{-# INLINABLE pushModule #-}
+
+-- | Report failure of testing a path.
+pathFailure :: FilePath -> String -> Tasty.TestTree
+pathFailure fp errMsg = Tasty.singleTest fp (Failure errMsg)
+
+-- | Run tasty.lua tests from the given file.
+testsFromFile :: FilePath -> Lua Tasty.TestTree
+testsFromFile fp =  do
+  Lua.openlibs
+  Lua.requirehs "tasty" (void pushModule)
+  res <- Lua.dofile fp
+  if res == Lua.OK
+    then do
+      results <- Lua.peekList Lua.stackTop
+      return $ Tasty.testGroup fp $ map testTree results
+    else do
+      errMsg <- toString <$> Lua.tostring' Lua.stackTop
+      return $ pathFailure fp errMsg
+
+-- | Convert internal (tasty.lua) tree format into Tasty tree.
+testTree :: Tree -> Tasty.TestTree
+testTree (Tree name tree) =
+  case tree of
+    SingleTest outcome -> Tasty.singleTest name outcome
+    TestGroup results  -> Tasty.testGroup name (map testTree results)
+
+data Tree = Tree Tasty.TestName UnnamedTree
+
+instance Peekable Tree where
+  peek idx = do
+    name   <- Lua.getfield idx "name"   *> Lua.popValue
+    result <- Lua.getfield idx "result" *> Lua.popValue
+    return $ Tree name result
+
+instance Tasty.IsTest Outcome where
+  run _ tr _ = return $ case tr of
+    Success     -> Tasty.testPassed ""
+    Failure msg -> Tasty.testFailed msg
+  testOptions = return []
+
+
+data UnnamedTree
+  = SingleTest Outcome
+  | TestGroup [Tree]
+
+instance Peekable UnnamedTree where
+  peek = peekTree
+
+peekTree :: StackIndex -> Lua UnnamedTree
+peekTree idx = do
+  ty <- Lua.ltype idx
+  case ty of
+    Lua.TypeTable   -> TestGroup   <$> Lua.peekList idx
+    _               -> SingleTest  <$> Lua.peek idx
+
+-- | Test outcome
+data Outcome = Success | Failure String
+
+instance Peekable Outcome where
+  peek idx = do
+    ty <- Lua.ltype idx
+    case ty of
+      Lua.TypeString  -> Failure <$> Lua.peek idx
+      Lua.TypeBoolean -> do
+        b <- Lua.peek idx
+        return $ if b then Success else Failure "???"
+      _ -> do
+        s <- toString <$> Lua.tostring' idx
+        Lua.throwException ("not a test result: " ++ s)
+
+-- | Convert UTF8-encoded @'ByteString'@ to a @'String'@.
+toString :: ByteString -> String
+toString = Text.unpack . Text.Encoding.decodeUtf8
diff --git a/tasty-lua.cabal b/tasty-lua.cabal
new file mode 100644
--- /dev/null
+++ b/tasty-lua.cabal
@@ -0,0 +1,45 @@
+name:                tasty-lua
+version:             0.1.0
+synopsis:            Write tests in Lua, integrate into tasty.
+description:         Allow users to define tasty tests from Lua.
+homepage:            https://github.com/hslua/tasty-lua
+license:             MIT
+license-file:        LICENSE
+author:              Albert Krewinkel
+maintainer:          albert+hslua@zeitkraut.de
+copyright:           Albert Krewinkel <albert+hslua@zeitkraut.de>
+category:            Foreign
+build-type:          Simple
+extra-source-files:  CHANGELOG.md
+                   , tasty.lua
+cabal-version:       >=1.10
+tested-with:         GHC == 8.0.2, GHC == 8.2.2, GHC == 8.4.4, GHC == 8.6.5
+
+source-repository head
+  type:              git
+  location:          https://github.com/hslua/hslua-module-tasty.git
+
+library
+  build-depends:       base        >= 4.9    && < 5
+                     , bytestring  >= 0.10.2 && < 0.11
+                     , file-embed  >= 0.0    && < 0.1
+                     , hslua       >= 1.0.3  && < 1.2
+                     , tasty       >= 1.2    && < 1.3
+                     , text        >= 1.0    && < 1.3
+  exposed-modules:     Test.Tasty.Lua
+  hs-source-dirs:      src
+  default-language:    Haskell2010
+  ghc-options:         -Wall
+
+test-suite test-tasty-lua
+  default-language:    Haskell2010
+  type:                exitcode-stdio-1.0
+  main-is:             test-tasty-lua.hs
+  hs-source-dirs:      test
+  ghc-options:         -Wall -threaded
+  build-depends:       base
+                     , directory
+                     , hslua
+                     , tasty
+                     , tasty-lua
+                     , tasty-hunit
diff --git a/tasty.lua b/tasty.lua
new file mode 100644
--- /dev/null
+++ b/tasty.lua
@@ -0,0 +1,145 @@
+------------------------------------------------------------------------
+--- Assertors
+
+local assertors = {}
+
+local function register_assertor (name, callback, error_message)
+  assertors[name] = function (...)
+    local bool = callback(...)
+    if bool then
+      return
+    end
+
+    local success, formatted_message =
+      pcall(string.format, error_message, ...)
+    if not success then
+      error('assertion failed, and error message could not be formatted', 2)
+    end
+    error('\n' .. formatted_message or 'assertion failed!', 2)
+  end
+end
+
+--- Value is truthy
+local function is_truthy (x)
+  return x ~= false and x ~= nil
+end
+
+--- Value is falsy
+local function is_falsy (x)
+  return not is_truthy(x)
+end
+
+--- Value is nil
+local function is_nil (x)
+  return x == nil
+end
+
+--- Values are equal
+local function are_equal (x, y)
+  return x == y
+end
+
+local function cycle_aware_compare(t1, t2, cache)
+  if cache[t1] and cache[t1][t2] then return true end
+
+  local ty1 = type(t1)
+  local ty2 = type(t2)
+
+  -- if t1 == t2 then return true end
+  if ty1 ~= ty2 then return false end
+  if ty1 ~= 'table' then return t1 == t2 end
+
+  -- Check tables have the same set of keys
+  for k1 in pairs(t1) do
+    if t2[k1] == nil then return false end
+  end
+  for k2 in pairs(t2) do
+    if t1[k2] == nil then return false end
+  end
+
+  -- cache comparison result
+  cache[t1] = cache[t1] or {}
+  cache[t1][t2] = true
+
+  for k1, v1 in pairs(t1) do
+    local v2 = t2[k1]
+    if not cycle_aware_compare(v1, v2, cache) then return false end
+  end
+  return true
+end
+
+--- Check if tables are the same
+local function are_same(x, y)
+  return cycle_aware_compare(x, y, {})
+end
+
+local function error_matches(fn, pattern)
+  local success, msg = pcall(fn)
+  if success then
+    return false
+  end
+  return msg:match(pattern)
+end
+
+register_assertor('is_truthy', is_truthy, "expected a truthy value, got %s")
+register_assertor('is_falsy', is_falsy, "expected a falsy value, got %s")
+register_assertor('is_nil', is_nil, "expected nil, got %s")
+register_assertor('are_same', are_same, 'expected same values, got %s and %s')
+register_assertor(
+  'are_equal',
+  are_equal,
+  "expected values to be equal, got '%s' and '%s'"
+)
+register_assertor(
+  'error_matches',
+  error_matches,
+  'no error matching the given pattern was raised'
+)
+
+------------------------------------------------------------------------
+
+local ok = true
+
+local function test_success (name)
+  return {
+    name = name,
+    result = ok,
+  }
+end
+
+local function test_failure (name, err_msg)
+  return {
+    name = name,
+    result = err_msg,
+  }
+end
+
+------------------------------------------------------------------------
+-- Test definitions
+
+local function test_case (name, callback)
+  local success, err_msg = pcall(callback)
+  return success
+    and test_success(name)
+    or  test_failure(name, err_msg)
+end
+
+local function test_group (name, tests)
+  if tests == nil then
+    -- let's try to curry
+    return function (tests_)
+      return tests_ and test_group(name, tests_) or error('no tests')
+    end
+  end
+  return {
+    name = name,
+    result = tests,
+  }
+end
+
+return {
+  assert = assertors,
+  ok = ok,
+  test_case = test_case,
+  test_group = test_group
+}
diff --git a/test/test-tasty-lua.hs b/test/test-tasty-lua.hs
new file mode 100644
--- /dev/null
+++ b/test/test-tasty-lua.hs
@@ -0,0 +1,50 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-|
+Module      : Main
+Copyright   : © 2019 Albert Krewinkel
+License     : MIT
+Maintainer  : Albert Krewinkel <albert+hslua@zeitkraut.de>
+Stability   : alpha
+Portability : Requires language extensions ForeignFunctionInterface,
+              OverloadedStrings.
+
+Tests for the @tasty@ Lua module.
+-}
+
+import Control.Monad (void)
+import Foreign.Lua (Lua)
+import System.Directory (withCurrentDirectory)
+import Test.Tasty (TestTree, defaultMain, testGroup)
+import Test.Tasty.HUnit (assertEqual, testCase)
+import Test.Tasty.Lua (pushModule, testsFromFile)
+
+import qualified Foreign.Lua as Lua
+
+main :: IO ()
+main = do
+  luaTest <- withCurrentDirectory "test" . Lua.run $
+    testsFromFile "test-tasty.lua"
+  defaultMain $ testGroup "tasty-hslua" [luaTest, tests]
+
+-- | HSpec tests for the Lua 'system' module
+tests :: TestTree
+tests = testGroup "HsLua tasty module"
+  [ testCase "can be pushed to the stack" $
+      Lua.run (void pushModule)
+
+  , testCase "can be added to the preloader" . Lua.run $ do
+      Lua.openlibs
+      Lua.preloadhs "tasty" pushModule
+      assertEqual' "function not added to preloader" Lua.TypeFunction =<< do
+        Lua.getglobal' "package.preload.tasty"
+        Lua.ltype (-1)
+
+  , testCase "can be loaded as tasty" . Lua.run $ do
+      Lua.openlibs
+      Lua.requirehs "tasty" (void pushModule)
+      assertEqual' "loading the module fails " Lua.OK =<<
+        Lua.dostring "require 'tasty'"
+  ]
+
+assertEqual' :: (Show a, Eq a) => String -> a -> a -> Lua ()
+assertEqual' msg expected = Lua.liftIO . assertEqual msg expected
