1
0
mirror of https://github.com/xorcare/testing-go-code-with-postgres.git synced 2024-12-24 16:28:34 +02:00

Publish an example testing go code with Postgres

This commit is contained in:
Vasiliy Vasilyuk 2023-07-05 01:31:59 +03:00
parent 7e6770223e
commit b346272f7f
No known key found for this signature in database
GPG Key ID: CD966F83D6FAFF48
15 changed files with 559 additions and 0 deletions

16
.editorconfig Normal file
View File

@ -0,0 +1,16 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 100
tab_width = 4
[Makefile]
indent_style = tab
[*.go]
indent_style = tab

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/coverage.out

52
Makefile Normal file
View File

@ -0,0 +1,52 @@
.DEFAULT_GOAL := help
COVER_FILE ?= coverage.out
.PHONY: build
build: ## Build a command to quickly check compiles.
@go build ./...
.PHONY: check
check: lint build test ## Runs all necessary code checks.
.PHONY: test
test: ## Run all tests.
@go test -race -count=1 -coverprofile=$(COVER_FILE) ./...
@go tool cover -func=$(COVER_FILE) | grep ^total | tr -s '\t'
.PHONY: test-short
test-short: ## Run only unit tests, tests without I/O dependencies.
@go test -short ./...
.PHONY: test-env-up
test-env-up: ## Run test environment.
@docker-compose up migrate
.PHONY: test-env-down
test-env-down: ## Down and cleanup test environment.
@docker-compose down -v
.PHONY: lint
lint: tools ## Check the project with lint.
@golangci-lint run \
--fix \
--disable-all \
-E errcheck \
-E godot \
-E goimports \
-E gosimple \
-E govet \
-E ineffassign \
-E misspell \
-E staticcheck \
-E typecheck \
-E unused \
-E whitespace \
./...
tools: ## Install all needed tools, e.g.
@go install -v github.com/golangci/golangci-lint/cmd/golangci-lint@v1.53.2
.PHONY: help
help: ## Show help for each of the Makefile targets.
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

13
README.md Normal file
View File

@ -0,0 +1,13 @@
# Example of testing Go code with Postgres
The example suggests a solution to the problem of cleaning the database after
running tests and the problem of running tests in parallel. It also shows how
to organize integration testing of Go code with Postgres.
## How to use
Run `make test-env-up test` and then everything will happen by itself.
## Disclaimer
**This example is not an example of software architecture!**

30
docker-compose.yml Normal file
View File

@ -0,0 +1,30 @@
version: "3.8"
services:
postgres:
image: postgres:15.3-alpine3.18
environment:
POSTGRES_DB: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_MULTIPLE_DATABASES: reference
healthcheck:
test: pg_isready --username "postgres" --dbname "reference"
interval: 1s
retries: 5
timeout: 5s
ports:
- "32260:5432"
volumes:
- ./docker-multiple-databases.sh:/docker-entrypoint-initdb.d/docker-multiple-databases.sh:ro
migrate:
image: migrate/migrate:v4.16.2
command: >
-source 'file:///migrations'
-database 'postgresql://postgres:postgres@postgres:5432/reference?sslmode=disable' up
depends_on:
postgres:
condition: service_healthy
volumes:
- ./migrations:/migrations:ro

20
docker-multiple-databases.sh Executable file
View File

@ -0,0 +1,20 @@
#!/bin/bash
set -e
set -u
function create_user_and_database() {
local database=$1
echo " Creating user and database '$database'"
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL
CREATE DATABASE $database OWNER $POSTGRES_USER;
EOSQL
}
if [ -n "$POSTGRES_MULTIPLE_DATABASES" ]; then
echo "Multiple database creation requested: $POSTGRES_MULTIPLE_DATABASES"
for database in $(echo "$POSTGRES_MULTIPLE_DATABASES" | tr ',' ' '); do
create_user_and_database "$database"
done
echo "Multiple databases created"
fi

23
go.mod Normal file
View File

