diff --git a/cmd/test/main.go b/cmd/test/main.go
new file mode 100644
index 0000000000000000000000000000000000000000..4a16d27c33b6198654a27f2f3af2f26f7967800d
--- /dev/null
+++ b/cmd/test/main.go
@@ -0,0 +1,28 @@
+package main
+
+import (
+	"flag"
+
+	"pggat/test"
+)
+
+func main() {
+	var config test.Config
+
+	flag.StringVar(&config.TestsPath, "path", "test/tests", "path to the tests to run")
+
+	flag.BoolVar(&config.Offline, "offline", false, "if true, existing test results will be used")
+
+	flag.StringVar(&config.Host, "host", "localhost", "postgres host")
+	flag.IntVar(&config.Port, "port", 5432, "postgres port")
+	flag.StringVar(&config.Database, "database", "pggat", "postgres database")
+	flag.StringVar(&config.User, "user", "postgres", "postgres user")
+	flag.StringVar(&config.Password, "password", "password", "postgres password")
+
+	flag.Parse()
+
+	tester := test.NewTester(config)
+	if err := tester.Run(); err != nil {
+		panic(err)
+	}
+}
diff --git a/contrib/discovery/k8s/k8s.go b/contrib/discovery/k8s/k8s.go
deleted file mode 100644
index 15b5e0ec8cb9864e64de162994f5897f068738ae..0000000000000000000000000000000000000000
--- a/contrib/discovery/k8s/k8s.go
+++ /dev/null
@@ -1,127 +0,0 @@
-package k8s
-
-import (
-	"context"
-	"fmt"
-
-	"pggat/lib/gat"
-
-	"tuxpa.in/a/zlog/log"
-
-	v1 "k8s.io/api/core/v1"
-	"k8s.io/client-go/kubernetes"
-
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/watch"
-)
-
-type PodWatcher struct {
-	BaseRecipe gat.TCPRecipe
-
-	Namespace   string
-	ListOptions metav1.ListOptions
-
-	pods map[string]*v1.Pod
-}
-
-func (p *PodWatcher) Start(
-	ctx context.Context,
-	c *kubernetes.Clientset,
-	pool *gat.Pool,
-) error {
-	p.pods = make(map[string]*v1.Pod)
-	err := p.getInitialPods(ctx, c, pool)
-	if err != nil {
-		return err
-	}
-	return p.startWatching(ctx, c, pool)
-}
-
-func (p *PodWatcher) getInitialPods(
-	ctx context.Context,
-	c *kubernetes.Clientset,
-	pool *gat.Pool,
-) error {
-	pods, err := c.CoreV1().Pods(p.Namespace).List(ctx, p.ListOptions)
-	if err != nil {
-		return err
-	}
-	for _, pod := range pods.Items {
-		if isPodReady(&pod) {
-			p.pods[pod.Name] = &pod
-		}
-	}
-	return nil
-}
-
-func (p *PodWatcher) startWatching(
-	ctx context.Context,
-	c *kubernetes.Clientset,
-	pool *gat.Pool,
-) error {
-	watcher, err := c.CoreV1().Pods(p.Namespace).Watch(ctx, p.ListOptions)
-	if err != nil {
-		return err
-	}
-	defer watcher.Stop()
-
-	for event := range watcher.ResultChan() {
-		pod, ok := event.Object.(*v1.Pod)
-		if !ok {
-			continue
-		}
-
-		podName := pod.Name
-		podIp := pod.Status.PodIP
-		podReady := isPodReady(pod)
-
-		shouldDelete := false
-		shouldCreate := false
-
-		// Log raw event stream to debug log
-		switch event.Type {
-		case watch.Added:
-			log.Printf("ADDED pod %s with ip %s. Ready = %v", podName, podIp, podReady)
-			if podReady {
-				shouldCreate = true
-			} else {
-				shouldDelete = true
-			}
-
-		case watch.Modified:
-			log.Printf("MODIFIED pod %s with ip %s. Ready = %v", podName, podIp, podReady)
-			if podReady {
-				shouldCreate = true
-			} else {
-				shouldDelete = true
-			}
-		case watch.Deleted:
-			log.Printf("DELETED pod %s with ip %s. Ready = %v", podName, podIp, podReady)
-			shouldDelete = true
-		default:
-			// ignore this event
-			continue
-		}
-
-		if shouldDelete {
-			pool.RemoveRecipe(podName)
-			delete(p.pods, podName)
-		} else if shouldCreate {
-			r := p.BaseRecipe
-			r.Address = fmt.Sprintf(r.Address, pod.Status.PodIP)
-			pool.AddRecipe(podName, r)
-		}
-
-	}
-
-	return nil
-}
-
-func isPodReady(pod *v1.Pod) bool {
-	for _, condition := range pod.Status.Conditions {
-		if condition.Type == v1.PodReady && condition.Status == v1.ConditionTrue {
-			return true
-		}
-	}
-	return false
-}
diff --git a/test/README.md b/test/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..912d5d3ea49f93016a19d12e6610514ff711b0b4
--- /dev/null
+++ b/test/README.md
@@ -0,0 +1,23 @@
+# How it works
+All tests are listed in the `tests` directory. They are each run line by line against a real postgres
+database and a database proxied through pggat. If the output differs in any meaningful way, the test
+will fail.
+
+# Running without a database
+The tests can be run without a postgres database by using previous test results in place of the
+database.
+
+# Test format
+The tests are formatted as a set of "instructions". Each instruction corresponds to zero or more packets
+to be sent to the server.
+
+## Instructions
+| Instruction | Arguments      | Description                                                                      |
+|-------------|----------------|----------------------------------------------------------------------------------|
+| PX          | bool           | Controls whether the next instructions can be run in parallel                    |
+| SQ          | string         | Runs a simple query                                                              |
+| QA          | string, ...any | Runs a query with arguments. This will run a prepare, bind, explain, and execute |
+
+## Parallel tests
+By default, many instances of a single test will be run at the same time. If the test or parts of the test
+cannot be run in parallel, prepend `PX false`.
diff --git a/test/config.go b/test/config.go
new file mode 100644
index 0000000000000000000000000000000000000000..957d96c68ed24c67ad4385e22ff1558d521669f1
--- /dev/null
+++ b/test/config.go
@@ -0,0 +1,13 @@
+package test
+
+type Config struct {
+	TestsPath string
+
+	Offline bool
+
+	Host     string
+	Port     int
+	Database string
+	User     string
+	Password string
+}
diff --git a/test/tester.go b/test/tester.go
new file mode 100644
index 0000000000000000000000000000000000000000..7b6f1ecb0e7d57423007a14818335e6c69ebd0c4
--- /dev/null
+++ b/test/tester.go
@@ -0,0 +1,85 @@
+package test
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+
+	"tuxpa.in/a/zlog/log"
+)
+
+type Tester struct {
+	config Config
+}
+
+func NewTester(config Config) *Tester {
+	return &Tester{
+		config: config,
+	}
+}
+
+func (T *Tester) Run() error {
+	dirEntries, err := os.ReadDir(T.config.TestsPath)
+	if err != nil {
+		return err
+	}
+
+	for _, dirEntry := range dirEntries {
+		if dirEntry.IsDir() {
+			continue
+		}
+		path := filepath.Join(T.config.TestsPath, dirEntry.Name())
+		log.Printf(`Running test "%s"`, path)
+
+		file, err := os.Open(path)
+		if err != nil {
+			return err
+		}
+
+		scanner := bufio.NewScanner(file)
+		for scanner.Scan() {
+			line := scanner.Text()
+
+			fields := strings.Fields(line)
+			if len(fields) == 0 {
+				continue
+			}
+
+			instruction := fields[0]
+			arguments := make([]any, 0, len(fields)-1)
+			for _, argString := range fields[1:] {
+				var arg any
+				switch {
+				case argString == "true":
+					arg = true
+				case argString == "false":
+					arg = false
+				case strings.HasPrefix(argString, `"`), strings.HasPrefix(argString, "`"):
+					log.Printf("unquote %s", argString)
+					arg, err = strconv.Unquote(argString)
+					if err != nil {
+						return err
+					}
+				default:
+					return fmt.Errorf(`unknown argument "%s"`, argString)
+				}
+				arguments = append(arguments, arg)
+			}
+			log.Print(instruction, " ", arguments)
+		}
+		if err = scanner.Err(); err != nil {
+			return err
+		}
+
+		if err = file.Close(); err != nil {
+			return err
+		}
+
+		log.Print("OK")
+	}
+
+	return nil
+}
diff --git a/test/testfile/instruction.go b/test/testfile/instruction.go
new file mode 100644
index 0000000000000000000000000000000000000000..119fe6e7c52c8d1447d3d30ae1849a0c3d2ed8f7
--- /dev/null
+++ b/test/testfile/instruction.go
@@ -0,0 +1,6 @@
+package testfile
+
+type Instruction struct {
+	Op   Opcode
+	Args []any
+}
diff --git a/test/testfile/opcode.go b/test/testfile/opcode.go
new file mode 100644
index 0000000000000000000000000000000000000000..2684e6182ac40ef543e2435d1892498335b3cccb
--- /dev/null
+++ b/test/testfile/opcode.go
@@ -0,0 +1,7 @@
+package testfile
+
+type Opcode int
+
+func OpcodeFromString(str string) (Opcode, bool) {
+
+}
diff --git a/test/testfile/parser/any.go b/test/testfile/parser/any.go
new file mode 100644
index 0000000000000000000000000000000000000000..86756b717c608634c4d80d4875c3751e2550d766
--- /dev/null
+++ b/test/testfile/parser/any.go
@@ -0,0 +1,4 @@
+package parser
+
+func Any(ctx *Context) (rune, bool) {
+}
diff --git a/test/testfile/parser/context.go b/test/testfile/parser/context.go
new file mode 100644
index 0000000000000000000000000000000000000000..3e476cff829bd6220a6e369439a7b36e0c5b3bae
--- /dev/null
+++ b/test/testfile/parser/context.go
@@ -0,0 +1,4 @@
+package parser
+
+type Context struct {
+}
diff --git a/test/testfile/parser/parser.go b/test/testfile/parser/parser.go
new file mode 100644
index 0000000000000000000000000000000000000000..7e1e57d32a6cdc11b9bad9db62d20b747fa9749f
--- /dev/null
+++ b/test/testfile/parser/parser.go
@@ -0,0 +1,3 @@
+package parser
+
+type Builder[O any] func(*Context) (O, bool)
diff --git a/test/testfile/parser/parsers/v0/parser.go b/test/testfile/parser/parsers/v0/parser.go
new file mode 100644
index 0000000000000000000000000000000000000000..a76501856891f5b92348f9916336eec13b032b10
--- /dev/null
+++ b/test/testfile/parser/parsers/v0/parser.go
@@ -0,0 +1,163 @@
+package parsers
+
+import (
+	"strings"
+
+	"pggat/test/testfile"
+	"pggat/test/testfile/parser"
+)
+
+func NewlineOrEOF(ctx *parser.Context) (struct{}, bool) {
+	c, ok := parser.Any(ctx)
+	if !ok || c == '\n' {
+		return struct{}{}, true
+	}
+	return struct{}{}, false
+}
+
+func Whitespace(ctx *parser.Context) (struct{}, bool) {
+	var n int
+	for {
+		_, ok := parser.SingleOf(ctx, func(r rune) bool {
+			switch r {
+			case ' ', '\t', '\r', '\n', '\v', '\f':
+				return true
+			default:
+				return false
+			}
+		})
+		if !ok {
+			break
+		}
+		n++
+	}
+	return struct{}{}, n > 0
+}
+
+func Identifier(ctx *parser.Context) (string, bool) {
+	var b strings.Builder
+	for {
+		c, ok := parser.SingleOf(ctx, func(r rune) bool {
+			return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_'
+		})
+		if !ok {
+			break
+		}
+		b.WriteRune(c)
+	}
+	if b.Len() == 0 {
+		return "", false
+	}
+	return b.String(), true
+}
+
+func Opcode(ctx *parser.Context) (testfile.Opcode, bool) {
+	ident, ok := Identifier(ctx)
+	if !ok {
+		return 0, false
+	}
+	return testfile.OpcodeFromString(ident)
+}
+
+func StringArgument(ctx *parser.Context) (string, bool) {
+	// open quote
+	_, ok := parser.Single(ctx, '"')
+	if !ok {
+		return "", false
+	}
+
+	var b strings.Builder
+	for {
+		c, ok := parser.Any(ctx)
+		if !ok {
+			return "", false
+		}
+		if c == '"' {
+			break
+		}
+		b.WriteRune(c)
+	}
+
+	return b.String(), true
+}
+
+func BoolArgument(ctx *parser.Context) (bool, bool) {
+	ident, ok := Identifier(ctx)
+	if !ok {
+		return false, false
+	}
+	switch ident {
+	case "false":
+		return false, true
+	case "true":
+		return true, true
+	default:
+		return false, false
+	}
+}
+
+func FloatArgument(ctx *parser.Context) (float64, bool) {
+	return 0, false // TODO(garet)
+}
+
+func IntArgument(ctx *parser.Context) (int, bool) {
+	return 0, false // TODO(garet)
+}
+
+func NumberArgument(ctx *parser.Context) (any, bool) {
+	if arg, ok := parser.Try(ctx, FloatArgument); ok {
+		return arg, true
+	}
+	if arg, ok := parser.Try(ctx, IntArgument); ok {
+		return arg, true
+	}
+	return nil, false
+}
+
+func Argument(ctx *parser.Context) (any, bool) {
+	if arg, ok := parser.Try(ctx, StringArgument); ok {
+		return arg, true
+	}
+	if arg, ok := parser.Try(ctx, BoolArgument); ok {
+		return arg, true
+	}
+	if arg, ok := parser.Try(ctx, NumberArgument); ok {
+		return arg, true
+	}
+	return nil, false
+}
+
+func Arguments(ctx *parser.Context) ([]any, bool) {
+	var args []any
+	for {
+		arg, ok := parser.Try(ctx, Argument)
+		if !ok {
+			break
+		}
+		args = append(args, arg)
+		Whitespace(ctx)
+	}
+	return args, len(args) != 0
+}
+
+func Instruction(ctx *parser.Context) (testfile.Instruction, bool) {
+	Whitespace(ctx)
+	opcode, ok := Opcode(ctx)
+	if !ok {
+		return testfile.Instruction{}, false
+	}
+
+	Whitespace(ctx)
+	arguments, _ := Arguments(ctx)
+
+	Whitespace(ctx)
+	_, ok = NewlineOrEOF(ctx)
+	if !ok {
+		return testfile.Instruction{}, false
+	}
+
+	return testfile.Instruction{
+		Op:   opcode,
+		Args: arguments,
+	}, true
+}
diff --git a/test/testfile/parser/singleof.go b/test/testfile/parser/singleof.go
new file mode 100644
index 0000000000000000000000000000000000000000..a8cdc7c128a3298ab4155c25bce12cccac2f039c
--- /dev/null
+++ b/test/testfile/parser/singleof.go
@@ -0,0 +1,23 @@
+package parser
+
+func SingleOf(ctx *Context, fn func(rune) bool) (rune, bool) {
+	c, ok := Any(ctx)
+	if !ok {
+		return 0, false
+	}
+	if !fn(c) {
+		return 0, false
+	}
+	return c, true
+}
+
+func Single(ctx *Context, r rune) (struct{}, bool) {
+	c, ok := Any(ctx)
+	if !ok {
+		return struct{}{}, false
+	}
+	if c != r {
+		return struct{}{}, false
+	}
+	return struct{}{}, true
+}
diff --git a/test/testfile/parser/try.go b/test/testfile/parser/try.go
new file mode 100644
index 0000000000000000000000000000000000000000..3dd8caf730c56f3c5f800d5be92c865ae2261c95
--- /dev/null
+++ b/test/testfile/parser/try.go
@@ -0,0 +1,5 @@
+package parser
+
+func Try[O any](ctx *Context, b Builder[O]) (O, bool) {
+
+}
diff --git a/test/tests/000_select b/test/tests/000_select
new file mode 100644
index 0000000000000000000000000000000000000000..be6a81acdfa4eb2d7bf22354818ca40f6b6d5442
--- /dev/null
+++ b/test/tests/000_select
@@ -0,0 +1 @@
+SQ	"select 1;"
\ No newline at end of file