How to Test Database Interactions in Go Through Abstraction

How to Test Database Interactions in Go Through Abstraction
Shutterstock / Istel

Jason Espinoza
August 5, 2020

At LTV, our Engineering team cares about testing our code as we write it. It makes our code more resilient, less prone to unintended consequences and gives us the confidence to deploy code iteratively. While there are many types of tests (e.g. unit tests, regression tests, integration tests, security tests, etc. [1]), this post will focus on Unit Tests.

Unit tests

A unit test is a low-level test that accesses a small component of our software and follows these steps:

  1. Prepares the “environment” to make sure that the portion of code to be tested is going to be functional when it is executed.
  2. Updates the environment with fixed arguments or updates needed according to the test case.
  3. Runs the component that is being tested.
  4. Compares the results with what is expected.

When we say “environment”, we are referring to artifacts in the application such as environment variables and arguments to the code under test, rather than external factors such as ports, internet status or system memory.

Understanding why tests must be isolated is important. There are many variables outside our code that can affect the behavior of the program. When the test runs, having those dependencies could make our tests return different results every time they run. One example of this is when a program interacts with a database, there are many errors that can happen while our program is running: wrong credentials were used, the database just restarted, a query had a bad format, a deadlock occurs, a port is already in use, etc. To avoid some situations (if they are not relevant for our tests), it’s a good idea to abstract the database interaction and use a mock for the parts we need to test.

We write each test as a self-contained unit, independent of the other tests. Which means that, having tests T1, T2 and T3, each one could be executed in isolation like a black box. It is worth mentioning that “sub-tests” are solely related to their parents and they must be isolated from each other. The following image clarifies this idea.

In this image you can see:

  • T1, T2 and T3 isolated between them.
  • T1-1 and T1-2 related to the status of the father T1.
  • T1-1 and T1-2 are isolated between them.

Strategies for creating unit tests

Creating unit tests is not always simple. Keep a few important points in mind while you create your tests:

  • Keep them isolated: Keep tests isolated from databases, the file system, OS services, networking interaction, etc. Because if they fail, the result of our tests will change for reasons unrelated to the code. If we keep these dependencies, we may end up with many different results for the same tests while they are running and the review of all those possibilities may waste time.
  • Use interfaces in your code: By using interfaces, we can interchange objects or even use a custom object (like a mock) that implements the interface with specific behavior.
  • Use independent tests: Keep each test independent to ensure that one test will not change the result of others.
  • Think about all possible cases: Think about the happy path and potential unhappy paths when writing your test cases.

To use or not to use mocking libraries?

There are different mocking libraries for Golang, and we have done some analysis to understand if using them is beneficial..

Even if libraries reduce coding time and are versatile tools that allow us to mock or stub, they could add unnecessary and unused code that make the tests difficult to understand. What you could end up with is obscure code that is not easily understood nor maintained.

Granted, we write more code by not using mocking libraries, but it shouldn’t be an arduous task because our code should be modular, easy to understand and well decoupled. It also depends on the nature of what we are testing.

A very important aspect of writing our own mocks is that the tests explain the main code by themselves, so they really complement the code.

Is Golang an OOP language?

In the following sections there will be more Golang code, and we found meaningful to answer if Golang is an object-oriented programming (OOP) language and as the Golang team explains, the answer is “yes and no”, as you can see on their official web page: https://golang.org/doc/faq#Is_Go_an_object-oriented_language.

For our purposes let’s agree that it is an object-oriented programming language.

Interfaces

An interface is a type or structure that defines the methods (with their signatures) that need to be implemented for any object to fulfill it. This allows us to have objects that implement this interface with different behaviors.

To clarify this let’s see an example:

Having an interface “Teacher”.

type Teacher interface {
	GetMainCourse() string
	SetName(string)
}

We could have an object that implements this interface, which could be achieved by adding methods that follow the signatures of the methods defined in the interface.

type CSTeacher struct {
	name       string
	mainCourse string
	salary     float64
}

func (cst *CSTeacher) GetMainCourse() string {
	return cst.mainCourse
}

