Skip to content

Commit 26fd6f3

Browse files
mafredripull[bot]
authored andcommitted
feat: Add database fixtures for testing migrations (#4858)
1 parent a9527f9 commit 26fd6f3

File tree

9 files changed

+6717
-0
lines changed

9 files changed

+6717
-0
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#!/usr/bin/env bash
2+
3+
# Naming the fixture is optional, if missing, the name of the latest
4+
# migration will be used.
5+
#
6+
# Usage:
7+
# ./create_fixture
8+
# ./create_fixture name of fixture
9+
# ./create_fixture "name of fixture"
10+
# ./create_fixture name_of_fixture
11+
12+
set -euo pipefail
13+
14+
SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}")
15+
(
16+
cd "$SCRIPT_DIR"
17+
18+
latest_migration=$(basename "$(find . -maxdepth 1 -name "*.up.sql" | sort -n | tail -n 1)")
19+
if [[ -n "${*}" ]]; then
20+
name=$*
21+
name=${name// /_}
22+
num=${latest_migration%%_*}
23+
latest_migration="${num}_${name}.up.sql"
24+
fi
25+
26+
filename="$(pwd)/testdata/fixtures/$latest_migration"
27+
touch "$filename"
28+
echo "$filename"
29+
echo "Edit fixture and commit it."
30+
)

coderd/database/migrations/migrate.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"database/sql"
66
"embed"
77
"errors"
8+
"io/fs"
89
"os"
910

1011
"github.com/golang-migrate/migrate/v4"
@@ -160,3 +161,52 @@ func CheckLatestVersion(sourceDriver source.Driver, currentVersion uint) error {
160161
}
161162
return nil
162163
}
164+
165+
// Stepper returns a function that runs SQL migrations one step at a time.
166+
//
167+
// Stepper cannot be closed pre-emptively, it must be run to completion
168+
// (or until an error is encountered).
169+
func Stepper(db *sql.DB) (next func() (version uint, more bool, err error), err error) {
170+
_, m, err := setup(db)
171+
if err != nil {
172+
return nil, xerrors.Errorf("migrate setup: %w", err)
173+
}
174+
175+
return func() (version uint, more bool, err error) {
176+
defer func() {
177+
if !more {
178+
srcErr, dbErr := m.Close()
179+
if err != nil {
180+
return
181+
}
182+
if dbErr != nil {
183+
err = dbErr
184+
return
185+
}
186+
err = srcErr
187+
}
188+
}()
189+
190+
err = m.Steps(1)
191+
if err != nil {
192+
switch {
193+
case errors.Is(err, migrate.ErrNoChange):
194+
// It's OK if no changes happened!
195+
return 0, false, nil
196+
case errors.Is(err, fs.ErrNotExist):
197+
// This error is encountered at the of Steps when
198+
// reading from embed.FS.
199+
return 0, false, nil
200+
}
201+
202+
return 0, false, xerrors.Errorf("Step: %w", err)
203+
}
204+
205+
v, _, err := m.Version()
206+
if err != nil {
207+
return 0, false, err
208+
}
209+
210+
return v, true, nil
211+
}, nil
212+
}

coderd/database/migrations/migrate_test.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,27 @@
33
package migrations_test
44

55
import (
6+
"context"
67
"database/sql"
78
"fmt"
9+
"os"
10+
"path/filepath"
11+
"sync"
812
"testing"
913

14+
"github.com/golang-migrate/migrate/v4"
15+
migratepostgres "github.com/golang-migrate/migrate/v4/database/postgres"
1016
"github.com/golang-migrate/migrate/v4/source"
17+
"github.com/golang-migrate/migrate/v4/source/iofs"
1118
"github.com/golang-migrate/migrate/v4/source/stub"
19+
"github.com/lib/pq"
1220
"github.com/stretchr/testify/require"
1321
"go.uber.org/goleak"
22+
"golang.org/x/exp/slices"
1423

1524
"github.com/coder/coder/coderd/database/migrations"
1625
"github.com/coder/coder/coderd/database/postgres"
26+
"github.com/coder/coder/testutil"
1727
)
1828

