Skip to content

Commit 6d3d020

Browse files
committed
Fix testutil: create new database for each test
I spent a bunch of time messing with locks, mutexes etc. I have concluded I and Claude do not understand how Go's test runner works. This seems the easiest way to actually get isolation in tests to avoid flakey weird problems.
1 parent 08c9f85 commit 6d3d020

File tree

1 file changed

+41
-122
lines changed

1 file changed

+41
-122
lines changed

internal/database/testutil.go

Lines changed: 41 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -2,55 +2,63 @@ package database
22

33
import (
44
"context"
5+
"crypto/rand"
6+
"encoding/binary"
57
"fmt"
6-
"sync"
78
"testing"
89
"time"
910

11+
"github.com/jackc/pgx/v5"
1012
"github.com/stretchr/testify/require"
1113
)
1214

13-
var (
14-
// testDBMutex ensures only one test modifies the database at a time
15-
// This prevents parallel tests from interfering with each other
16-
testDBMutex sync.Mutex
17-
)
18-
19-
const (
20-
// Advisory lock key for database schema initialization
21-
// Using a fixed key ensures all test processes coordinate on the same lock
22-
testSchemaLockKey = 123456789
23-
)
24-
25-
// NewTestDB creates a new PostgreSQL database connection for testing.
26-
// It ensures the database schema is initialized once per test run, then just clears data per test.
15+
// NewTestDB creates an isolated PostgreSQL database for each test.
16+
// Each test gets its own database with a random name, eliminating all coordination issues.
2717
// Requires PostgreSQL to be running on localhost:5432 (e.g., via docker-compose).
2818
func NewTestDB(t *testing.T) Database {
2919
t.Helper()
3020

31-
// Acquire mutex to prevent parallel test interference
32-
testDBMutex.Lock()
33-
t.Cleanup(func() {
34-
testDBMutex.Unlock()
35-
})
36-
37-
// Create context with timeout for database operations
38-
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
21+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
3922
defer cancel()
4023

41-
// Connect to test database
42-
connectionURI := "postgres://mcpregistry:mcpregistry@localhost:5432/mcp-registry?sslmode=disable"
43-
db, err := NewPostgreSQL(ctx, connectionURI)
44-
require.NoError(t, err, "Failed to connect to test PostgreSQL database. Make sure PostgreSQL is running via: docker-compose up -d postgres")
24+
// Generate unique database name for this test
25+
var randomBytes [8]byte
26+
_, err := rand.Read(randomBytes[:])
27+
require.NoError(t, err, "Failed to generate random database id")
28+
randomInt := binary.BigEndian.Uint64(randomBytes[:])
29+
dbName := fmt.Sprintf("test_%d", randomInt)
30+
31+
// Connect to postgres database to create test database
32+
adminURI := "postgres://mcpregistry:mcpregistry@localhost:5432/postgres?sslmode=disable"
33+
adminConn, err := pgx.Connect(ctx, adminURI)
34+
require.NoError(t, err, "Failed to connect to PostgreSQL. Make sure PostgreSQL is running via: docker-compose up -d postgres")
35+
defer adminConn.Close(ctx)
4536

46-
// Initialize schema once per test suite run using advisory locks for cross-process coordination
47-
err = initializeTestSchemaWithLock(db)
48-
require.NoError(t, err, "Failed to initialize test database schema")
37+
// Create test database
38+
_, err = adminConn.Exec(ctx, fmt.Sprintf("CREATE DATABASE %s", dbName))
39+
require.NoError(t, err, "Failed to create test database")
4940

50-
// Clear data for this specific test
51-
clearTestData(t, db)
41+
// Register cleanup to drop database
42+
t.Cleanup(func() {
43+
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
44+
defer cleanupCancel()
45+
46+
// Terminate any remaining connections
47+
_, _ = adminConn.Exec(cleanupCtx, fmt.Sprintf(
48+
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '%s' AND pid <> pg_backend_pid()",
49+
dbName,
50+
))
51+
52+
// Drop database
53+
_, _ = adminConn.Exec(cleanupCtx, fmt.Sprintf("DROP DATABASE IF EXISTS %s", dbName))
54+
})
55+
56+
// Connect to test database
57+
testURI := fmt.Sprintf("postgres://mcpregistry:mcpregistry@localhost:5432/%s?sslmode=disable", dbName)
58+
db, err := NewPostgreSQL(ctx, testURI)
59+
require.NoError(t, err, "Failed to connect to test database")
5260

53-
// Register cleanup function to close database connection
61+
// Register cleanup to close connection
5462
t.Cleanup(func() {
5563
if err := db.Close(); err != nil {
5664
t.Logf("Warning: failed to close test database connection: %v", err)
@@ -59,92 +67,3 @@ func NewTestDB(t *testing.T) Database {
5967

6068
return db
6169
}
62-
63-
// initializeTestSchemaWithLock sets up a fresh database schema with all migrations applied
64-
// Uses PostgreSQL advisory locks to ensure only one process initializes the schema
65-
func initializeTestSchemaWithLock(db Database) error {
66-
// Cast to PostgreSQL to access the connection pool
67-
pgDB, ok := db.(*PostgreSQL)
68-
if !ok {
69-
return fmt.Errorf("expected PostgreSQL database instance")
70-
}
71-
72-
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
73-
defer cancel()
74-
75-
// Acquire advisory lock to coordinate schema initialization across processes
76-
_, err := pgDB.pool.Exec(ctx, "SELECT pg_advisory_lock($1)", testSchemaLockKey)
77-
if err != nil {
78-
return fmt.Errorf("failed to acquire advisory lock: %w", err)
79-
}
80-
defer func() {
81-
// Always release the advisory lock
82-
_, _ = pgDB.pool.Exec(context.Background(), "SELECT pg_advisory_unlock($1)", testSchemaLockKey)
83-
}()
84-
85-
// Check if schema already exists (another process may have initialized it)
86-
var tableCount int64
87-
err = pgDB.pool.QueryRow(ctx, "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'servers'").Scan(&tableCount)
88-
if err != nil {
89-
return fmt.Errorf("failed to check if schema exists: %w", err)
90-
}
91-
92-
if tableCount > 0 {
93-
// Schema already exists, nothing to do
94-
return nil
95-
}
96-
97-
// Initialize the schema
98-
return initializeTestSchema(db)
99-
}
100-
101-
// initializeTestSchema sets up a fresh database schema with all migrations applied
102-
// This should only be called from initializeTestSchemaWithLock
103-
func initializeTestSchema(db Database) error {
104-
// Cast to PostgreSQL to access the connection pool
105-
pgDB, ok := db.(*PostgreSQL)
106-
if !ok {
107-
return fmt.Errorf("expected PostgreSQL database instance")
108-
}
109-
110-
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
111-
defer cancel()
112-
113-
// Drop and recreate schema completely fresh
114-
_, err := pgDB.pool.Exec(ctx, "DROP SCHEMA public CASCADE; CREATE SCHEMA public;")
115-
if err != nil {
116-
return fmt.Errorf("failed to reset database schema: %w", err)
117-
}
118-
119-
// Apply all migrations from scratch
120-
conn, err := pgDB.pool.Acquire(ctx)
121-
if err != nil {
122-
return fmt.Errorf("failed to acquire connection for migration: %w", err)
123-
}
124-
defer conn.Release()
125-
126-
migrator := NewMigrator(conn.Conn())
127-
err = migrator.Migrate(ctx)
128-
if err != nil {
129-
return fmt.Errorf("failed to run database migrations: %w", err)
130-
}
131-
132-
return nil
133-
}
134-
135-
// clearTestData removes all data from test tables while preserving schema
136-
// This runs before each individual test
137-
func clearTestData(t *testing.T, db Database) {
138-
t.Helper()
139-
140-
// Cast to PostgreSQL to access the connection pool
141-
pgDB, ok := db.(*PostgreSQL)
142-
require.True(t, ok, "Expected PostgreSQL database instance")
143-
144-
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
145-
defer cancel()
146-
147-
// Clear all data but keep schema intact
148-
_, err := pgDB.pool.Exec(ctx, "TRUNCATE TABLE servers RESTART IDENTITY CASCADE")
149-
require.NoError(t, err, "Failed to clear test data")
150-
}

0 commit comments

Comments
 (0)