Files
go-admin/test/e2e-apporder/e2e_test.go
zhangwenjian 1f56b956d2 test: build the end-to-end binary once, and assert from one list
Three tests each called newEnv, and newEnv built the binary, so the same
binary was linked three times - about 17 seconds of the run, measured. A
binary is read-only and there is nothing to isolate between tests; each test
still gets its own directory and its own database. The package now builds it
on the first test that needs one and removes it in TestMain. The suite goes
from 39 seconds to 9.

The three assertion blocks listed the same six queries, two or three times
each, differing only in the counts expected - so renaming a table meant
finding three places. They now share one list, with a flag for the uninstall's
"all of them at zero". The reinstall check gets stronger on the way past: it
was three of the six and is now all six.

The per-call sql.Open in count and exec stays. It looks like waste and is not:
the binary under test writes the same file, and a connection held open across
a run of it is a second writer for nothing. The shared part is factored out;
the opening is still per call, and the comment now says why.
2026-09-11 08:21:36 +08:00

310 lines
9.3 KiB
Go

package main
import (
"database/sql"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
_ "github.com/glebarez/go-sqlite"
)
// The whole 008 chain, against a binary that was actually built and a
// database that was actually migrated.
//
// Everything below this level is covered by unit tests with an injected
// engine and a hand-built schema, which is where the shapes are pinned down.
// What only this can catch is the wiring: that an application's init()
// reaches both registries, that `install` finds a manifest through
// app.Snapshot, that the seeder writes what the uninstaller looks for, and
// that the command exits non-zero when a migration fails - the last of which
// a deployment reads to decide whether to start the new version.
const settings = `settings:
application:
host: 0.0.0.0
mode: dev
name: e2e
port: 8000
readtimeout: 10000
writertimeout: 20000
database:
driver: sqlite3
source: ./e2e.db
jwt:
secret: e2e
timeout: 3600
logger:
path: temp/logs
stdout: default
level: error
enableddb: false
queue:
memory:
poolSize: 10
`
type env struct {
t *testing.T
dir string
bin string
}
// The binary is built once for the whole package. Every test drives the same
// one against its own directory and its own database, and a binary is
// read-only, so there is nothing to isolate - building it per test was three
// links of the same thing.
var (
buildOnce sync.Once
sharedDir string
sharedBin string
buildErr error
)
func TestMain(m *testing.M) {
code := m.Run()
if sharedDir != "" {
os.RemoveAll(sharedDir)
}
os.Exit(code)
}
// binary builds the go-admin binary with the example application linked in,
// on the first call that needs it.
func binary(t *testing.T) string {
t.Helper()
buildOnce.Do(func() {
sharedDir, buildErr = os.MkdirTemp("", "go-admin-e2e")
if buildErr != nil {
return
}
sharedBin = filepath.Join(sharedDir, "go-admin-e2e")
out, err := exec.Command("go", "build", "-tags", "sqlite3", "-o", sharedBin, ".").CombinedOutput()
if err != nil {
buildErr = fmt.Errorf("building the binary: %v\n%s", err, out)
}
})
if buildErr != nil {
t.Fatal(buildErr)
}
return sharedBin
}
// newEnv lays out a working directory for the binary to run in.
func newEnv(t *testing.T) *env {
t.Helper()
if testing.Short() {
t.Skip("builds a binary and migrates a database; skipped under -short")
}
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "config"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "temp", "logs"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "config", "settings.yml"), []byte(settings), 0o644); err != nil {
t.Fatal(err)
}
// The framework's first migration reads this file rather than carrying
// the rows in Go.
seedSQL, err := os.ReadFile(filepath.Join("..", "..", "config", "db.sql"))
if err != nil {
t.Fatalf("reading config/db.sql: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "config", "db.sql"), seedSQL, 0o644); err != nil {
t.Fatal(err)
}
return &env{t: t, dir: dir, bin: binary(t)}
}
// run executes the binary and returns its combined output and exit code.
func (e *env) run(args ...string) (string, int) {
e.t.Helper()
args = append(args, "-c", "config/settings.yml")
cmd := exec.Command(e.bin, args...)
cmd.Dir = e.dir
out, err := cmd.CombinedOutput()
code := 0
if ee, ok := err.(*exec.ExitError); ok {
code = ee.ExitCode()
} else if err != nil {
e.t.Fatalf("running %v: %v", args, err)
}
return string(out), code
}
func (e *env) mustRun(args ...string) string {
e.t.Helper()
out, code := e.run(args...)
if code != 0 {
e.t.Fatalf("%v exited %d:\n%s", args, code, out)
}
return out
}
// open connects to the database the binary uses.
//
// Per call, and closed again straight away, on purpose: the binary under test
// writes this same file, and a connection the test process holds open across
// a run of it is a second writer for no reason. The cost is a few
// milliseconds against a build measured in seconds.
func (e *env) open() *sql.DB {
e.t.Helper()
db, err := sql.Open("sqlite", filepath.Join(e.dir, "e2e.db"))
if err != nil {
e.t.Fatal(err)
}
return db
}
// exec runs one statement against the database the binary uses.
func (e *env) exec(stmt string) {
e.t.Helper()
db := e.open()
defer db.Close()
if _, err := db.Exec(stmt); err != nil {
e.t.Fatalf("%s: %v", stmt, err)
}
}
func (e *env) count(query string, args ...any) int {
e.t.Helper()
db := e.open()
defer db.Close()
var n int
if err := db.QueryRow(query, args...).Scan(&n); err != nil {
e.t.Fatalf("%s: %v", query, err)
}
return n
}
// seeded is what installing this application writes, with the counts a
// finished install leaves behind. An uninstall wants every one of them at
// zero, and a reinstall wants them back - which is why one list serves all
// three checks instead of three lists drifting apart.
var seeded = []struct {
what string
query string
want int
}{
{"menus", "SELECT COUNT(*) FROM sys_menu WHERE app_code = 'order'", 4},
{"apis", "SELECT COUNT(*) FROM sys_api WHERE app_code = 'order'", 4},
{"ledger", "SELECT COUNT(*) FROM sys_app_casbin_grant WHERE app_code = 'order'", 4},
{"policies", "SELECT COUNT(*) FROM casbin_rule WHERE v1 LIKE '/api/v1/order%'", 4},
{"migration records", "SELECT COUNT(*) FROM sys_migration WHERE app_code = 'order'", 1},
{"sys_app rows", "SELECT COUNT(*) FROM sys_app WHERE app_code = 'order'", 1},
}
// assertSeeded checks every row of seeded. gone flips the expectation to
// zero, which is the whole of what an uninstall has to leave.
func (e *env) assertSeeded(when string, gone bool) {
e.t.Helper()
for _, c := range seeded {
want := c.want
if gone {
want = 0
}
if n := e.count(c.query); n != want {
e.t.Errorf("%s, %s = %d, want %d", when, c.what, n, want)
}
}
}
func TestInstallUninstallReinstall(t *testing.T) {
e := newEnv(t)
// Framework only. The application's migration must not run here, or the
// install below has nothing left to do and the interesting half of it
// goes untested - which is what happens if this uses plain `migrate`.
e.mustRun("migrate", "--app", "core")
if n := e.count("SELECT COUNT(*) FROM sys_migration WHERE app_code = 'order'"); n != 0 {
t.Fatalf("the application's migration ran during the framework's: %d rows", n)
}
if n := e.count("SELECT COUNT(*) FROM sqlite_master WHERE name = 'app_order'"); n != 0 {
t.Fatal("the application's own table exists before it was installed")
}
// A1.
out := e.mustRun("migrate", "install", "order")
if !strings.Contains(out, "order-1793800000000") {
t.Errorf("the install did not report applying the migration:\n%s", out)
}
// A7.
if !strings.Contains(out, "rebuild") {
t.Errorf("the install did not say the code is not running yet:\n%s", out)
}
e.assertSeeded("after install", false)
if n := e.count("SELECT COUNT(*) FROM sqlite_master WHERE name = 'app_order'"); n != 1 {
t.Error("the application's own table was not created")
}
if n := e.count("SELECT COUNT(*) FROM sys_app WHERE app_code = 'order' AND status = 2"); n != 1 {
t.Error("sys_app does not say the install finished")
}
// A2: installing the same version again does nothing and says so.
out = e.mustRun("migrate", "install", "order")
if !strings.Contains(out, "already installed") {
t.Errorf("a second install was not reported as a no-op:\n%s", out)
}
// A3: the application's own data is not the uninstaller's to remove.
e.exec("INSERT INTO app_order (created_at, updated_at) VALUES (datetime('now'), datetime('now'))")
if n := e.count("SELECT COUNT(*) FROM app_order"); n != 1 {
t.Fatalf("the business row was not written: %d", n)
}
out = e.mustRun("migrate", "uninstall", "order")
if !strings.Contains(out, "own tables were not touched") {
t.Errorf("the uninstall did not say what it left alone:\n%s", out)
}
e.assertSeeded("after uninstall", true)
if n := e.count("SELECT COUNT(*) FROM sqlite_master WHERE name = 'app_order'"); n != 1 {
t.Error("the uninstall dropped the application's own table")
}
if n := e.count("SELECT COUNT(*) FROM app_order"); n != 1 {
t.Errorf("the uninstall removed %d business row(s)", 1-n)
}
// A4: the migration records had to go, or this reinstall finds every
// version applied, seeds nothing, and reports success.
e.mustRun("migrate", "install", "order")
e.assertSeeded("after reinstall", false)
if n := e.count("SELECT COUNT(*) FROM app_order"); n != 1 {
t.Error("the business row did not survive an uninstall and reinstall")
}
}
// A deployment decides whether to start the new version on this exit code.
func TestMigrateExitsNonZeroWhenItFails(t *testing.T) {
e := newEnv(t)
// The framework's first migration reads config/db.sql. Without it the
// migration fails, which is the cheapest real failure to arrange.
if err := os.Remove(filepath.Join(e.dir, "config", "db.sql")); err != nil {
t.Fatal(err)
}
out, code := e.run("migrate")
if code == 0 {
t.Errorf("a failed migration exited 0:\n%s", out)
}
}
func TestUnknownAppIsRefused(t *testing.T) {
e := newEnv(t)
e.mustRun("migrate", "--app", "core")
out, code := e.run("migrate", "install", "ordr")
if code == 0 {
t.Errorf("a mistyped code was installed:\n%s", out)
}
if !strings.Contains(out, "order") {
t.Errorf("the refusal does not name what is registered:\n%s", out)
}
}