1929
func TestMain(m *testing.M) {
@@ -129,3 +139,196 @@ func TestCheckLatestVersion(t *testing.T) {
129139
})
130140
}
131141
}
142+
143+
func setupMigrate(t *testing.T, db *sql.DB, name, path string) (source.Driver, *migrate.Migrate) {
144+
t.Helper()
145+
146+
ctx := context.Background()
147+
148+
conn, err := db.Conn(ctx)
149+
require.NoError(t, err)
150+
151+
dbDriver, err := migratepostgres.WithConnection(ctx, conn, &migratepostgres.Config{
152+
MigrationsTable: "test_migrate_" + name,
153+
})
154+
require.NoError(t, err)
155+
156+
dirFS := os.DirFS(path)
157+
d, err := iofs.New(dirFS, ".")
158+
require.NoError(t, err)
159+
t.Cleanup(func() {
160+
d.Close()
161+
})
162+
163+
m, err := migrate.NewWithInstance(name, d, "", dbDriver)
164+
require.NoError(t, err)
165+
t.Cleanup(func() {
166+
m.Close()
167+
})
168+
169+
return d, m
170+
}
171+
172+
type tableStats struct {
173+
mu sync.Mutex
174+
s map[string]int
175+
}
176+
177+
func (s *tableStats) Add(table string, n int) {
178+
s.mu.Lock()
179+
defer s.mu.Unlock()
180+
181+
s.s[table] = s.s[table] + n
182+
}
183+
184+
func (s *tableStats) Empty() []string {
185+
s.mu.Lock()
186+
defer s.mu.Unlock()
187+
188+
var m []string
189+
for table, n := range s.s {
190+
if n == 0 {
191+
m = append(m, table)
192+
}
193+
}
194+
return m
195+
}
196+
197+
func TestMigrateUpWithFixtures(t *testing.T) {
198+
t.Parallel()
199+
200+
if testing.Short() {
201+
t.Skip()
202+
return
203+
}
204+
205+
type testCase struct {
206+
name string
207+
path string
208+
209+
// For determining if test case table stats
210+
// are used to determine test coverage.
211+
useStats bool
212+
}
213+
tests := []testCase{
214+
{
215+
name: "fixtures",
216+
path: filepath.Join("testdata", "fixtures"),
217+
useStats: true,
218+
},
219+
// More test cases added via glob below.
220+
}
221+
222+
// Folders in testdata/full_dumps represent fixtures for a full
223+
// deployment of Coder.
224+
matches, err := filepath.Glob(filepath.Join("testdata", "full_dumps", "*"))
225+
require.NoError(t, err)
226+
for _, match := range matches {
227+
tests = append(tests, testCase{
228+
name: filepath.Base(match),
229+
path: match,
230+
useStats: true,
231+
})
232+
}
233+
234+
// These tables are allowed to have zero rows for now,
235+
// but we should eventually add fixtures for them.
236+
ignoredTablesForStats := []string{
237+
"audit_logs",
238+
"git_auth_links",
239+
"group_members",
240+
"licenses",
241+
"replicas",
242+
}
243+
s := &tableStats{s: make(map[string]int)}
244+
245+
// This will run after all subtests have run and fail the test if
246+
// new tables have been added without covering them with fixtures.
247+
t.Cleanup(func() {
248+
emptyTables := s.Empty()
249+
slices.Sort(emptyTables)
250+
for _, table := range ignoredTablesForStats {
251+
i := slices.Index(emptyTables, table)
252+
if i >= 0 {
253+
emptyTables = slices.Delete(emptyTables, i, i+1)
254+
}
255+
}
256+
if len(emptyTables) > 0 {
257+
t.Logf("The following tables have zero rows, consider adding fixtures for them or create a full database dump:")
258+
t.Errorf("tables have zero rows: %v", emptyTables)
259+
t.Logf("See https://github.com/coder/coder/blob/main/docs/CONTRIBUTING.md#database-fixtures-for-testing-migrations for more information")
260+
}
261+
})
262+
263+
for _, tt := range tests {
264+
tt := tt
265+
266+
t.Run(tt.name, func(t *testing.T) {
267+
t.Parallel()
268+
269+
db := testSQLDB(t)
270+
271+
ctx, _ := testutil.Context(t)
272+
273+
// Prepare database for stepping up.
274+
err := migrations.Down(db)
275+
require.NoError(t, err)
276+
277+
// Initialize migrations for fixtures.
278+
fDriver, fMigrate := setupMigrate(t, db, tt.name, tt.path)
279+
280+
nextStep, err := migrations.Stepper(db)
281+
require.NoError(t, err)
282+
283+
var fixtureVer uint
284+
nextFixtureVer, err := fDriver.First()
285+
require.NoError(t, err)
286+
287+
for {
288+
version, more, err := nextStep()
289+
require.NoError(t, err)
290+
291+
if !more {
292+
// We reached the end of the migrations.
293+
break
294+
}
295+
296+
if nextFixtureVer == version {
297+
err = fMigrate.Steps(1)
298+
require.NoError(t, err)
299+
fixtureVer = version
300+
301+
nv, _ := fDriver.Next(nextFixtureVer)
302+
if nv > 0 {
303+
nextFixtureVer = nv
304+
}
305+
}
306+
307+
t.Logf("migrated to version %d, fixture version %d", version, fixtureVer)
308+
}
309+
310+
// Gather number of rows for all existing tables
311+
// at the end of the migrations and fixtures.
312+
var tables pq.StringArray
313+
err = db.QueryRowContext(ctx, `
314+
SELECT array_agg(tablename)
315+
FROM pg_catalog.pg_tables
316+
WHERE
317+
schemaname != 'information_schema'
318+
AND schemaname != 'pg_catalog'
319+
AND tablename NOT LIKE 'test_migrate_%'
320+
`).Scan(&tables)
321+
require.NoError(t, err)
322+
323+
for _, table := range tables {
324+
var count int
325+
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+table).Scan(&count)
326+
require.NoError(t, err)
327+
328+
if tt.useStats {
329+
s.Add(table, count)
330+
}
331+
}
332+
})
333+
}
334+
}

0 commit comments

Comments
 (0)