Skip to content

Commit abfe62d

Browse files
Spin out cmd tests
1 parent e1c79cc commit abfe62d

File tree

8 files changed

+380
-49
lines changed

8 files changed

+380
-49
lines changed

cmd/pg-schema-diff/apply_cmd.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func buildApplyCmd() *cobra.Command {
4949

5050
cmd.SilenceUsage = true
5151

52-
plan, err := generatePlan(context.Background(), generatePlanParameters{
52+
plan, err := generatePlan(cmd.Context(), generatePlanParameters{
5353
fromSchema: fromSchema,
5454
toSchema: toSchema,
5555
tempDbConnConfig: connConfig,
@@ -59,12 +59,12 @@ func buildApplyCmd() *cobra.Command {
5959
if err != nil {
6060
return err
6161
} else if len(plan.Statements) == 0 {
62-
fmt.Println("Schema matches expected. No plan generated")
62+
cmd.Println("Schema matches expected. No plan generated")
6363
return nil
6464
}
6565

66-
fmt.Println(header("Review plan"))
67-
fmt.Print(planToPrettyS(plan), "\n\n")
66+
cmd.Println(header("Review plan"))
67+
cmd.Print(planToPrettyS(plan), "\n\n")
6868

6969
if err := failIfHazardsNotAllowed(plan, *allowedHazardsTypesStrs); err != nil {
7070
return err
@@ -81,10 +81,10 @@ func buildApplyCmd() *cobra.Command {
8181
}
8282
}
8383

84-
if err := runPlan(context.Background(), connConfig, plan); err != nil {
84+
if err := runPlan(cmd.Context(), cmd, connConfig, plan); err != nil {
8585
return err
8686
}
87-
fmt.Println("Schema applied successfully")
87+
cmd.Println("Schema applied successfully")
8888
return nil
8989
}
9090

@@ -123,7 +123,7 @@ func failIfHazardsNotAllowed(plan diff.Plan, allowedHazardsTypesStrs []string) e
123123
return nil
124124
}
125125

126-
func runPlan(ctx context.Context, connConfig *pgx.ConnConfig, plan diff.Plan) error {
126+
func runPlan(ctx context.Context, cmd *cobra.Command, connConfig *pgx.ConnConfig, plan diff.Plan) error {
127127
connPool, err := openDbWithPgxConfig(connConfig)
128128
if err != nil {
129129
return err
@@ -143,8 +143,8 @@ func runPlan(ctx context.Context, connConfig *pgx.ConnConfig, plan diff.Plan) er
143143
// must be executed within its own transaction block. Postgres will error if you try to set a TRANSACTION-level
144144
// timeout for it. SESSION-level statement_timeouts are respected by `ADD INDEX CONCURRENTLY`
145145
for i, stmt := range plan.Statements {
146-
fmt.Println(header(fmt.Sprintf("Executing statement %d", getDisplayableStmtIdx(i))))
147-
fmt.Printf("%s\n\n", statementToPrettyS(stmt))
146+
cmd.Println(header(fmt.Sprintf("Executing statement %d", getDisplayableStmtIdx(i))))
147+
cmd.Printf("%s\n\n", statementToPrettyS(stmt))
148148
start := time.Now()
149149
if _, err := conn.ExecContext(ctx, fmt.Sprintf("SET SESSION statement_timeout = %d", stmt.Timeout.Milliseconds())); err != nil {
150150
return fmt.Errorf("setting statement timeout: %w", err)
@@ -155,9 +155,9 @@ func runPlan(ctx context.Context, connConfig *pgx.ConnConfig, plan diff.Plan) er
155155
if _, err := conn.ExecContext(ctx, stmt.ToSQL()); err != nil {
156156
return fmt.Errorf("executing migration statement. the database maybe be in a dirty state: %s: %w", stmt, err)
157157
}
158-
fmt.Printf("Finished executing statement. Duration: %s\n", time.Since(start))
158+
cmd.Printf("Finished executing statement. Duration: %s\n", time.Since(start))
159159
}
160-
fmt.Println(header("Complete"))
160+
cmd.Println(header("Complete"))
161161

162162
return nil
163163
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package main
2+
3+
import (
4+
"github.com/stripe/pg-schema-diff/internal/pgdump"
5+
"github.com/stripe/pg-schema-diff/internal/pgengine"
6+
)
7+
8+
func (suite *cmdTestSuite) TestApplyCmd() {
9+
// Non-comprehensive set of tests for the plan command. Not totally comprehensive to avoid needing to avoid
10+
// hindering developer velocity when updating the command.
11+
type testCase struct {
12+
name string
13+
// fromDbArg is an optional argument to override the default "--from-dsn" arg.
14+
fromDbArg func(db *pgengine.DB) []string
15+
args []string
16+
// dynamicArgs is function that can be used to build args that are dynamic, i.e.,
17+
// saving schemas to a randomly generated temporary directory.
18+
dynamicArgs []dArgGenerator
19+
20+
outputContains []string
21+
// expectedSchema is the schema that is expected to be in the database after the migration.
22+
// If nil, the expected schema will be the fromDDL.
23+
expectedSchemaDDL []string
24+
// expectErrContains is a list of substrings that are expected to be contained in the error returned by
25+
// cmd.RunE. This is DISTINCT from stdErr.
26+
expectErrContains []string
27+
}
28+
for _, tc := range []testCase{
29+
{
30+
name: "to dir",
31+
dynamicArgs: []dArgGenerator{tempSchemaDirDArg("to-dir", []string{"CREATE TABLE foobar();"})},
32+
33+
expectedSchemaDDL: []string{"CREATE TABLE foobar();"},
34+
},
35+
{
36+
name: "to dsn",
37+
dynamicArgs: []dArgGenerator{tempDsnDArg(suite.pgEngine, "to-dsn", []string{"CREATE TABLE foobar();"})},
38+
39+
expectedSchemaDDL: []string{"CREATE TABLE foobar();"},
40+
},
41+
{
42+
name: "from empty dsn",
43+
fromDbArg: func(db *pgengine.DB) []string {
44+
tempSetPqEnvVarsForDb(suite.T(), db)
45+
return []string{"--from-empty-dsn"}
46+
},
47+
dynamicArgs: []dArgGenerator{tempSchemaDirDArg("to-dir", []string{"CREATE TABLE foobar();"})},
48+
49+
expectedSchemaDDL: []string{"CREATE TABLE foobar();"},
50+
},
51+
{
52+
name: "no to schema provided",
53+
expectErrContains: []string{"must be set"},
54+
},
55+
{
56+
name: "two to schemas provided",
57+
args: []string{"--to-dir", "some-other-dir", "--to-dsn", "some-dsn"},
58+
expectErrContains: []string{"only one of"},
59+
},
60+
} {
61+
suite.Run(tc.name, func() {
62+
fromDb := tempDbWithSchema(suite.T(), suite.pgEngine, nil)
63+
if tc.fromDbArg == nil {
64+
tc.fromDbArg = func(db *pgengine.DB) []string {
65+
return []string{"--from-dsn", db.GetDSN()}
66+
}
67+
}
68+
args := append([]string{
69+
"apply",
70+
"--skip-confirm-prompt",
71+
}, tc.fromDbArg(fromDb)...)
72+
args = append(args, tc.args...)
73+
suite.runCmdWithAssertions(runCmdWithAssertionsParams{
74+
args: args,
75+
dynamicArgs: tc.dynamicArgs,
76+
outputContains: tc.outputContains,
77+
expectErrContains: tc.expectErrContains,
78+
})
79+
// The migration should have been successful. Assert it was.
80+
expectedDb := tempDbWithSchema(suite.T(), suite.pgEngine, tc.expectedSchemaDDL)
81+
expectedDbDump, err := pgdump.GetDump(expectedDb, pgdump.WithSchemaOnly())
82+
suite.Require().NoError(err)
83+
fromDbDump, err := pgdump.GetDump(fromDb, pgdump.WithSchemaOnly())
84+
suite.Require().NoError(err)
85+
86+
suite.Equal(expectedDbDump, fromDbDump)
87+
})
88+
}
89+
}

cmd/pg-schema-diff/main.go

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,19 @@ import (
66
"github.com/spf13/cobra"
77
)
88

9-
// rootCmd represents the base command when called without any subcommands
10-
var rootCmd = &cobra.Command{
11-
Use: "pg-schema-diff",
12-
Short: "Diff two Postgres schemas and generate the SQL to get from one to the other",
13-
}
14-
15-
func init() {
9+
func buildRootCmd() *cobra.Command {
10+
rootCmd := &cobra.Command{
11+
Use: "pg-schema-diff",
12+
Short: "Diff two Postgres schemas and generate the SQL to get from one to the other",
13+
}
1614
rootCmd.AddCommand(buildPlanCmd())
1715
rootCmd.AddCommand(buildApplyCmd())
1816
rootCmd.AddCommand(buildVersionCmd())
17+
return rootCmd
1918
}
2019

2120
func main() {
22-
err := rootCmd.Execute()
23-
if err != nil {
21+
if err := buildRootCmd().Execute(); err != nil {
2422
os.Exit(1)
2523
}
2624
}

cmd/pg-schema-diff/main_test.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"database/sql"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/stretchr/testify/require"
12+
"github.com/stretchr/testify/suite"
13+
"github.com/stripe/pg-schema-diff/internal/pgengine"
14+
)
15+
16+
type cmdTestSuite struct {
17+
suite.Suite
18+
pgEngine *pgengine.Engine
19+
}
20+
21+
func (suite *cmdTestSuite) SetupSuite() {
22+
pgEngine, err := pgengine.StartEngine()
23+
suite.Require().NoError(err)
24+
suite.pgEngine = pgEngine
25+
}
26+
27+
func (suite *cmdTestSuite) TearDownSuite() {
28+
suite.Require().NoError(suite.pgEngine.Close())
29+
}
30+
31+
type runCmdWithAssertionsParams struct {
32+
args []string
33+
// dynamicArgs is function that can be used to build args that are dynamic, i.e.,
34+
// saving schemas to a randomly generated temporary directory.
35+
dynamicArgs []dArgGenerator
36+
37+
// outputContains is a list of substrings that are expected to be contained in the stdout output of the command.
38+
outputContains []string
39+
// expectErrContains is a list of substrings that are expected to be contained in the error returned by
40+
// cmd.RunE. This is DISTINCT from stdErr.
41+
expectErrContains []string
42+
}
43+
44+
func (suite *cmdTestSuite) runCmdWithAssertions(tc runCmdWithAssertionsParams) {
45+
args := tc.args
46+
for _, da := range tc.dynamicArgs {
47+
args = append(args, da(suite.T())...)
48+
}
49+
50+
rootCmd := buildRootCmd()
51+
rootCmd.SetArgs(args)
52+
stdOut := &bytes.Buffer{}
53+
rootCmd.SetOut(stdOut)
54+
stdErr := &bytes.Buffer{}
55+
rootCmd.SetErr(stdErr)
56+
57+
err := rootCmd.Execute()
58+
if len(tc.expectErrContains) > 0 {
59+
for _, e := range tc.expectErrContains {
60+
suite.ErrorContains(err, e)
61+
}
62+
} else {
63+
stdErrStr := stdErr.String()
64+
suite.Require().NoError(err)
65+
// Only assert the std error is empty if we don't expect an error
66+
suite.Empty(stdErrStr, "expected no stderr")
67+
}
68+
69+
stdOutStr := stdOut.String()
70+
if len(tc.outputContains) > 0 {
71+
for _, o := range tc.outputContains {
72+
suite.Contains(stdOutStr, o)
73+
}
74+
}
75+
}
76+
77+
// dArgGenerator generates argument at the run-time of the test case...
78+
// intended for resources that are not known at test start and potentially need
79+
// to be cleaned up.
80+
type dArgGenerator func(*testing.T) []string
81+
82+
func tempSchemaDirDArg(argName string, ddl []string) dArgGenerator {
83+
return func(t *testing.T) []string {
84+
t.Helper()
85+
return []string{"--" + argName, tempSchemaDir(t, ddl)}
86+
}
87+
}
88+
89+
func tempSchemaDir(t *testing.T, ddl []string) string {
90+
t.Helper()
91+
dir := t.TempDir()
92+
for i, d := range ddl {
93+
require.NoError(t, os.WriteFile(filepath.Join(dir, fmt.Sprintf("ddl_%d.sql", i)), []byte(d), 0644))
94+
}
95+
return dir
96+
}
97+
98+
func tempDsnDArg(pgEngine *pgengine.Engine, argName string, ddl []string) dArgGenerator {
99+
return func(t *testing.T) []string {
100+
t.Helper()
101+
db := tempDbWithSchema(t, pgEngine, ddl)
102+
return []string{"--" + argName, db.GetDSN()}
103+
}
104+
}
105+
106+
func tempDbWithSchema(t *testing.T, pgEngine *pgengine.Engine, ddl []string) *pgengine.DB {
107+
t.Helper()
108+
db, err := pgEngine.CreateDatabase()
109+
require.NoError(t, err)
110+
t.Cleanup(func() {
111+
require.NoError(t, db.DropDB())
112+
})
113+
dbPool, err := sql.Open("pgx", db.GetDSN())
114+
require.NoError(t, err)
115+
defer func() {
116+
require.NoError(t, dbPool.Close())
117+
}()
118+
for _, d := range ddl {
119+
_, err := dbPool.Exec(d)
120+
require.NoError(t, err)
121+
}
122+
return db
123+
}
124+
125+
func tempSetPqEnvVarsForDb(t *testing.T, db *pgengine.DB) {
126+
t.Helper()
127+
tempSetEnvVar(t, "PGHOST", db.GetConnOpts()[pgengine.ConnectionOptionHost])
128+
tempSetEnvVar(t, "PGPORT", db.GetConnOpts()[pgengine.ConnectionOptionPort])
129+
tempSetEnvVar(t, "PGUSER", db.GetConnOpts()[pgengine.ConnectionOptionUser])
130+
tempSetEnvVar(t, "PGDATABASE", db.GetName())
131+
}
132+
133+
func tempSetEnvVar(t *testing.T, k, v string) {
134+
t.Helper()
135+
require.NoError(t, os.Setenv(k, v))
136+
t.Cleanup(func() {
137+
require.NoError(t, os.Unsetenv(k))
138+
})
139+
}
140+
141+
func TestCmdTestSuite(t *testing.T) {
142+
suite.Run(t, new(cmdTestSuite))
143+
}

0 commit comments

Comments
 (0)