@ -0,0 +1,23 @@
module github.com/xorcare/testing-go-code-with-postgres
go 1.20
require (
github.com/google/uuid v1.3.0
github.com/jackc/pgx/v5 v5.3.1
github.com/stretchr/testify v1.8.4
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.9.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

37
go.sum Normal file
View File

@ -0,0 +1,37 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.3.1 h1:Fcr8QJ1ZeLi5zsPZqQeUZhNhxfkkKBOgJuYkJHoBOtU=
github.com/jackc/pgx/v5 v5.3.1/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
github.com/jackc/puddle/v2 v2.2.0 h1:RdcDk92EJBuBS55nQMMYFXTxwstHug4jkhT5pq8VxPk=
github.com/jackc/puddle/v2 v2.2.0/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1 @@
DROP TABLE users;

View File

@ -0,0 +1,6 @@
CREATE TABLE users
(
user_id uuid,
username VARCHAR(50) UNIQUE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL
);

116
testingpg/testingpg.go Normal file
View File

@ -0,0 +1,116 @@
// Copyright (c) 2023 Vasiliy Vasilyuk. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package testingpg
import (
"context"
"fmt"
"net/url"
"os"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
)
type TestingT interface {
require.TestingT
Logf(format string, args ...any)
Cleanup(f func())
}
func New(t TestingT) *Postgres {
return newPostgres(t).cloneFromReference(t)
}
type Postgres struct {
url string
ref string
conn *pgxpool.Pool
}
func newPostgres(t TestingT) *Postgres {
urlStr := os.Getenv("TESTING_DB_URL")
if urlStr == "" {
urlStr = "postgresql://postgres:postgres@localhost:32260/postgres?sslmode=disable"
const format = "env TESTING_DB_URL is empty, used default value: %s"
t.Logf(format, urlStr)
}
refDatabase := os.Getenv("TESTING_DB_REF")
if refDatabase == "" {
refDatabase = "reference"
}
pool, err := pgxpool.New(context.Background(), urlStr)
require.NoError(t, err)
return &Postgres{
url: urlStr,
ref: refDatabase,
conn: pool,
}
}
func (p *Postgres) URL() string {
return p.url
}
func (p *Postgres) PgxPool() *pgxpool.Pool {
return p.conn
}
func (p *Postgres) cloneFromReference(t TestingT) *Postgres {
cfg, err := pgxpool.ParseConfig(p.url)
require.NoError(t, err)
pool, err := pgxpool.New(context.Background(), p.url)
require.NoError(t, err)
newDatabaseName := uuid.New().String()
const sqlTemplate = `CREATE DATABASE %q WITH TEMPLATE %s OWNER %s;`
sql := fmt.Sprintf(
sqlTemplate,
newDatabaseName,
p.ref,
cfg.ConnConfig.User,
)
_, err = pool.Exec(context.Background(), sql)
require.NoError(t, err)
// Automatically drop database copy after the test is completed.
t.Cleanup(func() {
sql := fmt.Sprintf(`DROP DATABASE %q WITH (FORCE);`, newDatabaseName)
ctx, done := context.WithTimeout(context.Background(), time.Minute)
defer done()
_, err := p.conn.Exec(ctx, sql)
require.NoError(t, err)
})
urlString := replaceDBName(t, cfg, newDatabaseName)
newPool, err := pgxpool.New(context.Background(), urlString)
require.NoError(t, err)
return &Postgres{
url: urlString,
ref: newDatabaseName,
conn: newPool,
}
}
func replaceDBName(t TestingT, cfg *pgxpool.Config, dbname string) string {
r, err := url.Parse(cfg.ConnString())
require.NoError(t, err)
r.Path = dbname
return r.String()
}

View File

@ -0,0 +1,95 @@
// Copyright (c) 2023 Vasiliy Vasilyuk. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package testingpg_test
import (
"context"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"github.com/xorcare/testing-go-code-with-postgres/testingpg"
)
func TestNewPostgres(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode")
}
t.Parallel()
t.Run("Successfully connect by URL and get version", func(t *testing.T) {
t.Parallel()
// Arrange
postgres := testingpg.New(t)
ctx := context.Background()
dbPool, err := pgxpool.New(ctx, postgres.URL())
require.NoError(t, err)
// Act
var version string
err = dbPool.QueryRow(ctx, "SELECT version();").Scan(&version)
// Assert
require.NoError(t, err)
require.NotEmpty(t, version)
t.Log(version)
})
t.Run("Successfully obtained a version using a pre-configured conn", func(t *testing.T) {
t.Parallel()
// Arrange
postgres := testingpg.New(t)
ctx := context.Background()
// Act
var version string
err := postgres.PgxPool().QueryRow(ctx, "SELECT version();").Scan(&version)
// Assert
require.NoError(t, err)
require.NotEmpty(t, version)
t.Log(version)
})
t.Run("Changes are not visible in different instances", func(t *testing.T) {
t.Parallel()
// Arrange
postgres1 := testingpg.New(t)
postgres2 := testingpg.New(t)
ctx := context.Background()
// Act
const sql = `CREATE TABLE "no_conflict" (id integer PRIMARY KEY)`
_, err1 := postgres1.PgxPool().Exec(ctx, sql)
_, err2 := postgres2.PgxPool().Exec(ctx, sql)
// Assert
require.NoError(t, err1)
require.NoError(t, err2, "databases must be isolated for each instance")
})
t.Run("URL is different at different instances", func(t *testing.T) {
t.Parallel()
// Arrange
postgres1 := testingpg.New(t)
postgres2 := testingpg.New(t)
// Act
url1 := postgres1.URL()
url2 := postgres2.URL()
// Assert
require.NotEqual(t, url1, url2)
})
}

