diff --git a/.github/workflows/testcontainers.yml b/.github/workflows/testcontainers.yml new file mode 100644 index 00000000..c924b3fa --- /dev/null +++ b/.github/workflows/testcontainers.yml @@ -0,0 +1,34 @@ +name: Integrations Test for the Generated Blueprints + +on: + pull_request: {} + workflow_dispatch: {} + +jobs: + itests_matrix: + strategy: + matrix: + driver: + [mysql, postgres, mongo, redis] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.22.x' + + - name: Commit report + run: | + git config --global user.name 'testname' + git config --global user.email 'testemail@users.noreply.github.com' + + - name: build ${{ matrix.driver }} template + run: script -q /dev/null -c "go run main.go create -n ${{ matrix.driver }} -g commit -f fiber -d ${{matrix.driver}}" /dev/null + + - name: run ${{ matrix.driver }} integration tests + working-directory: ${{ matrix.driver }} + run: make itest + + - name: remove ${{ matrix.driver }} template + run: rm -rf ${{ matrix.driver }} diff --git a/cmd/program/program.go b/cmd/program/program.go index 8acb3d7f..698cb673 100644 --- a/cmd/program/program.go +++ b/cmd/program/program.go @@ -76,6 +76,7 @@ type Templater interface { type DBDriverTemplater interface { Service() []byte Env() []byte + Tests() []byte } type DockerTemplater interface { @@ -287,6 +288,15 @@ func (p *Project) CreateMainFile() error { cobra.CheckErr(err) return err } + + if p.DBDriver != "sqlite" { + err = p.CreateFileWithInjection(internalDatabasePath, projectPath, "database_test.go", "integration-tests") + if err != nil { + log.Printf("Error injecting database_test.go file: %v", err) + cobra.CheckErr(err) + return err + } + } } // Create correct docker compose for the selected driver @@ -708,6 +718,9 @@ func (p *Project) CreateFileWithInjection(pathToCreate string, projectPath strin case "db-docker": createdTemplate := template.Must(template.New(fileName).Parse(string(p.DockerMap[p.Docker].templater.Docker()))) err = createdTemplate.Execute(createdFile, p) + case "integration-tests": + createdTemplate := template.Must(template.New(fileName).Parse(string(p.DBDriverMap[p.DBDriver].templater.Tests()))) + err = createdTemplate.Execute(createdFile, p) case "tests": createdTemplate := template.Must(template.New(fileName).Parse(string(p.FrameworkMap[p.ProjectType].templater.TestHandler()))) err = createdTemplate.Execute(createdFile, p) diff --git a/cmd/template/dbdriver/files/tests/mongo.tmpl b/cmd/template/dbdriver/files/tests/mongo.tmpl new file mode 100644 index 00000000..956b46cb --- /dev/null +++ b/cmd/template/dbdriver/files/tests/mongo.tmpl @@ -0,0 +1,61 @@ +package database + +import ( + "context" + "log" + "testing" + + "github.com/testcontainers/testcontainers-go/modules/mongodb" +) + +func mustStartMongoContainer() (func(context.Context) error, error) { + dbContainer, err := mongodb.Run(context.Background(), "mongo:latest") + if err != nil { + return nil, err + } + + dbHost, err := dbContainer.Host(context.Background()) + if err != nil { + return dbContainer.Terminate, err + } + + dbPort, err := dbContainer.MappedPort(context.Background(), "27017/tcp") + if err != nil { + return dbContainer.Terminate, err + } + + host = dbHost + port = dbPort.Port() + + return dbContainer.Terminate, err +} + +func TestMain(m *testing.M) { + teardown, err := mustStartMongoContainer() + if err != nil { + log.Fatalf("could not start postgres container: %v", err) + } + + m.Run() + + if teardown != nil && teardown(context.Background()) != nil { + log.Fatalf("could not teardown postgres container: %v", err) + } +} + +func TestNew(t *testing.T) { + srv := New() + if srv == nil { + t.Fatal("New() returned nil") + } +} + +func TestHealth(t *testing.T) { + srv := New() + + stats := srv.Health() + + if stats["message"] != "It's healthy" { + t.Fatalf("expected message to be 'It's healthy', got %s", stats["message"]) + } +} diff --git a/cmd/template/dbdriver/files/tests/mysql.tmpl b/cmd/template/dbdriver/files/tests/mysql.tmpl new file mode 100644 index 00000000..3dce6f3f --- /dev/null +++ b/cmd/template/dbdriver/files/tests/mysql.tmpl @@ -0,0 +1,96 @@ +package database + +import ( + "context" + "log" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/mysql" + "github.com/testcontainers/testcontainers-go/wait" +) + +func mustStartMySQLContainer() (func(context.Context) error, error) { + var ( + dbName = "database" + dbPwd = "password" + dbUser = "user" + ) + + dbContainer, err := mysql.Run(context.Background(), + "mysql:8.0.36", + mysql.WithDatabase(dbName), + mysql.WithUsername(dbUser), + mysql.WithPassword(dbPwd), + testcontainers.WithWaitStrategy(wait.ForLog("port: 3306 MySQL Community Server - GPL").WithStartupTimeout(30*time.Second)), + ) + if err != nil { + return nil, err + } + + dbname = dbName + password = dbPwd + username = dbUser + + dbHost, err := dbContainer.Host(context.Background()) + if err != nil { + return dbContainer.Terminate, err + } + + dbPort, err := dbContainer.MappedPort(context.Background(), "3306/tcp") + if err != nil { + return dbContainer.Terminate, err + } + + host = dbHost + port = dbPort.Port() + + return dbContainer.Terminate, err +} + +func TestMain(m *testing.M) { + teardown, err := mustStartMySQLContainer() + if err != nil { + log.Fatalf("could not start mysql container: %v", err) + } + + m.Run() + + if teardown != nil && teardown(context.Background()) != nil { + log.Fatalf("could not teardown mysql container: %v", err) + } +} + +func TestNew(t *testing.T) { + srv := New() + if srv == nil { + t.Fatal("New() returned nil") + } +} + +func TestHealth(t *testing.T) { + srv := New() + + stats := srv.Health() + + if stats["status"] != "up" { + t.Fatalf("expected status to be up, got %s", stats["status"]) + } + + if _, ok := stats["error"]; ok { + t.Fatalf("expected error not to be present") + } + + if stats["message"] != "It's healthy" { + t.Fatalf("expected message to be 'It's healthy', got %s", stats["message"]) + } +} + +func TestClose(t *testing.T) { + srv := New() + + if srv.Close() != nil { + t.Fatalf("expected Close() to return nil") + } +} diff --git a/cmd/template/dbdriver/files/tests/postgres.tmpl b/cmd/template/dbdriver/files/tests/postgres.tmpl new file mode 100644 index 00000000..fd46cbbd --- /dev/null +++ b/cmd/template/dbdriver/files/tests/postgres.tmpl @@ -0,0 +1,100 @@ +package database + +import ( + "context" + "log" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +func mustStartPostgresContainer() (func(context.Context) error, error) { + var ( + dbName = "database" + dbPwd = "password" + dbUser = "user" + ) + + dbContainer, err := postgres.Run( + context.Background(), + "postgres:latest", + postgres.WithDatabase(dbName), + postgres.WithUsername(dbUser), + postgres.WithPassword(dbPwd), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(5*time.Second)), + ) + if err != nil { + return nil, err + } + + database = dbName + password = dbPwd + username = dbUser + + dbHost, err := dbContainer.Host(context.Background()) + if err != nil { + return dbContainer.Terminate, err + } + + dbPort, err := dbContainer.MappedPort(context.Background(), "5432/tcp") + if err != nil { + return dbContainer.Terminate, err + } + + host = dbHost + port = dbPort.Port() + + return dbContainer.Terminate, err +} + +func TestMain(m *testing.M) { + teardown, err := mustStartPostgresContainer() + if err != nil { + log.Fatalf("could not start postgres container: %v", err) + } + + m.Run() + + if teardown != nil && teardown(context.Background()) != nil { + log.Fatalf("could not teardown postgres container: %v", err) + } +} + +func TestNew(t *testing.T) { + srv := New() + if srv == nil { + t.Fatal("New() returned nil") + } +} + +func TestHealth(t *testing.T) { + srv := New() + + stats := srv.Health() + + if stats["status"] != "up" { + t.Fatalf("expected status to be up, got %s", stats["status"]) + } + + if _, ok := stats["error"]; ok { + t.Fatalf("expected error not to be present") + } + + if stats["message"] != "It's healthy" { + t.Fatalf("expected message to be 'It's healthy', got %s", stats["message"]) + } +} + +func TestClose(t *testing.T) { + srv := New() + + if srv.Close() != nil { + t.Fatalf("expected Close() to return nil") + } +} diff --git a/cmd/template/dbdriver/files/tests/redis.tmpl b/cmd/template/dbdriver/files/tests/redis.tmpl new file mode 100644 index 00000000..364d8d36 --- /dev/null +++ b/cmd/template/dbdriver/files/tests/redis.tmpl @@ -0,0 +1,71 @@ +package database + +import ( + "context" + "log" + "testing" + + "github.com/testcontainers/testcontainers-go/modules/redis" +) + +func mustStartRedisContainer() (func(context.Context) error, error) { + dbContainer, err := redis.Run( + context.Background(), + "docker.io/redis:7.2.4", + redis.WithSnapshotting(10, 1), + redis.WithLogLevel(redis.LogLevelVerbose), + ) + if err != nil { + return nil, err + } + + dbHost, err := dbContainer.Host(context.Background()) + if err != nil { + return dbContainer.Terminate, err + } + + dbPort, err := dbContainer.MappedPort(context.Background(), "6379/tcp") + if err != nil { + return dbContainer.Terminate, err + } + + address = dbHost + port = dbPort.Port() + database = "0" + + return dbContainer.Terminate, err +} + +func TestMain(m *testing.M) { + teardown, err := mustStartRedisContainer() + if err != nil { + log.Fatalf("could not start redis container: %v", err) + } + + m.Run() + + if teardown != nil && teardown(context.Background()) != nil { + log.Fatalf("could not teardown redis container: %v", err) + } +} + +func TestNew(t *testing.T) { + srv := New() + if srv == nil { + t.Fatal("New() returned nil") + } +} + +func TestHealth(t *testing.T) { + srv := New() + + stats := srv.Health() + + if stats["redis_status"] != "up" { + t.Fatalf("expected status to be up, got %s", stats["redis_status"]) + } + + if _, ok := stats["redis_version"]; !ok { + t.Fatalf("expected redis_version to be present, got %v", stats["redis_version"]) + } +} diff --git a/cmd/template/dbdriver/mongo.go b/cmd/template/dbdriver/mongo.go index 92694d9c..5786932d 100644 --- a/cmd/template/dbdriver/mongo.go +++ b/cmd/template/dbdriver/mongo.go @@ -12,6 +12,8 @@ var mongoServiceTemplate []byte //go:embed files/env/mongo.tmpl var mongoEnvTemplate []byte +//go:embed files/tests/mongo.tmpl +var mongoTestcontainersTemplate []byte func (m MongoTemplate) Service() []byte { return mongoServiceTemplate @@ -21,3 +23,6 @@ func (m MongoTemplate) Env() []byte { return mongoEnvTemplate } +func (m MongoTemplate) Tests() []byte { + return mongoTestcontainersTemplate +} diff --git a/cmd/template/dbdriver/mysql.go b/cmd/template/dbdriver/mysql.go index 8a268e52..a1099e83 100644 --- a/cmd/template/dbdriver/mysql.go +++ b/cmd/template/dbdriver/mysql.go @@ -12,6 +12,9 @@ var mysqlServiceTemplate []byte //go:embed files/env/mysql.tmpl var mysqlEnvTemplate []byte +//go:embed files/tests/mysql.tmpl +var mysqlTestcontainersTemplate []byte + func (m MysqlTemplate) Service() []byte { return mysqlServiceTemplate } @@ -20,3 +23,6 @@ func (m MysqlTemplate) Env() []byte { return mysqlEnvTemplate } +func (m MysqlTemplate) Tests() []byte { + return mysqlTestcontainersTemplate +} diff --git a/cmd/template/dbdriver/postgres.go b/cmd/template/dbdriver/postgres.go index 875836b0..612c6ed2 100644 --- a/cmd/template/dbdriver/postgres.go +++ b/cmd/template/dbdriver/postgres.go @@ -12,6 +12,9 @@ var postgresServiceTemplate []byte //go:embed files/env/postgres.tmpl var postgresEnvTemplate []byte +//go:embed files/tests/postgres.tmpl +var postgresTestcontainersTemplate []byte + func (m PostgresTemplate) Service() []byte { return postgresServiceTemplate } @@ -19,3 +22,7 @@ func (m PostgresTemplate) Service() []byte { func (m PostgresTemplate) Env() []byte { return postgresEnvTemplate } + +func (m PostgresTemplate) Tests() []byte { + return postgresTestcontainersTemplate +} diff --git a/cmd/template/dbdriver/redis.go b/cmd/template/dbdriver/redis.go index 52e04219..d30156bb 100644 --- a/cmd/template/dbdriver/redis.go +++ b/cmd/template/dbdriver/redis.go @@ -12,6 +12,9 @@ var redisServiceTemplate []byte //go:embed files/env/redis.tmpl var redisEnvTemplate []byte +//go:embed files/tests/redis.tmpl +var redisTestcontainersTemplate []byte + func (r RedisTemplate) Service() []byte { return redisServiceTemplate } @@ -19,3 +22,7 @@ func (r RedisTemplate) Service() []byte { func (r RedisTemplate) Env() []byte { return redisEnvTemplate } + +func (r RedisTemplate) Tests() []byte { + return redisTestcontainersTemplate +} diff --git a/cmd/template/dbdriver/sqlite.go b/cmd/template/dbdriver/sqlite.go index 55a5dccb..9fe4c776 100644 --- a/cmd/template/dbdriver/sqlite.go +++ b/cmd/template/dbdriver/sqlite.go @@ -19,3 +19,7 @@ func (m SqliteTemplate) Service() []byte { func (m SqliteTemplate) Env() []byte { return sqliteEnvTemplate } + +func (m SqliteTemplate) Tests() []byte { + return []byte{} +} diff --git a/cmd/template/framework/files/makefile.tmpl b/cmd/template/framework/files/makefile.tmpl index ded58c97..1e137c8b 100644 --- a/cmd/template/framework/files/makefile.tmpl +++ b/cmd/template/framework/files/makefile.tmpl @@ -38,6 +38,13 @@ test: @echo "Testing..." @go test ./tests -v +{{if and (ne .DBDriver "none") (ne .DBDriver "sqlite")}} +# Integrations Tests for the application +itest: + @echo "Running integration tests..." + @go test ./internal/database -v +{{end}} + # Clean the binary clean: @echo "Cleaning..." diff --git a/docs/docs/blueprint-core/db-drivers.md b/docs/docs/blueprint-core/db-drivers.md index 67dd10ac..e9524f57 100644 --- a/docs/docs/blueprint-core/db-drivers.md +++ b/docs/docs/blueprint-core/db-drivers.md @@ -17,6 +17,7 @@ Integrating a database adds a new layer to the project structure, primarily in t │ └── main.go ├── /internal │ ├── /database +│ │ ├── database_test.go │ │ └── database.go │ └── /server │ ├── routes.go @@ -33,6 +34,39 @@ Integrating a database adds a new layer to the project structure, primarily in t Users can select the desired database driver based on their project's specific needs. The chosen driver is then imported into the project, and the `database.go` file is adjusted accordingly to establish a connection and manage interactions with the selected database. +## Integration Tests for Database Operations + +For all the database drivers but the `Sqlite`, integration tests are automatically generated to ensure that the database connection is working correctly. It uses [Testcontainers for Go](https://golang.testcontainers.org/) to spin up a containerized instance of the database server, run the tests, and then tear down the container. + +[Testcontainers for Go](https://golang.testcontainers.org/) is a Go package that makes it simple to create and clean up container-based dependencies for automated integration/smoke tests. The clean, easy-to-use API enables developers to programmatically define containers that should be run as part of a test and clean up those resources when the test is done. + + +### Requirements + +You need a container runtime installed on your machine. Testcontainers supports Docker and any other container runtime that implements the Docker APIs. + +To install Docker: + +```bash +curl -sLO get.docker.com +``` + +### Running the tests + +Go to the `internal/database` directory and run the following command: + +```bash +go test -v +``` + +or just run the following command from the root directory: + +```bash +make itest +``` + +Testcontainers automatically pulls the required Docker images and start the containers. The tests run against the containers, and once the tests are done, the containers are stopped and removed. For further information, refer to the [official documentation](https://golang.testcontainers.org/). + ## Docker-Compose for Quick Database Spinup To facilitate quick setup and testing, a `docker-compose.yml` file is provided. This file defines a service for the chosen database system with the necessary environment variables. Running `docker-compose up` will quickly spin up a containerized instance of the database, allowing users to test their application against a real database server. diff --git a/docs/docs/index.md b/docs/docs/index.md index a795e89a..984484ef 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -47,6 +47,7 @@ Here's an overview of the project structure created by Go Blueprint when all opt │ └── hello_templ.go # Generated Go code for the "hello" template. ├── internal/ │ ├── database/ +│ │ └── database_test.go # File containing integrations tests for the database operations. │ │ └── database.go # File containing functions related to database operations. │ └── server/ │ ├── routes.go # File defining HTTP routes. diff --git a/go.mod b/go.mod index 77e6d9e4..88501fc4 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/melkeydev/go-blueprint -go 1.20 +go 1.22.4 require ( github.com/charmbracelet/bubbles v0.16.1