Skip to content

Commit ff2f52f

Browse files
BCDA-9392: Test Container Database (#1207)
## 🎫 Ticket https://jira.cms.gov/browse/BCDA-9392 ## 🛠 Changes **TestDatabaseContainer type that wraps golang [testcontainers](https://golang.testcontainers.org/modules/postgres/) package**. Why? - BCDA api is using pgx, pgx pool, and sql/db for database connections; wrapper will create a connection for any of these, which eliminates the need to create a connection in each test suite/file. - Calling NewTestDatabaseContainer ensures that the same exact container setup is called for each test, each test suite, or each file and removes the burden of configuring for the developer. - The wrapper has methods such as ExecuteFile and ExecuteDir, which will help enforce the location and naming of testdata across the repository. - README has been created for example and intended usage. **api_test.go used for initial tests to apply containers** - Example file/suite that was used to try the test container. - TestJobStatusNotComplete was the primary test that was updated/tested for the containers **db/testdata directory creation** - Standard directory naming for test data. - README created for example and intended usage ## ℹ️ Context **The goal of this PR is to**: - set a standard that can be used across all tests, which will improve readability and speed up development time - implement a solution that can be incrementally rolled out with other dev work with no disruption to current tests or CI/CD. - reduce development burden when creating tests - improve test reliability, since the database will not have potential state changes in between tests Currently, a single container database is used across all tests, with some or no cleanup, depending on the tests. There is a risk that tests are not running in a "clean" state; the shared database could be altered from a previous test which can affect the accuracy and reliability of all tests. Additionally, there is no standard of how we do database integration tests or unit tests. For database integration tests, we should have the same setup for the database every time, across all tests that will utilize the database. This will ensure that the tests are in a good state before running and their outcome will not affect other tests. For unit tests, we should be mocking the database each time, but this PR only address integration test strategies. Because this container strategy would be implemented at whatever granularity is desired (test file, suite, sub test, etc) and does not change the current test database that is used across all tests, it will not affect our current test suite and our tests can be gradually updated over time, as developers are working in various package. With the new changes, a test database container will be created and each time a new test runs, the database will be restored back to a good known state with a snapshot, which will ensure a clean state for each run. Testing data and additional snapshots can be created as needed, depending on the testing scenario. <!-- If any of the following security implications apply, this PR must not be merged without Stephen Walter's approval. Explain in this section and add @SJWalter11 as a reviewer. - Adds a new software dependency or dependencies. - Modifies or invalidates one or more of our security controls. - Stores or transmits data that was not stored or transmitted before. - Requires additional review of security implications for other reasons. --> ## 🧪 Validation Tests passin, v2/test_api.go file updated to use new test containers (arbitrarily chosen), and tests added for container.go. --------- Co-authored-by: Parwinder Bhagat <[email protected]>
1 parent 9ff4dce commit ff2f52f

File tree

13 files changed

+1301
-136
lines changed

13 files changed

+1301
-136
lines changed

bcda/api/v2/api_test.go

Lines changed: 126 additions & 60 deletions
Large diffs are not rendered by default.

db/README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Test Database Container
2+
3+
## Purpose
4+
To have an idempotent database for each test run.
5+
6+
## Implementation Strategy
7+
8+
`TestDatabaseContainer` is a lightweight wrapper of https://golang.testcontainers.org/modules/postgres/. Because BCDA uses pgx, pgxpool, and sql/db for database connections, this wrapper type will implement methods to utilize any of the aforementioned connection types.
9+
10+
This type also implements methods to apply migrations and seed the database with an initial set of necessary data for the BCDA application to execute database operations.
11+
12+
13+
## How To Use
14+
15+
1. Create the container in the setup of the test suite; this is the longest running step.
16+
2. Create the database connection in the setup of the test or the setup of the subtest.
17+
3. (optional) Seed any additional test data with TestDatabaseContainer.ExecuteFile() or TestDatabaseContainer.ExecuteDir(). For details on seed data, please consult the README.md in ./testdata
18+
4. Restore a snapshot in the test teardown.
19+
20+
*Note*: Database snapshots cannot be created or restored if a database connection still exists.
21+
22+
```
23+
type FooTestSuite struct {
24+
suite.Suite
25+
db *sql.DB // example; pgx or pool can also be used
26+
dbContainer db.TestDatabaseContainer. // example; this is optional to be part of the test suite
27+
}
28+
29+
func (s *FooTestSuite) SetupSuite() {
30+
ctr, err := db.NewTestDatabaseContainer()
31+
require.NoError(s.T(), err)
32+
s.dbContainer = ctr
33+
}
34+
35+
36+
func (s *FooTestSuite) SetupTest() {
37+
db, err := s.dbContainer.NewSqlDbConnection()
38+
require.NoError(s.T(), err)
39+
s.db = db
40+
}
41+
42+
func (s *FooTestSuite) TearDownTest() {
43+
s.db.Close()
44+
err := s.dbContainer.RestoreSnapshot(). // example, you can restore from another desired snapshot
45+
require.NoError(s.T(), err)
46+
}
47+
```

db/container.go

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
package db
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"errors"
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
12+
"github.com/golang-migrate/migrate/v4"
13+
_ "github.com/golang-migrate/migrate/v4/database/postgres"
14+
_ "github.com/golang-migrate/migrate/v4/source/file"
15+
"github.com/jackc/pgx/v5"
16+
"github.com/jackc/pgx/v5/pgxpool"
17+
"github.com/testcontainers/testcontainers-go/modules/postgres"
18+
)
19+
20+
const postgresImage = "postgres:16-alpine"
21+
22+
type TestDatabaseContainer struct {
23+
Container *postgres.PostgresContainer
24+
ConnectionString string
25+
migrations string
26+
testdata string
27+
}
28+
29+
// ExecuteFile will execute a *.sql file for a database container.
30+
// Sql files for testing purposes should be under a package's testdata/ directory.
31+
func (td *TestDatabaseContainer) ExecuteFile(path string) (int64, error) {
32+
ctx := context.Background()
33+
var rows int64
34+
35+
file, err := os.Stat(filepath.Clean(path))
36+
if err != nil {
37+
return rows, fmt.Errorf("failed to stat file: %w", err)
38+
}
39+
40+
if filepath.Ext(file.Name()) != ".sql" {
41+
return rows, fmt.Errorf("failed execute file: not a .sql file")
42+
}
43+
44+
content, err := os.ReadFile(filepath.Clean(path))
45+
if err != nil {
46+
return rows, fmt.Errorf("failed to open file: %w", err)
47+
}
48+
49+
sql := string(content)
50+
51+
pgx, err := td.NewPgxConnection()
52+
if err != nil {
53+
return rows, fmt.Errorf("failed to connect to container database: %w", err)
54+
}
55+
defer pgx.Close(ctx)
56+
result, err := pgx.Exec(ctx, sql)
57+
58+
if err != nil {
59+
return rows, fmt.Errorf("failed to execute sql: %w", err)
60+
}
61+
rows = result.RowsAffected()
62+
if rows == 0 {
63+
return rows, fmt.Errorf("zero rows affected")
64+
}
65+
66+
return rows, err
67+
}
68+
69+
// ExecuteFile will execute all *.sql files for the provided dirpath.
70+
// Is it recommended to use the package's testdata/ directory to add test files.
71+
// A package's testdata/ dir can be retrieved with GetTestDataDir().
72+
func (td *TestDatabaseContainer) ExecuteDir(dirpath string) error {
73+
var err error
74+
testDir, err := os.Stat(dirpath)
75+
if err != nil {
76+
return fmt.Errorf("failed to get testdata directory: %w", err)
77+
}
78+
79+
if !testDir.IsDir() {
80+
return errors.New("failed to get directory; path is not a directory")
81+
}
82+
83+
err = filepath.Walk(dirpath, func(path string, info os.FileInfo, err error) error {
84+
if err != nil {
85+
return fmt.Errorf("error accessing path %s: %w", path, err)
86+
}
87+
if !info.IsDir() && (filepath.Ext(info.Name()) == ".sql") {
88+
_, err = td.ExecuteFile(path)
89+
if err != nil {
90+
return fmt.Errorf("failed to execute sql file %s with error: %w", path, err)
91+
}
92+
}
93+
return err
94+
})
95+
return err
96+
}
97+
98+
// CreateSnapshot will create a snapshot for a given name. Close any active connections to the database
99+
// before taking a snapshot.
100+
func (td *TestDatabaseContainer) CreateSnapshot(name string) error {
101+
err := td.Container.Snapshot(context.Background(), postgres.WithSnapshotName(name))
102+
if err != nil {
103+
return fmt.Errorf("failed to restore container database snapshot: %w", err)
104+
}
105+
return nil
106+
}
107+
108+
// RestoreSnapshot will restore the snapshot that is taken after the database container
109+
// has had the initial migrations and data seed applied. If no name is provided, it will restore
110+
// the default snapshot. "Base" will restore the database to it's init state.
111+
func (td *TestDatabaseContainer) RestoreSnapshot(name string) error {
112+
err := td.Container.Restore(context.Background(), postgres.WithSnapshotName(name))
113+
if err != nil {
114+
return fmt.Errorf("failed to restore container database snapshot: %w", err)
115+
}
116+
return nil
117+
}
118+
119+
// Return a pgx connection for a given database container.
120+
func (td *TestDatabaseContainer) NewPgxConnection() (*pgx.Conn, error) {
121+
pgx, err := pgx.Connect(context.Background(), td.ConnectionString)
122+
if err != nil {
123+
return nil, fmt.Errorf("failed to open connection to container database: %w", err)
124+
}
125+
return pgx, nil
126+
}
127+
128+
// Return a sql/db connection for a given database container.
129+
func (td *TestDatabaseContainer) NewSqlDbConnection() (*sql.DB, error) {
130+
db, err := sql.Open("postgres", td.ConnectionString+"sslmode=disable")
131+
if err != nil {
132+
return nil, fmt.Errorf("failed to open connection to container database: %w", err)
133+
}
134+
return db, nil
135+
}
136+
137+
// Return a pgx pool for a given database container.
138+
func (td *TestDatabaseContainer) NewPgxPoolConnection() (*pgxpool.Pool, error) {
139+
pool, err := pgxpool.New(context.Background(), td.ConnectionString)
140+
if err != nil {
141+
return nil, fmt.Errorf("failed to create pool for container database: %w", err)
142+
}
143+
return pool, nil
144+
}
145+
146+
// runMigrations runs the production migrations to the local database so there is no drift between prod and local development.
147+
func (td *TestDatabaseContainer) runMigrations() error {
148+
m, err := migrate.New("file:"+td.migrations, td.ConnectionString+"sslmode=disable")
149+
if err != nil {
150+
return fmt.Errorf("failed to get migrations: %w", err)
151+
}
152+
err = m.Up()
153+
if err != nil {
154+
return fmt.Errorf("failed to apply migrations: %w", err)
155+
}
156+
err, _ = m.Close()
157+
if err != nil {
158+
return fmt.Errorf("failed to close database: %w", err)
159+
}
160+
return nil
161+
}
162+
163+
// initSeed will apply the baseline data to the the database with newly run migrations.
164+
// For applying test or scenario specific data, utilize ExecuteFile or ExecuteDir.
165+
func (td *TestDatabaseContainer) initSeed() error {
166+
err := td.ExecuteDir(td.testdata)
167+
if err != nil {
168+
return fmt.Errorf("failed to seed database container: %w", err)
169+
}
170+
return nil
171+
}
172+
173+
// Returns a new postgres container with migrations from db/migrations/bcda applied and seed
174+
// data from db/testdata applied.
175+
func NewTestDatabaseContainer() (TestDatabaseContainer, error) {
176+
ctx := context.Background()
177+
c, err := postgres.Run(ctx,
178+
postgresImage,
179+
postgres.WithDatabase("bcda"),
180+
postgres.WithUsername("toor"),
181+
postgres.WithPassword("foobar"),
182+
postgres.BasicWaitStrategies())
183+
184+
if err != nil {
185+
return TestDatabaseContainer{}, fmt.Errorf("failed to create database container: %w", err)
186+
}
187+
188+
conn, err := c.ConnectionString(ctx)
189+
if err != nil {
190+
return TestDatabaseContainer{}, fmt.Errorf("failed to get connection string for container database: %w", err)
191+
}
192+
193+
tdc := TestDatabaseContainer{
194+
Container: c,
195+
ConnectionString: conn,
196+
}
197+
198+
err = tdc.getSetupDirs()
199+
if err != nil {
200+
return TestDatabaseContainer{}, fmt.Errorf("failed to get testdata or migrations dirs: %w", err)
201+
}
202+
203+
err = tdc.runMigrations()
204+
if err != nil {
205+
return TestDatabaseContainer{}, fmt.Errorf("failed to apply migrations to container database: %w", err)
206+
}
207+
208+
err = tdc.initSeed()
209+
if err != nil {
210+
return TestDatabaseContainer{}, fmt.Errorf("failed to add test data to container database: %w", err)
211+
}
212+
213+
err = tdc.CreateSnapshot("Base")
214+
if err != nil {
215+
return TestDatabaseContainer{}, err
216+
}
217+
218+
return tdc, nil
219+
220+
}
221+
222+
// GetTestDataDir is a helper function that will return the testdata directory for package in which
223+
// it is invoked. If the testdata directory has been created in another package or the files exist
224+
// outside the package, they will not be found.
225+
func GetTestDataDir() (string, error) {
226+
currentDir, err := os.Getwd()
227+
if err != nil {
228+
return "", fmt.Errorf("failed to get current working directory: %w", err)
229+
}
230+
231+
testDir := filepath.Join(filepath.Clean(currentDir), "testdata")
232+
_, err = os.Stat(testDir)
233+
if err != nil {
234+
return "", fmt.Errorf("failed to get testdata directory: %w", err)
235+
}
236+
237+
return testDir, err
238+
}
239+
240+
// getSetupDirs ensures that we get the db/testdata and migrations directories no matter where NewTestDatabaseContainer is called.
241+
func (td *TestDatabaseContainer) getSetupDirs() error {
242+
currentDir, err := os.Getwd()
243+
if err != nil {
244+
return fmt.Errorf("failed to get current working directory: %w", err)
245+
}
246+
testDir := filepath.Join("db", "testdata")
247+
migrationsDir := filepath.Join("db", "migrations", "bcda")
248+
dirPaths := []string{testDir, migrationsDir}
249+
250+
for _, v := range dirPaths {
251+
for {
252+
targetPath := filepath.Join(filepath.Clean(currentDir), filepath.Clean(v))
253+
_, err := os.Stat(targetPath)
254+
if err == nil {
255+
if strings.Contains(v, "testdata") {
256+
td.testdata = targetPath
257+
}
258+
if strings.Contains(v, "migrations") {
259+
td.migrations = targetPath
260+
}
261+
break
262+
}
263+
if !os.IsNotExist(err) {
264+
return fmt.Errorf("error checking path %s: %w", targetPath, err)
265+
}
266+
267+
parentDir := filepath.Dir(currentDir)
268+
if parentDir == currentDir {
269+
return fmt.Errorf("file or directory '%s' not found in parent directories", "db/testdata")
270+
}
271+
currentDir = parentDir
272+
}
273+
}
274+
return nil
275+
276+
}

0 commit comments

Comments
 (0)