🍃 Integration testing of Golang microservices with a DB
In this article, we will discuss the integration testing of microservices in Golang that include managed dependencies such as databases.
One of the important stages of integration testing is checking managed dependencies such as databases that are not available to other microservices in a distributed system. When testing an API that interacts with a database, mock objects should not be used for integration testing. It is important to ensure that your testing scenarios involve the presence and use of a database object with a schema that duplicates an existing database.
Running tests using a real database is associated with the risk of changing or deleting any stored data. In addition, tests performed by different developers can affect each other and block the joint work of tests.
One way to test databases is through the method of incremental migrations, which involves gradually updating the database schema from one version to another. In addition, there is a method based on duplicating the state of the database with the reference stored in a special repository. After testing, the snapshot of the resulting database is compared to the reference. However, I do not recommend using this approach as tests can reproduce an even number of errors, and you will not be able to track where the scenario failed.
I believe that testing based on the state of the database is paradigmatically irrelevant. In this case, we are not testing the process of executing some API, but rather the state of the database. I participated in a project where complex calculations were performed and the results were recorded in the database. We repeatedly encountered problems with algorithms that were very large in volume and complexity. We assumed that having a reference database would improve our situation, but it only led to a deterioration: numerous tests comparing the output data with the reference were useless when introducing changes to the production environment. Instead of understanding the algorithms and dividing the logic into microservices, we were busy writing tests that adjusted the calculations to the expected result. Ultimately, this led to a catastrophe and the closure of a promising project.
It is also important to pay attention to transaction management in the database. In a bad case, when running tests in parallel and accessing the same data, we can compromise the integrity of the database. Therefore, when writing an API that adds or updates data, it is necessary to ensure that your tests cover such classic data manipulation phenomena as lost updates, dirty, non-repeatable, and phantom reads.
A simple approach is to use packaged database source code that can be deployed in memory during application testing. Embedded-Postgres is one of the tools for this, which does not require external dependencies outside the ecosystem of the tested microservice. Thus, if you need to run a series of tests or debug one of them, this tool can greatly simplify the work.
Configuration
To successfully run testing, it is necessary to specify information about the version of PostgreSQL and the repository from which the binary file will be downloaded. The database configuration can be placed in a separate substructure.
As usual, I created a JSON file containing all the necessary information, including the repository, version, and database configuration. It is important to note that the parameters specified in the bd
substructure must be available during testing. For example, if the port is already in use, you will not be able to start the Embedded-Postgre client.
{ "embedded-postgres": { "repository": "https://repo1.maven.org/maven2", "version": "14.5.0", "db": { "host": "localhost", "port": "6616", "user": "postgres", "password": "postgres", "dbname": "postgres", "sslmode": "disable" } } }
Migrations
For updating and rolling back, migration files will be created that will be stored in two directories, /migrations/up
and /migrations/down
respectively, and will have specific file names. The file names will follow the mask:
<version>_<description>.sql
goose - is a package that allows versioning of the database schema using snapshots. Therefore, we can gradually make changes to the database schema or, for example, add records, and then roll back to previous versions without losing performance and risking data integrity.
I divide migration files into three classes:
- Migration files that update the database schema and increase the schema version number.
- Migration files that roll back changes in the database schema and lower the schema version number.
- Migration files that add test data and increase the schema version number.
Obviously, the third class of migration files should be executed last so that we can, during testing, return to a clean latest version of the database schema without losing architectural additions.
To update the database to a certain version, you can use the following command:
if err := goose.UpTo(testSuite.db.DB, MIGRATION_UP_DIR, LAST_VERSION, goose.WithNoVersioning()); err != nil { test.Errorf("Failed to execute up goose instructions. Error: %s.\n", err.Error()) ... }
To rollback the database schema to a certain version, you can use the following command:
if err := goose.DownTo(testSuite.db.DB, MIGRATION_DOWN_DIR, LAST_VERSION, goose.WithNoVersioning()); err != nil { test.Errorf("Failed to execute up goose instructions. Error: %s.\n", err.Error()) ... }
Testing
Now let's discuss the preparatory stage of setting up tests, which is executed within the TestSuite
function. At the very beginning, we should get the configuration from a pre-prepared file with PostgreSQL settings:
configuration, err := configuration.New("../settings/configuration.json") if err != nil { test.Errorf("Failed to get the configuration. Error: %s", err.Error()) ... }
Then we create an EmbeddedPostgreSQL client, passing the configuration data from the configuration file to the settings. After creating the client, we start it listening for actions coming to the host and port specified in the configuration.
clientEmbeddedPostgreSQL := embeddedpostgres.NewDatabase(embeddedpostgres. DefaultConfig(). Port(configuration.GetPort()). Version(embeddedpostgres.PostgresVersion(configuration.GetVersion())). Database(configuration.GetName()). Username(configuration.GetUser()). Password(configuration.GetPassword()). StartTimeout(startTimeout). Logger(nil). BinaryRepositoryURL(configuration.GetRepository()), ) if err := clientEmbeddedPostgreSQL.Start(); err != nil { test.Errorf("Failed to start PostgreSQL. Error: %s.\n", err.Error()) ... } defer clientEmbeddedPostgreSQL.Stop()
Next, we need to connect to the database, but in such a way that the connection placed is carried out according to the same parameters that are being listened to by the clientEmbeddedPostgreSQL
:
testSuite := new(testSuite) testSuite.db, err = sqlx.Connect(configuration.GetDriver(), configuration.GetConnectionString()) if err != nil { test.Errorf("Failed to comnect to the DB. Error: %s", err.Error()) ... }
Now it remains to update the database schema to the LAST_VERSION_WITHOUT_DATA
version and start testing.
if err := goose.UpTo(testSuite.db.DB, "../migrations/up", LAST_VERSION_WITHOUT_DATA, goose.WithNoVersioning()); err != nil { test.Errorf("Failed to execute up goose instructions. Error: %s.\n", err.Error()) ... } suite.Run(test, testSuite)
Here is an example of a test that checks records in a table selection:
func (suite *testSuite) TestGettingRowsFromTableIsValid() { expect := []models.AstroObject{ {Name: "Mercury"}, {Name: "Venus"}, {Name: "Earth"}, } data := GetRowsFromAstroCatalogueTable(suite.db) assert.Equal(suite.T(), len(expect), len(data)) for _, row := range data { assert.Equal(suite.T(), true, slices.Contains(expect, models.AstroObject{Name: row.Name})) } }
If there is a difference between the selection and the data stored in the slice, we will receive a warning, and the test will record the algorithm failure. Of course, real scenarios contained in the API will be much more complicated. Nevertheless, this approach increases the robustness of testing and, ultimately, the stability of your product!
Repository
Below is a repository containing code with an example of using the EmbeddedPostgreSQL that you can study.
If you have downloaded the repository and want to see how the template works, simply enter the following command in the root directory of the repository.
make test