diff --git a/LICENSE b/LICENSE index 7e05b71..2a3cff8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2023, Vasiliy Vasilyuk +Copyright (c) 2023-2024, Vasiliy Vasilyuk Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/Makefile b/Makefile index 91c6c4f..a925adf 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ build: ## Build a command to quickly check compiles. .PHONY: check check: lint build test ## Runs all necessary code checks. + @go mod tidy .PHONY: test test: ## Run all tests. @@ -30,6 +31,7 @@ test-env-down: ## Down and cleanup test environment. lint: tools ## Check the project with lint. @golangci-lint run --fix ./... +.PHONY: tools tools: ## Install all needed tools, e.g. @go install -v github.com/golangci/golangci-lint/cmd/golangci-lint@v1.55.2 diff --git a/README.md b/README.md index a37bdb0..8d6b0a1 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,9 @@ total: (statements) 100.0% - Example of test database connection management in [testingpg](https://github.com/xorcare/testing-go-code-with-postgres/tree/main/testingpg) package. -- Example of - integration [tests](https://github.com/xorcare/testing-go-code-with-postgres/blob/main/user_repository_test.go). +- [Example of integration testing with isolated database for each testcase](https://github.com/xorcare/testing-go-code-with-postgres/blob/main/user_repository_with_isolated_database_test.go). +- [Example of integration testing with transaction cleanup for each testcase](https://github.com/xorcare/testing-go-code-with-postgres/blob/main/user_repository_with_transactional_cleanup_test.go). +- [Example of integration testing with isolated schema for each testcase](https://github.com/xorcare/testing-go-code-with-postgres/blob/main/user_repository_with_isolated_schema_test.go). - And example of [GitHub Actions](https://github.com/xorcare/testing-go-code-with-postgres/blob/main/.github/workflows/go.yml) and [Gitlab CI](https://github.com/xorcare/testing-go-code-with-postgres/blob/main/.gitlab-ci.yml). diff --git a/docker-compose.yml b/docker-compose.yml index d5e0882..248f618 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: POSTGRES_DB: postgres POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres - POSTGRES_MULTIPLE_DATABASES: reference + POSTGRES_MULTIPLE_DATABASES: reference, transaction healthcheck: test: pg_isready --username "postgres" --dbname "reference" interval: 1s @@ -21,7 +21,7 @@ services: tmpfs: - /var/lib/postgresql/data:rw # Necessary to speed up integration tests. - migrate: + migrate-reference: image: migrate/migrate:v4.16.2 command: > -source 'file:///migrations' @@ -31,3 +31,25 @@ services: condition: service_healthy volumes: - ./migrations:/migrations:ro + + migrate-transaction: + image: migrate/migrate:v4.16.2 + command: > + -source 'file:///migrations' + -database 'postgresql://postgres:postgres@postgres:5432/transaction?sslmode=disable' up + depends_on: + postgres: + condition: service_healthy + volumes: + - ./migrations:/migrations:ro + + migrate: + image: postgres:15.3-alpine3.18 + command: echo 'All migrations have been successfully applied!' + depends_on: + postgres: + condition: service_healthy + migrate-reference: + condition: service_completed_successfully + migrate-transaction: + condition: service_completed_successfully diff --git a/go.mod b/go.mod index 64d7fd9..41e364a 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/xorcare/testing-go-code-with-postgres go 1.22.0 require ( + github.com/golang-migrate/migrate/v4 v4.17.1 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.6.0 github.com/stretchr/testify v1.9.0 @@ -10,14 +11,18 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror 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.1 // indirect github.com/kr/text v0.2.0 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect - golang.org/x/crypto v0.17.0 // indirect - golang.org/x/sync v0.1.0 // indirect + go.uber.org/atomic v1.7.0 // indirect + golang.org/x/crypto v0.20.0 // indirect + golang.org/x/sync v0.5.0 // indirect golang.org/x/text v0.14.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index dad6ff3..f047e28 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,32 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 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/dhui/dktest v0.4.1 h1:/w+IWuDXVymg3IrRJCHHOkMK10m9aNVMOyD0X12YVTg= +github.com/dhui/dktest v0.4.1/go.mod h1:DdOqcUpL7vgyP4GlF3X3w7HbSlz8cEQzwewPveYEQbA= +github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= +github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0= +github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= +github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 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= @@ -16,6 +39,18 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= +github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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= @@ -25,12 +60,22 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +golang.org/x/crypto v0.20.0 h1:jmAMJJZXr5KiCw05dfYK9QnqaqKLYXijU23lsEdcQqg= +golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= +golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= +golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg= +golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM= 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/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/migrations/embed.go b/migrations/embed.go new file mode 100644 index 0000000..12cc1e9 --- /dev/null +++ b/migrations/embed.go @@ -0,0 +1,10 @@ +// Copyright (c) 2024 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 migrations + +import "embed" + +//go:embed *.up.sql +var FS embed.FS diff --git a/testingpg/testingpg.go b/testingpg/testingpg.go index 010c21e..76d42aa 100644 --- a/testingpg/testingpg.go +++ b/testingpg/testingpg.go @@ -1,4 +1,4 @@ -// Copyright (c) 2023 Vasiliy Vasilyuk. All rights reserved. +// Copyright (c) 2023-2024 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. @@ -7,6 +7,7 @@ package testingpg import ( "context" "crypto/rand" + "database/sql" "encoding/base64" "fmt" "net/url" @@ -16,7 +17,8 @@ import ( "time" "unicode" - "github.com/jackc/pgx/v5/pgxpool" + _ "github.com/golang-migrate/migrate/v4/source/file" + _ "github.com/jackc/pgx/v5/stdlib" "github.com/stretchr/testify/require" ) @@ -30,8 +32,40 @@ type TestingT interface { Failed() bool } -func New(t TestingT) *Postgres { - return newPostgres(t).cloneFromReference() +const defaultPostgresURL = "postgresql://postgres:postgres@localhost:32260/postgres?sslmode=disable" + +func NewWithIsolatedDatabase(t TestingT) *Postgres { + return newPostgres(t, defaultPostgresURL).cloneFromReference() +} + +func NewWithIsolatedSchema(t TestingT) *Postgres { + return newPostgres(t, defaultPostgresURL).createSchema(t) +} + +func NewWithTransactionalCleanup(t TestingT) interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} { + // databaseName a separate database is used for transactional cleanup. + const databaseName = "transaction" + + postgres := newPostgres(t, defaultPostgresURL) + postgres = postgres.replaceDBName(databaseName) + + ctx, done := context.WithCancel(context.Background()) + t.Cleanup(done) + + tx, err := postgres.DB().BeginTx(ctx, &sql.TxOptions{ + Isolation: sql.LevelRepeatableRead, + ReadOnly: false, + }) + require.NoError(t, err) + + t.Cleanup(func() { + require.NoError(t, tx.Rollback()) + }) + + return tx } type Postgres struct { @@ -40,14 +74,14 @@ type Postgres struct { url string ref string - pgxpool *pgxpool.Pool - pgxpoolOnce sync.Once + sqlDB *sql.DB + sqlDBOnce sync.Once } -func newPostgres(t TestingT) *Postgres { +func newPostgres(t TestingT, defaultPostgresURL string) *Postgres { urlStr := os.Getenv("TESTING_DB_URL") if urlStr == "" { - urlStr = "postgresql://postgres:postgres@localhost:32260/postgres?sslmode=disable" + urlStr = defaultPostgresURL const format = "env TESTING_DB_URL is empty, used default value: %s" @@ -71,12 +105,46 @@ func (p *Postgres) URL() string { return p.url } -func (p *Postgres) PgxPool() *pgxpool.Pool { - p.pgxpoolOnce.Do(func() { - p.pgxpool = newPGxPool(p.t, p.URL()) +func (p *Postgres) DB() *sql.DB { + p.sqlDBOnce.Do(func() { + p.sqlDB = open(p.t, p.URL()) + }) + + return p.sqlDB +} + +func (p *Postgres) createSchema(t TestingT) *Postgres { + schemaName := newUniqueHumanReadableDatabaseName(p.t) + + // Unclear why, but if the scheme contains letters of different case, the + // tests stop working. At the moment I don't quite understand why this + // happens, but converting to lower case fixes the problem. + schemaName = strings.ToLower(schemaName) + + ctx, done := context.WithCancel(context.Background()) + t.Cleanup(done) + + { + sql := fmt.Sprintf(`CREATE SCHEMA "%s";`, schemaName) + + _, err := p.DB().ExecContext(ctx, sql) + require.NoError(t, err) + } + + t.Cleanup(func() { + sql := fmt.Sprintf(`DROP SCHEMA "%s" CASCADE;`, schemaName) + + _, err := p.DB().ExecContext(ctx, sql) + require.NoError(t, err) }) - return p.pgxpool + pgurl := setSearchPath(t, p.URL(), schemaName) + + return &Postgres{ + t: p.t, + ref: p.ref, + url: pgurl.String(), + } } func (p *Postgres) cloneFromReference() *Postgres { @@ -90,7 +158,7 @@ func (p *Postgres) cloneFromReference() *Postgres { p.ref, ) - _, err := p.PgxPool().Exec(context.Background(), sql) + _, err := p.DB().ExecContext(context.Background(), sql) require.NoError(p.t, err) // Automatically drop database copy after the test is completed. @@ -100,15 +168,26 @@ func (p *Postgres) cloneFromReference() *Postgres { ctx, done := context.WithTimeout(context.Background(), time.Minute) defer done() - _, err := p.PgxPool().Exec(ctx, sql) + _, err := p.DB().ExecContext(ctx, sql) require.NoError(p.t, err) }) + return p.replaceDBName(newDBName) +} + +func (p *Postgres) replaceDBName(newDBName string) *Postgres { + o := p.clone() + o.url = replaceDBName(p.t, p.URL(), newDBName) + + return o +} + +func (p *Postgres) clone() *Postgres { return &Postgres{ t: p.t, - url: replaceDBName(p.t, p.URL(), newDBName), - ref: newDBName, + url: p.url, + ref: p.ref, } } @@ -169,17 +248,25 @@ func replaceDBName(t TestingT, dataSourceURL, dbname string) string { return r.String() } -func newPGxPool(t TestingT, dataSourceURL string) *pgxpool.Pool { - ctx, done := context.WithTimeout(context.Background(), 1*time.Second) - defer done() - - pool, err := pgxpool.New(ctx, dataSourceURL) +func open(t TestingT, dataSourceURL string) *sql.DB { + db, err := sql.Open("pgx/v5", dataSourceURL) require.NoError(t, err) // Automatically close connection after the test is completed. t.Cleanup(func() { - pool.Close() + require.NoError(t, db.Close()) }) - return pool + return db +} + +func setSearchPath(t TestingT, pgURL string, schemaName string) *url.URL { + pgurl, err := url.Parse(pgURL) + require.NoError(t, err) + + query := pgurl.Query() + query.Set("search_path", schemaName) + pgurl.RawQuery = query.Encode() + + return pgurl } diff --git a/testingpg/testingpg_test.go b/testingpg/testingpg_test.go index 6ab429b..8b1dd78 100644 --- a/testingpg/testingpg_test.go +++ b/testingpg/testingpg_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2023 Vasiliy Vasilyuk. All rights reserved. +// Copyright (c) 2023-2024 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. @@ -25,7 +25,7 @@ func TestNewPostgres(t *testing.T) { t.Parallel() // Arrange - postgres := testingpg.New(t) + postgres := testingpg.NewWithIsolatedDatabase(t) ctx := context.Background() dbPool, err := pgxpool.New(ctx, postgres.URL()) @@ -45,12 +45,12 @@ func TestNewPostgres(t *testing.T) { t.Parallel() // Arrange - postgres := testingpg.New(t) + postgres := testingpg.NewWithIsolatedDatabase(t) ctx := context.Background() // Act var version string - err := postgres.PgxPool().QueryRow(ctx, "SELECT version();").Scan(&version) + err := postgres.DB().QueryRowContext(ctx, "SELECT version();").Scan(&version) // Assert require.NoError(t, err) @@ -63,15 +63,15 @@ func TestNewPostgres(t *testing.T) { t.Parallel() // Arrange - postgres1 := testingpg.New(t) - postgres2 := testingpg.New(t) + postgres1 := testingpg.NewWithIsolatedDatabase(t) + postgres2 := testingpg.NewWithIsolatedDatabase(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) + const sqlStr = `CREATE TABLE "no_conflict" (id integer PRIMARY KEY)` + _, err1 := postgres1.DB().ExecContext(ctx, sqlStr) + _, err2 := postgres2.DB().ExecContext(ctx, sqlStr) // Assert require.NoError(t, err1) @@ -82,8 +82,8 @@ func TestNewPostgres(t *testing.T) { t.Parallel() // Arrange - postgres1 := testingpg.New(t) - postgres2 := testingpg.New(t) + postgres1 := testingpg.NewWithIsolatedDatabase(t) + postgres2 := testingpg.NewWithIsolatedDatabase(t) // Act url1 := postgres1.URL() @@ -93,3 +93,134 @@ func TestNewPostgres(t *testing.T) { require.NotEqual(t, url1, url2) }) } + +func TestNewWithIsolatedSchema(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.NewWithIsolatedSchema(t) + + ctx := context.Background() + dbPool, err := pgxpool.New(ctx, postgres.URL()) + require.NoError(t, err) + + // Act + var version string + err = dbPool.QueryRow(ctx, "SHOW search_path;").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.NewWithIsolatedSchema(t) + ctx := context.Background() + + // Act + var version string + err := postgres.DB().QueryRowContext(ctx, "SHOW search_path;").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.NewWithIsolatedSchema(t) + postgres2 := testingpg.NewWithIsolatedSchema(t) + + ctx := context.Background() + + // Act + const sqlStr = `CREATE TABLE "no_conflict" (id integer PRIMARY KEY)` + _, err1 := postgres1.DB().ExecContext(ctx, sqlStr) + _, err2 := postgres2.DB().ExecContext(ctx, sqlStr) + + // 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.NewWithIsolatedSchema(t) + postgres2 := testingpg.NewWithIsolatedSchema(t) + + // Act + url1 := postgres1.URL() + url2 := postgres2.URL() + + // Assert + require.NotEqual(t, url1, url2) + }) +} + +func TestNewWithTransactionalCleanup(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode") + } + + t.Parallel() + + t.Run("Successfully obtained a version", func(t *testing.T) { + t.Parallel() + + // Arrange + tx := testingpg.NewWithTransactionalCleanup(t) + ctx := context.Background() + + // Act + var version string + err := tx.QueryRowContext(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 + ctx := context.Background() + const sqlStr = `CREATE TABLE "no_conflict" (id integer PRIMARY KEY)` + + t.Run("Arrange", func(t *testing.T) { + tx := testingpg.NewWithTransactionalCleanup(t) + _, err := tx.ExecContext(ctx, sqlStr) + require.NoError(t, err) + }) + + var err error + + // Act + t.Run("Act", func(t *testing.T) { + tx := testingpg.NewWithTransactionalCleanup(t) + _, err = tx.ExecContext(ctx, sqlStr) + }) + + // Assert + require.NoError(t, err, "side effects must be isolated for each instance") + }) +} diff --git a/user_repository.go b/user_repository.go index 8294754..152419d 100644 --- a/user_repository.go +++ b/user_repository.go @@ -1,4 +1,4 @@ -// Copyright (c) 2023 Vasiliy Vasilyuk. All rights reserved. +// Copyright (c) 2023-2024 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. @@ -6,18 +6,23 @@ package testing_go_code_with_postgres import ( "context" + "database/sql" "fmt" "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgxpool" ) -func NewUserRepository(db *pgxpool.Pool) *UserRepository { +type DB interface { + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +func NewUserRepository(db DB) *UserRepository { return &UserRepository{db: db} } type UserRepository struct { - db *pgxpool.Pool + db DB } func (r *UserRepository) ReadUser(ctx context.Context, userID uuid.UUID) (User, error) { @@ -25,12 +30,11 @@ func (r *UserRepository) ReadUser(ctx context.Context, userID uuid.UUID) (User, user := User{} - row := r.db.QueryRow(ctx, sql, userID) + row := r.db.QueryRowContext(ctx, sql, userID) err := row.Scan(&user.ID, &user.Username, &user.CreatedAt) if err != nil { - const format = "failed selection of User from database: %v" - + const format = "failed selection of User from database: %w" return User{}, fmt.Errorf(format, err) } @@ -40,7 +44,7 @@ func (r *UserRepository) ReadUser(ctx context.Context, userID uuid.UUID) (User, 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( + _, err := r.db.ExecContext( ctx, sql, user.ID, diff --git a/user_repository_test.go b/user_repository_test.go index eb5e89d..35a0a6a 100644 --- a/user_repository_test.go +++ b/user_repository_test.go @@ -1,96 +1,12 @@ -// Copyright (c) 2023 Vasiliy Vasilyuk. All rights reserved. +// Copyright (c) 2023-2024 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_test -import ( - "context" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/require" - - rootpkg "github.com/xorcare/testing-go-code-with-postgres" - "github.com/xorcare/testing-go-code-with-postgres/testingpg" -) - -func TestUserRepository_CreateUser(t *testing.T) { - if testing.Short() { - t.Skip("skipping test in short mode") - } - - t.Parallel() - - newFullyFiledUser := func() rootpkg.User { - return rootpkg.User{ - ID: uuid.New(), - Username: "gopher", - CreatedAt: time.Now().Truncate(time.Microsecond), - } - } - - t.Run("Successfully created a User", func(t *testing.T) { - t.Parallel() - - // Arrange - postgres := testingpg.New(t) - repo := rootpkg.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) { - t.Parallel() - - // Arrange - postgres := testingpg.New(t) - repo := rootpkg.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) { - if testing.Short() { - t.Skip("skipping test in short mode") - } - - t.Parallel() - - t.Run("Get an error if the user does not exist", func(t *testing.T) { - t.Parallel() - - // Arrange - postgres := testingpg.New(t) - repo := rootpkg.NewUserRepository(postgres.PgxPool()) - - // Act - _, err := repo.ReadUser(context.Background(), uuid.New()) - - // Assert - require.Error(t, err) - }) -} +// Since the repository contains examples of different approaches to +// database cleanup, this file is empty. The specific tests can be found +// in the following files: +// - user_repository_with_isolated_database_test.go +// - user_repository_with_transactional_cleanup_test.go +// - user_repository_with_isolated_schema_test.go diff --git a/user_repository_with_isolated_database_test.go b/user_repository_with_isolated_database_test.go new file mode 100644 index 0000000..bdad927 --- /dev/null +++ b/user_repository_with_isolated_database_test.go @@ -0,0 +1,96 @@ +// 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_test + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + rootpkg "github.com/xorcare/testing-go-code-with-postgres" + "github.com/xorcare/testing-go-code-with-postgres/testingpg" +) + +func TestUserRepository_CreateUser(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode") + } + + t.Parallel() + + newFullyFiledUser := func() rootpkg.User { + return rootpkg.User{ + ID: uuid.New(), + Username: "gopher", + CreatedAt: time.Now().Truncate(time.Microsecond), + } + } + + t.Run("Successfully created a User", func(t *testing.T) { + t.Parallel() + + // Arrange + postgres := testingpg.NewWithIsolatedDatabase(t) + repo := rootpkg.NewUserRepository(postgres.DB()) + + 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) { + t.Parallel() + + // Arrange + postgres := testingpg.NewWithIsolatedDatabase(t) + repo := rootpkg.NewUserRepository(postgres.DB()) + + 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) { + if testing.Short() { + t.Skip("skipping test in short mode") + } + + t.Parallel() + + t.Run("Get an error if the user does not exist", func(t *testing.T) { + t.Parallel() + + // Arrange + postgres := testingpg.NewWithIsolatedDatabase(t) + repo := rootpkg.NewUserRepository(postgres.DB()) + + // Act + _, err := repo.ReadUser(context.Background(), uuid.New()) + + // Assert + require.Error(t, err) + }) +} diff --git a/user_repository_with_isolated_schema_test.go b/user_repository_with_isolated_schema_test.go new file mode 100644 index 0000000..9d68e11 --- /dev/null +++ b/user_repository_with_isolated_schema_test.go @@ -0,0 +1,134 @@ +// Copyright (c) 2023-2024 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_test + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/golang-migrate/migrate/v4/source/iofs" + + "github.com/xorcare/testing-go-code-with-postgres/migrations" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + rootpkg "github.com/xorcare/testing-go-code-with-postgres" + "github.com/xorcare/testing-go-code-with-postgres/testingpg" +) + +func migrateDatabaseSchema(t *testing.T, pg *testingpg.Postgres) { + source, err := iofs.New(migrations.FS, ".") + require.NoError(t, err) + + mi, err := migrate.NewWithSourceInstance( + "iofs", + source, + pg.URL(), + ) + require.NoError(t, err) + + err = mi.Up() + + if !errors.Is(err, migrate.ErrNoChange) { + require.NoError(t, err) + } +} + +func Test_Schema_UserRepository_CreateUser(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode") + } + + t.Parallel() + + newFullyFiledUser := func() rootpkg.User { + return rootpkg.User{ + ID: uuid.New(), + Username: "gopher", + CreatedAt: time.Now().Truncate(time.Microsecond), + } + } + + t.Run("Successfully created a User", func(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode") + } + + t.Parallel() + + // Arrange + pg := testingpg.NewWithIsolatedSchema(t) + + migrateDatabaseSchema(t, pg) + + repo := rootpkg.NewUserRepository(pg.DB()) + 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) { + t.Parallel() + + // Arrange + pg := testingpg.NewWithIsolatedSchema(t) + + migrateDatabaseSchema(t, pg) + + repo := rootpkg.NewUserRepository(pg.DB()) + + 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 Test_Schema_UserRepository_ReadUser(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode") + } + + t.Parallel() + + t.Run("Get an error if the user does not exist", func(t *testing.T) { + t.Parallel() + + // Arrange + pg := testingpg.NewWithIsolatedSchema(t) + + migrateDatabaseSchema(t, pg) + + repo := rootpkg.NewUserRepository(pg.DB()) + + // Act + _, err := repo.ReadUser(context.Background(), uuid.New()) + + // Assert + require.ErrorIs(t, err, sql.ErrNoRows) + }) +} diff --git a/user_repository_with_transactional_cleanup_test.go b/user_repository_with_transactional_cleanup_test.go new file mode 100644 index 0000000..184125b --- /dev/null +++ b/user_repository_with_transactional_cleanup_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2023-2024 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_test + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + rootpkg "github.com/xorcare/testing-go-code-with-postgres" + "github.com/xorcare/testing-go-code-with-postgres/testingpg" +) + +func Test_Transactional_UserRepository_CreateUser(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode") + } + + t.Parallel() + + newFullyFiledUser := func() rootpkg.User { + return rootpkg.User{ + ID: uuid.New(), + Username: "gopher", + CreatedAt: time.Now().Truncate(time.Microsecond), + } + } + + t.Run("Successfully created a User", func(t *testing.T) { + t.Parallel() + + // Arrange + db := testingpg.NewWithTransactionalCleanup(t) + repo := rootpkg.NewUserRepository(db) + + 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) { + t.Parallel() + + // Arrange + db := testingpg.NewWithTransactionalCleanup(t) + repo := rootpkg.NewUserRepository(db) + + 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 Test_Transactional_UserRepository_ReadUser(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode") + } + + t.Parallel() + + t.Run("Get an error if the user does not exist", func(t *testing.T) { + t.Parallel() + + // Arrange + db := testingpg.NewWithTransactionalCleanup(t) + repo := rootpkg.NewUserRepository(db) + + // Act + _, err := repo.ReadUser(context.Background(), uuid.New()) + + // Assert + require.Error(t, err) + }) +}