func (cst *CSTeacher) GetSalary() float64 {
	return cst.salary
}

func (cst *CSTeacher) SetName(name string) {
	cst.name = name
}

We can add more methods as needed but we should implement the ones defined in the interface at a minimum. Golang is an intelligent language such that it checks if the contracts are being followed in order to define if an interface is being implemented. We don’t need to specify that CSTeacher implements Teacher in the above example. This way we can abstract the solution to our problem using interfaces.

Abstraction

Before we go deeper into our solution for testing database interaction in Golang through abstraction, we should define what we mean by abstraction.

Abstraction in programming is a design technique where we avoid the irrelevant details of something and keep the relevant ones exposed to the exterior world.

The same is available in Object Oriented Programming (“OOP”) where we can expose the methods for a class (as public methods) and hide their implementation (e.g., We usually don’t care about the implementation of a method of a library, we just use it) and also hide methods (as private methods) that are usually helper methods in a library (e.g. Methods like validators that do something specific for the internal use of a library and we don’t need to export them).

We can take advantage of interfaces that will allow us to use a custom implementation of the exported methods with the needed behavior, or just add new methods to the object that implements the interface.

Are we talking about abstract database interactions for testing?

Yes, database libraries export some methods and their implementation is not very important to us. But their behavior could be important, so we define interfaces in our code to use custom implementations of the interfaces and use a custom implementation for our tests.

The coded solution

In this section we present a code snippet to show how to use interfaces and mocks with the application of abstraction to test how our code interacts with external factors like a database.

Creating Interfaces

First, let’s create the interfaces that will allow us to keep the database library code and implemented mocks separate.

package mydb

import (
	"context"
)

// IDB is the interface that defines the methods that must be implemented.
type IDB interface {
	QueryRowContext(ctx context.Context, query string, args ...interface{}) IRow
}

// IRow defines the methods that belong to a Row object.
type IRow interface {
	Scan(dest ...interface{}) error
}

Creating Mocks

We can create mocks with different behaviors as needed.

package mydb_test

import (
	"context"
	"some_path/.../mydb"
)

type dbMock struct {
	queryRowContextResult *rowMock
}

// QueryRowContext queries a database.
func (db *dbMock) QueryRowContext(ctx context.Context, query string, args ...interface{}) mydb.IRow {
	// Here we can do something with the arguments received,
	// like saving the query to validate it later, etc.
	return db.queryRowContextResult
}

type rowMock struct {
	scanError   error
	valuesInRow []interface{}
}

// Scan reads a value from a row.
func (r *rowMock) Scan(dest ...interface{}) error {
	if r.scanError != nil {
		return r.scanError // We can have a specific error.
	}

	// Specific and customized scan code goes here, using valuesInRow if you want.

	return nil
}

Using Interfaces

In the code below, the method ValidateStudents receives an interface as a parameter, so we can pass it the real database object or use a custom mock.

In the tests, we pass our mock objects and in the production code we pass the objects that the database library provides.

package blog

import (
	"context"
	"some_path/.../mydb"
)

// ValidateStudents does some validation over the recorded students.
func ValidateStudents(ctx context.Context, db mydb.IDB) error {
	students := NewStudents()
	err := db.QueryRowContext(ctx, "SELECT ...").Scan(&students)
	if err != nil {
		return err
	}

	// Code that validates students would go here.

	return nil
}

Conclusion

Testing is an important part of any project. Without tests, we are less equipped to recognize failing code before placing into production and our end-user experience could suffer.

To achieve the isolation in unit testing we can apply abstraction. With abstraction and the use of interfaces, we can have modules decoupled to interchange between a mocked module for testing and another module used for production.

Interested in working with us? Have a look at our careers page and reach out to us if you would like to be a part of our team!

Bibliography

  1. Softwaretestinghelp.com. n.d. Types Of Software Testing: Different Testing Types With Details. [online] Available at: <https://www.softwaretestinghelp.com/types-of-software-testing/> [Accessed 24 June 2020].