| name | integration-testing |
| description | How to write and run devctl integration tests — where tests live, how to write failing tests first (TDD), and how to run them safely inside an Incus container without touching live host data. |
Skill: integration-testing
The cardinal rule
NEVER run integration tests on the host machine.
The tests in tests/api/, tests/integration/, and tests/e2e/ run against a live devctl instance. They mutate real state: they send emails, create sites, install services, change settings. Running them against the host devctl corrupts live data.
All tests run INSIDE the Incus test container. The host devctl is never stopped and port 4000 on the host is never touched. Multiple containers can run in parallel without conflicts.
Test layers
| Layer | Location | Framework | Execution |
|---|
| Go API tests | tests/api/ | go test -tags integration | Compiled to binary on host, pushed into container, run via incus exec |
| BATS tests | tests/integration/ | bats-core | Pushed into container, run via incus exec (bats pre-baked in image) |
| Playwright e2e | tests/e2e/ | Playwright + Chromium | Pushed into container, run via incus exec (Playwright + Chromium pre-baked in image) |
TDD workflow — write the failing test FIRST
Follow Red → Green → Refactor. No production code before a failing test exists.
- Write the test in the right file (see below).
- Start the Incus container (see below).
- Run just that test against the container — confirm it fails for the right reason.
- Write the minimal production code fix.
- Re-run — confirm it passes.
- Run the full suite to confirm no regressions.
Where to put Go API tests
| What you're testing | File |
|---|
| Mail / Mailpit tools | tests/api/mail_test.go |
| Dumps | tests/api/ — add to existing dumps file or create dumps_test.go |
| Services (read-only) | tests/api/services_test.go |
| Services (mutating) | tests/api/services_mutate_test.go |
| Sites (read-only) | tests/api/sites_test.go |
| Sites (mutating) | tests/api/sites_mutate_test.go |
| Settings | tests/api/settings_test.go / settings_mutate_test.go |
| New area | Create tests/api/<area>_test.go |
Every file must start with:
package apitest
Test helpers available in tests/api/helpers_test.go
| Helper | Signature | Use for |
|---|
httpGet | (t, path) → []byte | GET, asserts 200 |
httpGetStatus | (t, path, wantStatus) → []byte | GET with expected status |
httpPost | (t, path, body) → ([]byte, int) | POST with optional JSON body |
httpPut | (t, path, body) → ([]byte, int) | PUT with JSON body |
httpDelete | (t, path) → ([]byte, int) | DELETE, no body |
decodeJSON[T] | (t, []byte) → T | Unmarshal JSON, fatal on error |
pollServiceStatus | (t, id, wantStatus, timeout) | Poll until service reaches status |
baseURL() reads DEVCTL_BASE_URL from the environment — when running inside the container this is set to http://127.0.0.1:4000 (the container's own devctl).
One-time image setup
Before the first test run, bake the base Incus image with all test tooling:
make test-env-setup
This installs Node.js 22, bats-core, Playwright, and Chromium into the devctl-ubuntu-base image. Re-run whenever you want to update the base image. Takes ~5-10 minutes once.
Starting the Incus test container
make build
make test-env
make test-env will:
- Launch a fresh
devctl-ubuntu-base Incus container
- Push the
./devctl binary, write the systemd unit, start the service
- Compile the Go API test binary and push it in
- Push BATS tests and Playwright test files into the container
- Wait for devctl to respond at
127.0.0.1:4000 inside the container
- Export
DEVCTL_CONTAINER=devctl-test-<timestamp> and block until Ctrl+C or the container is destroyed
- The host devctl is never touched — its port 4000 remains yours
Container cleanup is automatic. Every make test, make test-api, make test-bats, and make test-e2e stops devctl and destroys the container when finished (even on failure). make test-run does the same. Set KEEP_TEST_CONTAINER=1 to skip cleanup — used internally by make test-push for iterative runs.
Running tests against the container
make test-env blocks in one terminal. In a second terminal:
export DEVCTL_CONTAINER=devctl-test-1234567890
make test-bats
make test-api
make test-e2e
make test
make test-run
Parallel test runs
Because no host ports are bound, you can run multiple containers simultaneously:
make build && make test-env
make build && make test-env
Pushing a new binary and re-running tests
After changing Go code, rebuild and push without tearing down the container:
DEVCTL_CONTAINER=devctl-test-1234567890 make test-push
This runs make build, pushes the new binary, restarts devctl inside the container, waits for it to respond, then runs the full test suite.
Running a single Go API test
go test -c -tags=integration -o devctl.test ./tests/api/
incus file push devctl.test $DEVCTL_CONTAINER/tmp/devctl.test
incus exec $DEVCTL_CONTAINER -- chmod 755 /tmp/devctl.test
incus exec $DEVCTL_CONTAINER -- env DEVCTL_BASE_URL=http://127.0.0.1:4000 \
/tmp/devctl.test -test.v -test.run TestDeleteAllEmails
Finding the running container name
incus list
The test container is named devctl-test-<timestamp>. The currently running one will show STATE=RUNNING.
Cleaning up orphaned containers
If a test run was interrupted and left containers behind, destroy them all:
make test-cleanup-all
Or destroy a specific container:
DEVCTL_CONTAINER=devctl-test-1234567890 make test-cleanup
Example: writing a failing Go API test
package apitest
import (
"encoding/json"
"testing"
)
func TestDeleteAllEmails_RemovesAllMessages(t *testing.T) {
before := mailCount(t)
if before == 0 {
t.Fatal("need at least one email to test deletion")
}
_, status := httpDelete(t, "/api/mail/api/v1/messages")
if status != 200 {
t.Fatalf("expected 200, got %d", status)
}
after := mailCount(t)
if after != 0 {
t.Errorf("expected 0 emails after delete-all, got %d", after)
}
}
Checklist
Gotchas and known patterns
Never use run bash -c "api_get ..." in BATS tests
api_get, api_post, etc. are shell functions defined in setup.bash. They are not available in child processes created by bash -c "...".
Wrong (function not in subshell):
run bash -c "api_get /api/services | jq '.[] | .installed'"
Correct (inline curl):
run bash -c "curl -sf '${BASE_URL}/api/services' | jq '.[] | .installed'"
BASE_URL is exported in setup.bash and is available in subshells.
PHP settings tests require a PHP stub
The PHP settings tests (test_api_php_mutate.bats) require at least one PHP version to exist under SERVER_ROOT/php/. The test-env.sh script creates a PHP 8.4 stub (a no-op php-fpm binary + a real php.ini with the four tracked settings) in Step 7a so the read/write round-trip works.
If the stub is missing, GET /api/php/settings returns hardcoded defaults, PUT /api/php/settings silently no-ops (no PHP versions found to write to), and all settings tests will fail with unexpected values.
MySQL creates wrapper scripts, not symlinks
MySQL's InstallW calls WrapperScriptIntoBinDir (not LinkIntoBinDir) for mysql, mysqldump, and mysqladmin. This writes executable shell scripts (not symlinks) into the shared bin/ directory. Tests should check with test -x (executable), not test -L (symlink).