diff --git a/knob.cabal b/knob.cabal
--- a/knob.cabal
+++ b/knob.cabal
@@ -1,5 +1,5 @@
 name: knob
-version: 0.1
+version: 0.1.1
 license: MIT
 license-file: license.txt
 author: John Millikin <jmillikin@gmail.com>
@@ -13,7 +13,7 @@
 
 synopsis: Memory-backed handles
 description:
-  Create memory-backed 'IO.Handle's, referencing virtual files. This is
+  Create memory-backed 'Handle's, referencing virtual files. This is
   mostly useful for testing 'Handle'-based APIs without having to
   interact with the filesystem.
   .
@@ -29,6 +29,14 @@
   >     bytes <- Data.Knob.getContents knob
   >     putStrLn ("Wrote bytes: " ++ show bytes)
 
+extra-source-files:
+  scripts/common.bash
+  scripts/run-coverage
+  scripts/run-tests
+  --
+  tests/knob-tests.cabal
+  tests/KnobTests.hs
+
 source-repository head
   type: bazaar
   location: https://john-millikin.com/software/knob/
@@ -36,7 +44,7 @@
 source-repository this
   type: bazaar
   location: https://john-millikin.com/branches/knob/0.1/
-  tag: knob_0.1
+  tag: knob_0.1.1
 
 library
   hs-source-dirs: lib
@@ -44,8 +52,8 @@
 
   build-depends:
       base >= 4.2 && < 5.0
-    , bytestring >= 0.9 && < 0.10
-    , transformers >= 0.2 && < 0.3
+    , bytestring >= 0.9
+    , transformers >= 0.2
 
   exposed-modules:
     Data.Knob
