How to write DGopher's tests. Development has the commands to run them and the servers the integration tests need.
| Kind | Runs | Needs |
|---|---|---|
| Unit | always, with go test ./... |
nothing |
| UI | always; drawn without a window | nothing; SQLite and DuckDB run in memory |
| Integration | only with DGOPHER_IT=1 |
the servers in compose.yaml: docker compose up -d --wait |
| Fuzz | with -fuzz |
nothing |
| Screenshots | only with DGOPHER_SCREENSHOTS |
Pango's library on the loader's path |
Prefer a UI test on in-memory SQLite or DuckDB over an integration test when the behavior does not depend on a server.
-
Name tests for the behavior, in plain words, with a comment that states it as a sentence:
// ⌘↵ runs the statement around the caret, which blank lines delimit. func TestRunsStatementAtCaretAcrossBlankLines(t *testing.T) {
Integration tests in
internal/dbare namedTestIntegration…. -
Table-driven tests use an anonymous struct slice, with
cas the loop variable:for _, c := range []struct { e db.Engine sql string out bool }{ {db.Postgres, "VACUUM ANALYZE shop.orders", true}, {db.MySQL, "OPTIMIZE TABLE t", false}, } { if got := OutsideTransaction(c.e, c.sql); got != c.out { t.Errorf("%s %q: %v", c.e, c.sql, got) } }
-
No assertion library. Fail with
t.Errorfort.Fatalf, printing what was got. -
Files go in
t.TempDir(). Close every file a test opens: see the open-file check. -
Secrets use
store.MemorySecrets(), never the real keychain. -
Config: pass
t.TempDir()tostore.Openrather than settingDGOPHER_CONFIG_DIR.
UI tests draw frames headlessly with MyGo's ui.Tester.
dataview.FakeHost (internal/ui/dataview/fakehost.go) stands in for the
app window: it makes a project in a temporary folder, runs posted and
background work, and records confirmations, errors and jobs for you to
check. On cleanup it closes tabs, pools and state in the order quitting
does.
The SQL editor's tests wrap it in fakeQueryHost
(internal/ui/query/host_test.go), with helpers that open a connected
editor:
func openEditor(t *testing.T, cfg db.Config, w, h int, text string) (*fakeQueryHost, *ui.Tester, *connection.Conn, *Tab) {
t.Helper()
a := newFakeQueryHost(t)
cn := a.AddConn(cfg)
tt := ui.NewTester(a.view, w, h)
return a, tt, cn, newEditor(t, a, tt, cn, text)
}A test then acts and waits for the frames to settle:
func TestRunsStatementAtCaretAcrossBlankLines(t *testing.T) {
a, tt, _, q := openEditor(t, db.Config{ID: "lite", Name: "lite", Engine: db.SQLite, Database: ":memory:"},
1000, 700, "SELECT 1 AS one\n\nSELECT 2 AS two\nUNION ALL SELECT 3")
testutil.SetCaret(tt, &q.Editor, strings.Index(q.Editor.Text(), "UNION"))
q.Run(RunStatement)
testutil.WaitFor(t, tt, "the run", func() bool { return !q.Running })
// check q.results …
}- Input:
tt.Type("…"),tt.Key(ui.Cmd, ui.KeyS),tt.Click("Commit"), andtt.Frame()to draw one frame. - Waiting:
testutil.WaitFor(t, tt, what, cond)draws frames every 20 ms untilcondholds, and fails after 20 seconds, printing the texts on screen. - Checking text:
tt.Texts(),testutil.HasTextContainingandtestutil.Settled. - App-level tests in
internal/appbuild a realAppwithnewBareApp(t)and draw it withui.NewTester(a.windowView(a.main), w, h).
Start the servers once, then run the tests against them:
docker compose up -d --wait
DGOPHER_IT=1 go test ./... # everything
DGOPHER_IT=1 go test ./internal/db -run TestIntegration # one package's
docker compose down # when doneStart an integration test with testutil.Integration(t). It skips the test
unless DGOPHER_IT is set, and takes a lock so integration tests from
different packages do not run against the servers at once:
func TestAutocomplete(t *testing.T) {
testutil.Integration(t)
testutil.SeedPostgres(t)
a, tt, cn, q := openEditor(t, testutil.PGConfig(), 1200, 760, "")
// …
}testutil.PGConfig()returns the PostgreSQL test server's config, andtestutil.SeedPostgres(t)recreates itsshopschema. Other engines' configs are defined in the tests that use them.internal/dbcannot importtestutil, which imports it, so it has its ownintegration(t)helper and a table of fixtures, one per engine, ininternal/db/integration_test.go. Its tests loop over the fixtures witht.Run(f.cfg.Name, …).- The lock is a file lock on Unix (
internal/testutil/serverlock). On other systems it does nothing, so run integration tests withgo test -p 1. - A new server, or a new engine version, goes in
compose.yaml, with its port and password ininternal/testutil/testutil.goor the test that uses it. Bind its ports to127.0.0.1, and add a health check sodocker compose up --waitwaits for it.
Packages whose tests use files run their TestMain through testutil.Main:
func TestMain(m *testing.M) { os.Exit(testutil.Main(m)) }On Linux, it fails the package if a file is still open in a test's
temporary folder after the tests end, because Windows could not remove
it. Add this TestMain to a new package whose tests open files.
sshtest.Start(internal/sshtunnel/sshtest) runs an SSH server for the test.proxytest.SOCKS5andproxytest.HTTP(internal/netproxy/proxytest) run proxies and return theirnetproxy.Config.
testutil.Snapshot(t, tt, name)writes<name>.pngof the current frame whenDGOPHER_SNAPSHOTSnames a folder, and does nothing otherwise. Use it to see what a UI test sees.TestScreenshots(internal/app/screenshots_test.go) draws the screenshots used in the README and docs; see Screenshots.
TestControlsAreNamed (a11y_test.go at the repository root) parses every
file under internal/ and fails if a MyGo input control (TextInput,
Select, NumberInput, Switch, and others) has no Label and is not
the first control in a ui.Field. Give a new control a label to pass it.
| Fuzz test | Package |
|---|---|
FuzzTokenize |
internal/sqltext: tokens cover the text; splitting, classifying and formatting hold up |
FuzzDecode |
internal/decode |
FuzzVim |
internal/ui/editor |
go test ./internal/sqltext -run '^$' -fuzz '^FuzzTokenize$' -fuzztime 60s
go test ./internal/sqltext -run '^$' -bench Tokenize -benchmemBenchmarks also cover redaction, cell text, DuckDB transaction state and literal formatting.