Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 126 additions & 60 deletions bcda/api/v2/api_test.go

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions db/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Test Database Container
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking this should be in a different dir. When I think of the db dir I think of db migrations, sql scripts, etc, not a go package aimed at testing. Maybe something like test_container?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I put it in here because it does utilize the db migrations and sql scripts, but the primary reason was that db does not depend on any other local packages, so it would be a safe import.

I am up for putting it in another package, as long as the package does not import any other local packages.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need a new package? While I agree, this is testing, but it is specifically for the database. Would we ever see this being used outside of DB?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this were in it's own package, it would still need access to all the files under db/. Because we have some high level packages and this would be used in both the api and the worker, it made sense to have it here, I think.


## Purpose
To have an idempotent database for each test run.

## Implementation Strategy

`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.

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.


## How To Use

1. Create the container in the setup of the test suite; this is the longest running step.
2. Create the database connection in the setup of the test or the setup of the subtest.
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
4. Restore a snapshot in the test teardown.

*Note*: Database snapshots cannot be created or restored if a database connection still exists.

```
type FooTestSuite struct {
suite.Suite
db *sql.DB // example; pgx or pool can also be used
dbContainer db.TestDatabaseContainer. // example; this is optional to be part of the test suite
}

func (s *FooTestSuite) SetupSuite() {
ctr, err := db.NewTestDatabaseContainer()
require.NoError(s.T(), err)
s.dbContainer = ctr
}


func (s *FooTestSuite) SetupTest() {
db, err := s.dbContainer.NewSqlDbConnection()
require.NoError(s.T(), err)
s.db = db
}