diff --git a/scripts/common.bash b/scripts/common.bash
new file mode 100644
--- /dev/null
+++ b/scripts/common.bash
@@ -0,0 +1,23 @@
+PATH="$PATH:$PWD/cabal-dev/bin/"
+
+VERSION=$(awk '/^version:/{print $2}' knob.cabal)
+
+CABAL_DEV=$(which cabal-dev)
+XZ=$(which xz)
+
+require_cabal_dev()
+{
+	if [ -z "$CABAL_DEV" ]; then
+		echo "Can't find 'cabal-dev' executable; make sure it exists on your "'$PATH'
+		echo "Cowardly refusing to fuck with the global package database"
+		exit 1
+	fi
+}
+
+clean_dev_install()
+{
+	require_cabal_dev
+	
+	rm -rf dist
+	$CABAL_DEV install || exit 1
+}
diff --git a/scripts/run-coverage b/scripts/run-coverage
new file mode 100644
--- /dev/null
+++ b/scripts/run-coverage
@@ -0,0 +1,23 @@
+#!/bin/bash
+if [ ! -f 'knob.cabal' ]; then
+	echo -n "Can't find knob.cabal; please run this script as"
+	echo -n " ./scripts/run-coverage from within the knob source"
+	echo " directory"
+	exit 1
+fi
+
+. scripts/common.bash
+
+require_cabal_dev
+
+pushd tests
+$CABAL_DEV -s ../cabal-dev install --flags="coverage" || exit 1
+popd
+
+rm -f knob_tests.tix
+cabal-dev/bin/knob_tests $@
+
+EXCLUDES="--exclude=Main"
+
+hpc markup --srcdir=src/ --srcdir=tests/ knob_tests.tix --destdir=hpc-markup $EXCLUDES > /dev/null
+hpc report --srcdir=src/ --srcdir=tests/ knob_tests.tix $EXCLUDES
diff --git a/scripts/run-tests b/scripts/run-tests
new file mode 100644
--- /dev/null
+++ b/scripts/run-tests
@@ -0,0 +1,20 @@
+#!/bin/bash
+if [ ! -f 'knob.cabal' ]; then
+	echo -n "Can't find knob.cabal; please run this script as"
+	echo -n " ./scripts/run-tests from within the knob source"
+	echo " directory"
+	exit 1
+fi
+
+. scripts/common.bash
+
+require_cabal_dev
+
+clean_dev_install
+
+pushd tests
+rm -rf dist
+$CABAL_DEV -s ../cabal-dev install || exit 1
+popd
+
+cabal-dev/bin/knob_tests
diff --git a/tests/KnobTests.hs b/tests/KnobTests.hs
new file mode 100644
--- /dev/null
+++ b/tests/KnobTests.hs
@@ -0,0 +1,338 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TemplateHaskell #-}
+
+-- Copyright (C) 2011 John Millikin <jmillikin@gmail.com>
+--
+-- See license.txt for details
+module Main
+	( tests
+	, main
+	) where
+
+import           Control.Monad.IO.Class (liftIO)
+import qualified Data.ByteString
+import           Data.ByteString.Char8 ()
+import           Data.ByteString.Unsafe (unsafePackCStringLen)
+import           Foreign (nullPtr)
+import qualified GHC.IO.Exception as GHC
+import           System.IO
+import           Test.Chell
+
+import           Data.Knob
+
+main :: IO ()
+main = Test.Chell.defaultMain tests
+
+tests :: [Suite]
+tests = [test_File, test_Duplex]
+
+test_File :: Suite
+test_File = suite "file"
+	[ suite "read"
+		[ test_ReadFromStart
+		, test_ReadFromOffset
+		, test_ReadToEOF
+		, test_ReadPastEOF
+		]
+	, suite "write"
+		[ test_WriteFromStart
+		, test_WriteFromOffset
+		, test_WritePastEOF
+		, test_WriteAppended
+		]
+	, suite "seek"
+		[ test_SeekAbsolute
+		, test_SeekRelative
+		, test_SeekFromEnd
+		, test_SeekBeyondMaxInt
+		]
+	, suite "setSize"
+		[ test_SetSize_Read
+		, test_SetSize_Write
+		, test_SetSize_ReadWrite
+		, test_SetSize_Append
+		]
+	
+	, test_Ready
+	, test_Close
+	, test_SetContents
+	, test_WithFileHandle
+	]
+
+test_ReadFromStart :: Suite
+test_ReadFromStart = assertions "from-start" $ do
+	k <- newKnob "abcde"
+	h <- newFileHandle k "foo.txt" ReadMode
+	
+	bytes <- liftIO $ Data.ByteString.hGet h 3
+	$expect (equal bytes "abc")
+	
+	off <- liftIO $ hTell h
+	$expect (equal off 3)
+
+test_ReadFromOffset :: Suite
+test_ReadFromOffset = assertions "from-offset" $ do
+	k <- newKnob "abcde"
+	h <- newFileHandle k "foo.txt" ReadMode
+	
+	liftIO $ hSeek h AbsoluteSeek 1
+	bytes <- liftIO $ Data.ByteString.hGet h 3
+	$expect (equal bytes "bcd")
+	
+	off <- liftIO $ hTell h
+	$expect (equal off 4)
+
+test_ReadToEOF :: Suite
+test_ReadToEOF = assertions "to-eof" $ do
+	k <- newKnob "abcde"
+	h <- newFileHandle k "foo.txt" ReadMode
+	
+	bytes <- liftIO $ Data.ByteString.hGet h 10
+	$expect (equal bytes "abcde")
+	
+	off <- liftIO $ hTell h
+	$expect (equal off 5)
+
+test_ReadPastEOF :: Suite
+test_ReadPastEOF = assertions "past-eof" $ do
+	k <- newKnob "abcde"
+	h <- newFileHandle k "foo.txt" ReadMode
+	
+	liftIO $ hSeek h AbsoluteSeek 10
+	bytes <- liftIO $ Data.ByteString.hGet h 10
+	$expect (equal bytes "")
+	
+	off <- liftIO $ hTell h
+	$expect (equal off 10)
+
+test_WriteFromStart :: Suite
+test_WriteFromStart = assertions "from-start" $ do
+	k <- newKnob ""
+	h <- newFileHandle k "foo.txt" WriteMode
+	liftIO $ hSetBuffering h NoBuffering
+	
+	liftIO $ Data.ByteString.hPut h "abcde"
+	bytes <- Data.Knob.getContents k
+	$expect (equal bytes "abcde")
+
+test_WriteFromOffset :: Suite
+test_WriteFromOffset = assertions "from-offset" $ do
+	k <- newKnob ""
+	h <- newFileHandle k "foo.txt" WriteMode
+	liftIO $ hSetBuffering h NoBuffering
+	
+	liftIO $ Data.ByteString.hPut h "abcde"
+	liftIO $ hSeek h AbsoluteSeek 2
+	liftIO $ Data.ByteString.hPut h "abcde"
+	
+	bytes <- Data.Knob.getContents k
+	$expect (equal bytes "ababcde")
+
+test_WritePastEOF :: Suite
+test_WritePastEOF = assertions "past-eof" $ do
+	k <- newKnob ""
+	h <- newFileHandle k "foo.txt" WriteMode
+	liftIO $ hSetBuffering h NoBuffering
+	
+	liftIO $ hSeek h AbsoluteSeek 2
+	liftIO $ Data.ByteString.hPut h "abcde"
+	bytes <- Data.Knob.getContents k
+	$expect (equal bytes "\0\0abcde")
+
+test_WriteAppended :: Suite
+test_WriteAppended = assertions "appended" $ do
+	k <- newKnob "foo"
+	h <- newFileHandle k "foo.txt" AppendMode
+	liftIO $ hSetBuffering h NoBuffering
+	
+	liftIO $ Data.ByteString.hPut h "bar"
+	bytes <- Data.Knob.getContents k
+	$expect (equal bytes "foobar")
+
+test_SeekAbsolute :: Suite
+test_SeekAbsolute = assertions "absolute" $ do
+	k <- newKnob ""
+	h <- newFileHandle k "foo.txt" ReadMode
+	
+	before <- liftIO $ hTell h
+	liftIO $ hSeek h AbsoluteSeek 2
+	after <- liftIO $ hTell h
+	
+	$expect (equal before 0)
+	$expect (equal after 2)
+
+test_SeekRelative :: Suite
+test_SeekRelative = assertions "relative" $ do
+	k <- newKnob ""
+	h <- newFileHandle k "foo.txt" ReadMode
+	
+	before <- liftIO $ hTell h
+	liftIO $ hSeek h RelativeSeek 2
+	after1 <- liftIO $ hTell h
+	liftIO $ hSeek h RelativeSeek 2
+	after2 <- liftIO $ hTell h
+	
+	$expect (equal before 0)
+	$expect (equal after1 2)
+	$expect (equal after2 4)
+
+test_SeekFromEnd :: Suite
+test_SeekFromEnd = assertions "from-end" $ do
+	k <- newKnob "abcde"
+	h <- newFileHandle k "foo.txt" ReadMode
+	
+	before <- liftIO $ hTell h
+	liftIO $ hSeek h SeekFromEnd (- 2)
+	after <- liftIO $ hTell h
+	
+	$expect (equal before 0)
+	$expect (equal after 3)
+
+test_SeekBeyondMaxInt :: Suite
+test_SeekBeyondMaxInt = assertions "beyond-max-int" $ do
+	k <- newKnob "abcde"
+	h <- newFileHandle k "foo.txt" ReadMode
+	
+	let intPlusOne = toInteger (maxBound :: Int) + 1
+	
+	$expect $ throwsEq
+		(GHC.IOError (Just h) GHC.InvalidArgument "hSeek" "offset > (maxBound :: Int)" Nothing (Just "foo.txt"))
+		(hSeek h AbsoluteSeek intPlusOne)
+	$expect $ throwsEq
+		(GHC.IOError (Just h) GHC.InvalidArgument "hSeek" "offset > (maxBound :: Int)" Nothing (Just "foo.txt"))
+		(hSeek h RelativeSeek intPlusOne)
+	
+	-- testing this with real contents is difficult/impossible on a
+	-- 64-bit system, so use an unsafe function to corrupt the knob's
+	-- internal buffer first.
+	hugeBytes <- liftIO (unsafePackCStringLen (nullPtr, maxBound))
+	liftIO $ hSeek h AbsoluteSeek (intPlusOne - 1)
+	setContents k hugeBytes
+	$expect $ throwsEq
+		(GHC.IOError (Just h) GHC.InvalidArgument "hSeek" "offset > (maxBound :: Int)" Nothing (Just "foo.txt"))
+		(hSeek h SeekFromEnd 2)
+
+test_Ready :: Suite
+test_Ready = assertions "ready" $ do
+	k <- newKnob "abcde"
+	h <- newFileHandle k "foo.txt" ReadMode
+	
+	ready <- liftIO $ hReady h
+	$expect ready
+	
+	_ <- liftIO $ Data.ByteString.hGet h 10
+	$expect $ throwsEq
+		(GHC.IOError (Just h) GHC.EOF "hWaitForInput" "" Nothing (Just "foo.txt"))
+		(hReady h)
+
+test_Close :: Suite
+test_Close = assertions "close" $ do
+	k <- newKnob "abcde"
+	h <- newFileHandle k "foo.txt" ReadMode
+	
+	liftIO $ hClose h
+	$expect $ throwsEq
+		(GHC.IOError (Just h) GHC.IllegalOperation "hGetBuf" "handle is closed" Nothing (Just "foo.txt"))
+		(Data.ByteString.hGet h 1)
+	$expect $ throwsEq
+		(GHC.IOError (Just h) GHC.IllegalOperation "hWaitForInput" "handle is closed" Nothing (Just "foo.txt"))
+		(hReady h)
+
+test_SetSize_Read :: Suite
+test_SetSize_Read = assertions "ReadMode" $ do
+	k <- newKnob "abcde"
+	h <- newFileHandle k "foo.txt" ReadMode
+	
+	let intPlusOne = toInteger (maxBound :: Int) + 1
+	$expect $ throwsEq
+		(GHC.IOError (Just h) GHC.InvalidArgument "hSetFileSize" "size > (maxBound :: Int)" Nothing (Just "foo.txt"))
+		(hSetFileSize h intPlusOne)
+	
+	$expect $ throwsEq
+		(GHC.IOError (Just h) GHC.IllegalOperation "hSetFileSize" "handle in ReadMode" Nothing (Just "foo.txt"))
+		(hSetFileSize h 2)
+
+test_SetSize_Write :: Suite
+test_SetSize_Write = assertions "WriteMode" $ do
+	k <- newKnob "abcde"
+	h <- newFileHandle k "foo.txt" WriteMode
+	
+	let intPlusOne = toInteger (maxBound :: Int) + 1
+	$expect $ throwsEq
+		(GHC.IOError (Just h) GHC.InvalidArgument "hSetFileSize" "size > (maxBound :: Int)" Nothing (Just "foo.txt"))
+		(hSetFileSize h intPlusOne)
+	
+	-- Resets contents to all NULL, regardless of offset
+	liftIO $ hSeek h AbsoluteSeek 2
+	liftIO $ hSetFileSize h 4
+	
+	bytes <- Data.Knob.getContents k
+	$expect (equal bytes "\0\0\0\0")
+
+test_SetSize_ReadWrite :: Suite
+test_SetSize_ReadWrite = assertions "ReadWriteMode" $ do
+	k <- newKnob "abcde"
+	h <- newFileHandle k "foo.txt" ReadWriteMode
+	
+	let intPlusOne = toInteger (maxBound :: Int) + 1
+	$expect $ throwsEq
+		(GHC.IOError (Just h) GHC.InvalidArgument "hSetFileSize" "size > (maxBound :: Int)" Nothing (Just "foo.txt"))
+		(hSetFileSize h intPlusOne)
+	
+	-- Truncates contents, regardless of offset
+	do
+		liftIO $ hSeek h AbsoluteSeek 2
+		liftIO $ hSetFileSize h 4
+		bytes <- Data.Knob.getContents k
+		$expect (equal bytes "abcd")
+	
+	do
+		liftIO $ hSetFileSize h 6
+		bytes <- Data.Knob.getContents k
+		$expect (equal bytes "abcd\0\0")
+
+test_SetSize_Append :: Suite
+test_SetSize_Append = assertions "AppendMode" $ do
+	k <- newKnob "abcde"
+	h <- newFileHandle k "foo.txt" AppendMode
+	
+	let intPlusOne = toInteger (maxBound :: Int) + 1
+	$expect $ throwsEq
+		(GHC.IOError (Just h) GHC.InvalidArgument "hSetFileSize" "size > (maxBound :: Int)" Nothing (Just "foo.txt"))
+		(hSetFileSize h intPlusOne)
+	
+	do
+		liftIO $ hSetFileSize h 4
+		bytes <- Data.Knob.getContents k
+		$expect (equal bytes "abcd")
+	
+	do
+		liftIO $ hSetFileSize h 6
+		bytes <- Data.Knob.getContents k
+		$expect (equal bytes "abcd\0\0")
+
+test_SetContents :: Suite
+test_SetContents = assertions "setContents" $ do
+	k <- newKnob "abcde"
+	before <- Data.Knob.getContents k
+	setContents k "foo"
+	after <- Data.Knob.getContents k
+	
+	$expect (equal before "abcde")
+	$expect (equal after "foo")
+
+test_WithFileHandle :: Suite
+test_WithFileHandle = assertions "withFileHandle" $ do
+	k <- newKnob ""
+	h <- withFileHandle k "test.txt" WriteMode $ \h -> do
+		Data.ByteString.hPut h "abcde"
+		return h
+	
+	bytes <- Data.Knob.getContents k
+	$expect (equal bytes "abcde")
+	
+	closed <- liftIO $ hIsClosed h
+	$expect closed
+
+test_Duplex :: Suite
+test_Duplex = suite "duplex" []
diff --git a/tests/knob-tests.cabal b/tests/knob-tests.cabal
new file mode 100644
--- /dev/null
+++ b/tests/knob-tests.cabal
@@ -0,0 +1,22 @@
+name: knob-tests
+version: 0
+build-type: Simple
+cabal-version: >= 1.6
+
+flag coverage
+  default: False
+  manual: True
+
+executable knob_tests
+  main-is: KnobTests.hs
+  ghc-options: -Wall
+  hs-source-dirs: ../lib,.
+
+  if flag(coverage)
+    ghc-options: -fhpc
+
+  build-depends:
+      base >= 4.2 && < 5.0
+    , bytestring >= 0.9 && < 0.10
+    , chell >= 0.2 && < 0.3
+    , transformers >= 0.2 && < 0.3
