Last updated on

Database testing tutorial


Testing interactions with the database isn’t the easiest. Doing it properly can be even challenging.
This post presents a practice which helps testing the database efficiently.

Types of tests

Based on how much code is being tested in a test case, the following test categories can be defined:


Unit tests are testing a single unit of code, separated from all external resources. These are the simplest, fastest, cheapest tests.


Integration tests are testing that a unit of code can be integrated with another unit of code or with an external resource. These tests are more complicated, slower, more expensive.


E2E tests are testing that a system behaves correctly. These are the most complicated, slowest, most expensive tests.

bread

Why unit tests aren’t suitable for the purpose

You may think, OK, if unit tests are the fastest simplest, and cheapest, why don’t we write only unit tests?


The answer is: there are things which can’t be tested with unit tests, and database interactions fall into this category.


If you wrote unit tests for the code interacting with your database, you would have to mock out the actual interactions, so the tests would lose their original purpose.


Altough if you asked the above question, you were right, one should write unit tests as long as possible. If something can’t be tested with a unit test, try testing it with an integration test. If it can’t be tested with integration test, then write an E2E test - this explains the shape of the testing pyramid.
Keep you test suite simple, just like everything else.

Testing with integrations tests

The following demonstration is written in Go, but the same setup can be achieved with probably all languages.


In this case I’m using the gorm library, because I don’t like to deal with low-level database libraries. Let these higher level libraries do the connection-pooling and constructing the SQL statements. If you don’t trust the authors, you can implement these functionalities and re-invent the wheel.


Let’s consider having a very simple module responsible for creating and deleting users:

package db_test

import (
	"db_test/db"
	"errors"

	"gorm.io/gorm"
)

func CreateUser(conn *gorm.DB, name string) (db.User, error) {
	user := db.User{Name: name}
	res := conn.Create(&user)
	return user, res.Error
}

func DeleteUser(conn *gorm.DB, id int) error {
	res := conn.Where("id = ?", id).Delete(&db.User{})

	if res.Error != nil {
		return res.Error
	}

	if res.RowsAffected == 0 {
		return errors.New("USER_NOT_EXISTS")
	}

	return nil
}

Both functions are receiving the conn argument, which is the database connection, and as you will see this is essential from the testing perspective. Maybe a later post will cover the importance of DI.


Since gorm doesn’t have different types for connections and transactions, the conn parameter could be either. Let’s gather the pros and cons using a connection or a transaction in the functions.

ProsCons
Connection
  • We can be sure the data is being saved into the db (commit can happen within the function)

  • Tests can affect eachother
  • Tests can’t run in parallel
  • Tests are slower compared to the transactional tests
Transaction
  • The commit can be executed in a single, centralised function
  • Tests can’t affect eachother (transactional isolation)
  • Rolling back at the end of each test is faster than commiting and deleting

  • Tests can run in parallel
  • We can’t be sure the data is being saved into the db, additional test is needed


Now it’s clearly visible: we should pass transactions to our functions and not the connection itself.
Let’s have a look at the tests:

package db_test

import (
	"db_test/db"
	"testing"

	"github.com/DAtek/gotils"
	"github.com/stretchr/testify/assert"

	"gorm.io/gorm"
)

func TestCreateUser(t *testing.T) {
	t.Run(
		"Creates user",
		db.TestWithTransaction(func(t *testing.T, tx *gorm.DB) {
			// given
			name := "Asimov"

			// when
			user := gotils.ResultOrPanic(CreateUser(tx, name))

			// then
			assert.Equal(t, name, user.Name)
			savedUser := db.User{}
			tx.Find(&savedUser).Where("id = ?", user.Id)
			assert.Equal(t, name, savedUser.Name)
		}))

	t.Run(
		"Returns error if user with the same name already exists",
		db.TestWithTransaction(func(t *testing.T, tx *gorm.DB) {
			// given
			name := "Asimov"
			CreateUser(tx, name)

			// when
			_, err := CreateUser(tx, name)

			// then
			assert.Error(t, err)
		}))
}

func TestDeleteUser(t *testing.T) {
	t.Run(
		"Deletes user",
		db.TestWithTransaction(func(t *testing.T, tx *gorm.DB) {
			// given
			user := db.User{Name: "Isaac"}
			res := tx.Create(&user)
			if res.Error != nil {
				panic(res.Error)
			}

			// when
			err := DeleteUser(tx, user.Id)

			// then
			assert.Nil(t, err)
			count := int64(0)
			tx.Find(&db.User{}).Where("id = ?", user.Id).Count(&count)
			assert.Equal(t, int64(0), count)
		}))

	t.Run(
		"Returns error if user not exists",
		db.TestWithTransaction(func(t *testing.T, tx *gorm.DB) {
			// when
			err := DeleteUser(tx, 1)

			// then
			assert.Error(t, err)
		}))

	t.Run(
		"Returns error if unexpected event happens",
		db.TestWithTransaction(func(t *testing.T, tx *gorm.DB) {
			// given
			user := db.User{Name: "Isaac"}
			res := tx.Create(&user)
			if res.Error != nil {
				panic(res.Error)
			}

			tx.Rollback()

			// when
			err := DeleteUser(tx, user.Id)

			// then
			assert.Error(t, err)
		}))
}

And the test_helpers.go:

package db

import (
	"sync"
	"testing"

	"github.com/DAtek/gotils"
	"gorm.io/gorm"
)

var GetTestConn = sync.OnceValue(func() *gorm.DB {
	return gotils.ResultOrPanic(NewConnFromEnv())
})

func GetTestTransaction() *gorm.DB {
	return GetTestConn().Begin()
}

func TestWithTransaction(
	transactionalTest func(t *testing.T, tx *gorm.DB),
) func(t *testing.T) {
	return func(t *testing.T) {
		tx := GetTestTransaction()
		t.Cleanup(func() { tx.Rollback() })
		transactionalTest(t, tx)
	}
}


With this approach each test owns a transaction and the test suite can stay blazingly fast.


The full source code can be found in the project’s repository, in the /code_examples/db_testing folder.