func (s *FooTestSuite) TearDownTest() {
s.db.Close()
err := s.dbContainer.RestoreSnapshot(). // example, you can restore from another desired snapshot
require.NoError(s.T(), err)
}
```
276 changes: 276 additions & 0 deletions db/container.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
package db
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to previous comment, this isnt really a db package, but more focused on testing so I think we should acknowledge that in the package name as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do we think about testcontainer? or testdb?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Package testdb in dir ./testdb directory?


import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)

const postgresImage = "postgres:16-alpine"

type TestDatabaseContainer struct {
Container *postgres.PostgresContainer
ConnectionString string
migrations string
testdata string
}

// ExecuteFile will execute a *.sql file for a database container.
// Sql files for testing purposes should be under a package's testdata/ directory.
func (td *TestDatabaseContainer) ExecuteFile(path string) (int64, error) {
ctx := context.Background()
var rows int64

file, err := os.Stat(filepath.Clean(path))
if err != nil {
return rows, fmt.Errorf("failed to stat file: %w", err)
}

if filepath.Ext(file.Name()) != ".sql" {
return rows, fmt.Errorf("failed execute file: not a .sql file")
}

content, err := os.ReadFile(filepath.Clean(path))
if err != nil {
return rows, fmt.Errorf("failed to open file: %w", err)
}

sql := string(content)

pgx, err := td.NewPgxConnection()
if err != nil {
return rows, fmt.Errorf("failed to connect to container database: %w", err)
}
defer pgx.Close(ctx)
result, err := pgx.Exec(ctx, sql)

if err != nil {
return rows, fmt.Errorf("failed to execute sql: %w", err)
}
rows = result.RowsAffected()
if rows == 0 {
return rows, fmt.Errorf("zero rows affected")
}

return rows, err
}

// ExecuteFile will execute all *.sql files for the provided dirpath.
// Is it recommended to use the package's testdata/ directory to add test files.
// A package's testdata/ dir can be retrieved with GetTestDataDir().
func (td *TestDatabaseContainer) ExecuteDir(dirpath string) error {
var err error
testDir, err := os.Stat(dirpath)
if err != nil {
return fmt.Errorf("failed to get testdata directory: %w", err)
}

if !testDir.IsDir() {
return errors.New("failed to get directory; path is not a directory")
}

err = filepath.Walk(dirpath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return fmt.Errorf("error accessing path %s: %w", path, err)
}
if !info.IsDir() && (filepath.Ext(info.Name()) == ".sql") {
_, err = td.ExecuteFile(path)
if err != nil {
return fmt.Errorf("failed to execute sql file %s with error: %w", path, err)
}
}
return err
})
return err
}

// CreateSnapshot will create a snapshot for a given name. Close any active connections to the database
// before taking a snapshot.
func (td *TestDatabaseContainer) CreateSnapshot(name string) error {
err := td.Container.Snapshot(context.Background(), postgres.WithSnapshotName(name))
if err != nil {
return fmt.Errorf("failed to restore container database snapshot: %w", err)
}
return nil
}

// RestoreSnapshot will restore the snapshot that is taken after the database container
// has had the initial migrations and data seed applied. If no name is provided, it will restore
// the default snapshot. "Base" will restore the database to it's init state.
func (td *TestDatabaseContainer) RestoreSnapshot(name string) error {
err := td.Container.Restore(context.Background(), postgres.WithSnapshotName(name))
if err != nil {
return fmt.Errorf("failed to restore container database snapshot: %w", err)
}
return nil
}

// Return a pgx connection for a given database container.
func (td *TestDatabaseContainer) NewPgxConnection() (*pgx.Conn, error) {
pgx, err := pgx.Connect(context.Background(), td.ConnectionString)
if err != nil {
return nil, fmt.Errorf("failed to open connection to container database: %w", err)
}
return pgx, nil
}

// Return a sql/db connection for a given database container.
func (td *TestDatabaseContainer) NewSqlDbConnection() (*sql.DB, error) {
db, err := sql.Open("postgres", td.ConnectionString+"sslmode=disable")
if err != nil {
return nil, fmt.Errorf("failed to open connection to container database: %w", err)
}
return db, nil
}

// Return a pgx pool for a given database container.
func (td *TestDatabaseContainer) NewPgxPoolConnection() (*pgxpool.Pool, error) {
pool, err := pgxpool.New(context.Background(), td.ConnectionString)
if err != nil {
return nil, fmt.Errorf("failed to create pool for container database: %w", err)
}
return pool, nil
}

// runMigrations runs the production migrations to the local database so there is no drift between prod and local development.
func (td *TestDatabaseContainer) runMigrations() error {
m, err := migrate.New("file:"+td.migrations, td.ConnectionString+"sslmode=disable")
if err != nil {
return fmt.Errorf("failed to get migrations: %w", err)
}
err = m.Up()
if err != nil {
return fmt.Errorf("failed to apply migrations: %w", err)
}
err, _ = m.Close()
if err != nil {
return fmt.Errorf("failed to close database: %w", err)
}
return nil
}

// initSeed will apply the baseline data to the the database with newly run migrations.
// For applying test or scenario specific data, utilize ExecuteFile or ExecuteDir.
func (td *TestDatabaseContainer) initSeed() error {
err := td.ExecuteDir(td.testdata)
if err != nil {
return fmt.Errorf("failed to seed database container: %w", err)
}
return nil
}

// Returns a new postgres container with migrations from db/migrations/bcda applied and seed
// data from db/testdata applied.
func NewTestDatabaseContainer() (TestDatabaseContainer, error) {
ctx := context.Background()
c, err := postgres.Run(ctx,
postgresImage,
postgres.WithDatabase("bcda"),
postgres.WithUsername("toor"),
postgres.WithPassword("foobar"),
postgres.BasicWaitStrategies())

if err != nil {
return TestDatabaseContainer{}, fmt.Errorf("failed to create database container: %w", err)
}

conn, err := c.ConnectionString(ctx)
if err != nil {
return TestDatabaseContainer{}, fmt.Errorf("failed to get connection string for container database: %w", err)
}

tdc := TestDatabaseContainer{
Container: c,
ConnectionString: conn,
}

err = tdc.getSetupDirs()
if err != nil {
return TestDatabaseContainer{}, fmt.Errorf("failed to get testdata or migrations dirs: %w", err)
}

err = tdc.runMigrations()
if err != nil {
return TestDatabaseContainer{}, fmt.Errorf("failed to apply migrations to container database: %w", err)
}

err = tdc.initSeed()
if err != nil {
return TestDatabaseContainer{}, fmt.Errorf("failed to add test data to container database: %w", err)
}

err = tdc.CreateSnapshot("Base")
if err != nil {
return TestDatabaseContainer{}, err
}

return tdc, nil

}

// GetTestDataDir is a helper function that will return the testdata directory for package in which
// it is invoked. If the testdata directory has been created in another package or the files exist
// outside the package, they will not be found.
func GetTestDataDir() (string, error) {
currentDir, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get current working directory: %w", err)
}

testDir := filepath.Join(filepath.Clean(currentDir), "testdata")
_, err = os.Stat(testDir)
if err != nil {
return "", fmt.Errorf("failed to get testdata directory: %w", err)
}

return testDir, err
}

// getSetupDirs ensures that we get the db/testdata and migrations directories no matter where NewTestDatabaseContainer is called.
func (td *TestDatabaseContainer) getSetupDirs() error {
currentDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current working directory: %w", err)
}
testDir := filepath.Join("db", "testdata")
migrationsDir := filepath.Join("db", "migrations", "bcda")
dirPaths := []string{testDir, migrationsDir}

for _, v := range dirPaths {
for {
targetPath := filepath.Join(filepath.Clean(currentDir), filepath.Clean(v))
_, err := os.Stat(targetPath)
if err == nil {
if strings.Contains(v, "testdata") {
td.testdata = targetPath
}
if strings.Contains(v, "migrations") {
td.migrations = targetPath
}
break
}
if !os.IsNotExist(err) {
return fmt.Errorf("error checking path %s: %w", targetPath, err)
}

parentDir := filepath.Dir(currentDir)
if parentDir == currentDir {
return fmt.Errorf("file or directory '%s' not found in parent directories", "db/testdata")
}
currentDir = parentDir
}
}
return nil

}
Loading