17
user_model.go Normal file
View File

@ -0,0 +1,17 @@
// Copyright (c) 2023 Vasiliy Vasilyuk. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package testing_go_code_with_postgres
import (
"time"
"github.com/google/uuid"
)
type User struct {
ID uuid.UUID
Username string
CreatedAt time.Time
}

55
user_repository.go Normal file
View File

@ -0,0 +1,55 @@
// Copyright (c) 2023 Vasiliy Vasilyuk. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package testing_go_code_with_postgres
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func NewUserRepository(db *pgxpool.Pool) *UserRepository {
return &UserRepository{db: db}
}
type UserRepository struct {
db *pgxpool.Pool
}
func (r *UserRepository) ReadUser(ctx context.Context, userID uuid.UUID) (User, error) {
const sql = `SELECT user_id, username, created_at FROM users WHERE user_id = $1;`
user := User{}
row := r.db.QueryRow(ctx, sql, userID)
err := row.Scan(&user.ID, &user.Username, &user.CreatedAt)
if err != nil {
const format = "failed selection of User from database: %v"
return User{}, fmt.Errorf(format, err)
}
return user, nil
}
func (r *UserRepository) CreateUser(ctx context.Context, user User) error {
const sql = `INSERT INTO users (user_id, username, created_at) VALUES ($1,$2,$3);`
_, err := r.db.Exec(
ctx,
sql,
user.ID,
user.Username,
user.CreatedAt,
)
if err != nil {
const format = "failed insertion of User to database: %v"
return fmt.Errorf(format, err)
}
return nil
}

77
user_repository_test.go Normal file
View File

@ -0,0 +1,77 @@
// Copyright (c) 2023 Vasiliy Vasilyuk. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package testing_go_code_with_postgres
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/xorcare/testing-go-code-with-postgres/testingpg"
)
func TestUserRepository_CreateUser(t *testing.T) {
newFullyFiledUser := func() User {
return User{
ID: uuid.New(),
Username: "gopher",
CreatedAt: time.Now().Truncate(time.Microsecond),
}
}
t.Run("Successfully created a User", func(t *testing.T) {
// Arrange
postgres := testingpg.New(t)
repo := NewUserRepository(postgres.PgxPool())
user := newFullyFiledUser()
// Act
err := repo.CreateUser(context.Background(), user)
// Assert
require.NoError(t, err)
gotUser, err := repo.ReadUser(context.Background(), user.ID)
require.NoError(t, err)
require.Equal(t, user, gotUser)
})
t.Run("Cannot create a user with the same ID", func(t *testing.T) {
// Arrange
postgres := testingpg.New(t)
repo := NewUserRepository(postgres.PgxPool())
user := newFullyFiledUser()
err := repo.CreateUser(context.Background(), user)
require.NoError(t, err)
// Act
err = repo.CreateUser(context.Background(), user)
// Assert
require.Error(t, err)
require.Contains(t, err.Error(), "duplicate key value violates unique constraint")
})
}
func TestUserRepository_ReadUser(t *testing.T) {
t.Run("Get an error if the user does not exist", func(t *testing.T) {
// Arrange
postgres := testingpg.New(t)
repo := NewUserRepository(postgres.PgxPool())
// Act
_, err := repo.ReadUser(context.Background(), uuid.New())
// Assert
require.Error(t, err)
})
}