1. Code
  2. Coding Fundamentals
  3. Testing

Testing Data-Intensive Code With Go, Part 3

Scroll to top
8 min read
This post is part of a series called Testing Data-Intensive Code with Go.

Overview

This is part three out of five in a tutorial series on testing data-intensive code with Go. In part two, I covered testing against a real in-memory data layer based on the popular SQLite. In this tutorial, I'll go over testing against a local complex data layer that includes a relational DB and a Redis cache.

Testing Against a Local Data Layer

Testing against an in-memory data layer is awesome. The tests are lightning fast, and you have full control. But sometimes you need to be closer to the actual configuration of your production data layer. Here are some possible reasons:

  • You use specific details of your relational DB that you want to test.
  • Your data layer consists of several interacting data stores.
  • The code under test consists of several processes accessing the same data layer.
  • You want to prepare or observe your test data using standard tools.
  • You don't want to implement a dedicated in-memory data layer if your data layer is in flux.
  • You just want to know that you're testing against your actual data layer.
  • You need to test with a lot of data that doesn't fit in memory.

I'm sure there are other reasons, but you can see why just using an in-memory data layer for testing may not be enough in many cases.

OK. So we want to test an actual data layer. But we still want to be as lightweight and agile as possible. That means a local data layer. Here are the benefits:

  • No need to provision and configure anything in the data center or the cloud.
  • No need to worry about our tests corrupting the production data by accident.
  • No need to coordinate with fellow developers in a shared test environment. 
  • No slowness over the network calls.
  • Full control over the content of the data layer, with the ability to start from scratch any time.  

In this tutorial we'll up the ante. We'll implement (very partially) a hybrid data layer that consists of a MariaDB relational DB and a Redis server. Then we will use Docker to stand up a local data layer we can use in our tests. 

Using Docker to Avoid Installation Headaches

First, you need Docker, of course. Check out the documentation if you're not familiar with Docker. The next step is to get images for our data stores: MariaDB and Redis. Without getting into too much detail, MariaDB is a great relational DB compatible with MySQL, and Redis is a great in-memory key-value store (and much more). 

1
> docker pull mariadb
2
...
3
4
> docker pull redis
5
...
6
7
> docker images
8
REPOSITORY      TAG      IMAGE ID      CREATED      SIZE
9
mariadb         latest   51d6a5e69fa7  2 weeks ago  402MB
10
redis           latest   b6dddb991dfa  2 weeks ago  107MB

Now that we have Docker installed and we have the images for MariaDB and Redis, we can write a docker-compose.yml file, which we'll use to launch our data stores. Let's call our DB "songify".

1
mariadb-songify:
2
  image: mariadb:latest
3
  command: >
4
      --general-log 
5
      --general-log-file=/var/log/mysql/query.log
6
  expose:
7
    - "3306"
8
  ports:
9
    - "3306:3306"
10
  environment:
11
    MYSQL_DATABASE: "songify"
12
    MYSQL_ALLOW_EMPTY_PASSWORD: "true"
13
  volumes_from:
14
    - mariadb-data
15
mariadb-data:
16
  image: mariadb:latest
17
  volumes:
18
    - /var/lib/mysql
19
  entrypoint: /bin/bash
20
21
redis:
22
  image: redis
23
  expose:
24
    - "6379"
25
  ports:
26
    - "6379:6379"

You can launch your data stores with the docker-compose up command (similar to vagrant up). The output should look like this: 

1
> docker-compose up
2
Starting hybridtest_redis_1 ...
3
Starting hybridtest_mariadb-data_1 ...
4
Starting hybridtest_redis_1
5
Starting hybridtest_mariadb-data_1 ... done
6
Starting hybridtest_mariadb-songify_1 ...
7
Starting hybridtest_mariadb-songify_1 ... done
8
Attaching to hybridtest_mariadb-data_1, 
9
             hybridtest_redis_1, 
10
             hybridtest_mariadb-songify_1
11
.
12
.
13
.
14
redis_1  | * DB loaded from disk: 0.002 seconds
15
redis_1  | * Ready to accept connections
16
.
17
.
18
.
19
mariadb-songify_1  | [Note] mysqld: ready for connections.
20
.
21
.
22
.

At this point, you have a full-fledged MariaDB server listening on port 3306 and a Redis server listening on port 6379 (both are the standard ports).

The Hybrid Data Layer

Let's take advantage of these powerful data stores and upgrade our data layer to a hybrid data layer that caches songs per user in Redis. When GetSongsByUser() is called, the data layer will first check if Redis already stores the songs for the user. If it does then just return the songs from Redis, but if it doesn't (cache miss) then it will fetch the songs from MariaDB and populate the Redis cache, so it's ready for the next time. 

Here is the struct and constructor definition. The struct keeps a DB handle like before and also a redis client. The constructor connects to the relational DB as well as to Redis. It creates the schema and flushes redis only if the corresponding parameters are true, which is needed only for testing. In production, you create the schema once (ignoring schema migrations).

1
type HybridDataLayer struct {
2
    db *sql.DB
3
	redis *redis.Client
4
}
5
6
func NewHybridDataLayer(dbHost string, 
7
                        dbPort int, 
8
                        redisHost string, 
9
                        createSchema bool, 
10
                        clearRedis bool) (*HybridDataLayer, 
11
                                          error) {
12
	dsn := fmt.Sprintf("root@tcp(%s:%d)/", dbHost, dbPort)
13
	if createSchema {
14
		err := createMariaDBSchema(dsn)
15
		if err != nil {
16
			return nil, err
17
		}
18
	}
19
20
	db, err := sql.Open("mysql", 
21
                         dsn+"desongcious?parseTime=true")
22
	if err != nil {
23
		return nil, err
24
	}
25
26
	redisClient := redis.NewClient(&redis.Options{
27
		Addr:     redisHost + ":6379",
28
		Password: "",
29
		DB:       0,
30
	})
31
32
	_, err = redisClient.Ping().Result()
33
	if err != nil {
34
		return nil, err
35
	}
36
37
	if clearRedis {
38
		redisClient.FlushDB()
39
	}
40
41
	return &HybridDataLayer{db, redisClient}, nil
42
}

Using MariaDB

MariaDB and SQLite are a little different as far as DDL goes. The differences are small, but important. Go doesn't have a mature cross-DB toolkit like Python's fantastic SQLAlchemy, so you have to manage it yourself (no, Gorm doesn't count). The main differences are:

  • The SQL driver is "github.com/go-sql-driver/mysql".
  • The database doesn't live in memory, so it is recreated every time (drop and create). 
  • The schema must be a slice of independent DDL statements instead of one string of all statements.
  • The auto incrementing primary keys are marked by AUTO_INCREMENT.
  • VARCHAR instead of TEXT.

Here is the code:

1
func createMariaDBSchema(dsn string) error {
2
    db, err := sql.Open("mysql", dsn)
3
	if err != nil {
4
		return err
5
	}
6
7
	// Recreate DB

8
	commands := []string{
9
		"DROP DATABASE songify;",
10
		"CREATE DATABASE songify;",
11
	}
12
	for _, s := range (commands) {
13
		_, err = db.Exec(s)
14
		if err != nil {
15
			return err
16
		}
17
	}
18
19
	// Create schema

20
	db, err = sql.Open("mysql", dsn+"songify?parseTime=true")
21
	if err != nil {
22
		return err
23
	}
24
25
	schema := []string{
26
		`CREATE TABLE IF NOT EXISTS song (
27
		  id          INTEGER PRIMARY KEY AUTO_INCREMENT,
28
		  url         VARCHAR(2088) UNIQUE,
29
		  title       VARCHAR(100),
30
		  description VARCHAR(500)
31
		);`,
32
		`CREATE TABLE IF NOT EXISTS user (
33
		  id            INTEGER PRIMARY KEY AUTO_INCREMENT,
34
		  name          VARCHAR(100),
35
		  email         VARCHAR(100) UNIQUE,
36
		  registered_at TIMESTAMP,
37
		  last_login    TIMESTAMP
38
		);`,
39
		"CREATE INDEX user_email_idx  ON user (email);",
40
		`CREATE TABLE IF NOT EXISTS label (
41
		  id   INTEGER PRIMARY KEY AUTO_INCREMENT,
42
		  name VARCHAR(100) UNIQUE
43
		);`,
44
		"CREATE INDEX label_name_idx ON label (name);",
45
		`CREATE TABLE IF NOT EXISTS label_song (
46
		  label_id  INTEGER NOT NULL REFERENCES label (id),
47
		  song_id INTEGER NOT NULL REFERENCES song (id),
48
		  PRIMARY KEY (label_id, song_id)
49
		);`,
50
		`CREATE TABLE IF NOT EXISTS user_song (
51
		  user_id INTEGER NOT NULL REFERENCES user (id),
52
		  song_id INTEGER NOT NULL REFERENCES song (id),
53
		  PRIMARY KEY (user_id, song_id)
54
		);`,
55
	}
56
57
	for _, s := range (schema) {
58
		_, err = db.Exec(s)
59
		if err != nil {
60
			return err
61
		}
62
	}
63
	return nil
64
}

Using Redis

Redis is very easy to use from Go. The "github.com/go-redis/redis" client library is very intuitive and faithfully follows the Redis commands. For example, to test if a key exists, you just use the Exits() method of the redis client, which accepts one or more keys and returns how many of them exist. 

In this case, I check for one key only:

1
    count, err := m.redis.Exists(email).Result()
2
	if err != nil {
3
		return err
4
	}

Testing Access to Multiple Data Stores

The tests are actually identical. The interface didn't change, and the behavior didn't change. The only change is that the implementation now keeps a cache in Redis. The GetSongsByEmail() method now just calls refreshUser_Redis().

1
func (m *HybridDataLayer) GetSongsByUser(u User) (songs []Song, 
2
                                                  err error) {
3
    err = m.refreshUser_Redis(u.Email, &songs)
4
	return
5
}

The refreshUser_Redis() method returns the user songs from Redis if they exist and otherwise fetches them from MariaDB.

1
type Songs *[]Song
2
3
func (m *HybridDataLayer) refreshUser_Redis(email string, 
4
                                            out Songs) error {
5
    count, err := m.redis.Exists(email).Result()
6
	if err != nil {
7
		return err
8
	}
9
10
	if count == 0 {
11
		err = m.getSongsByUser_DB(email, out)
12
		if err != nil {
13
			return err
14
		}
15
16
		for _, song := range *out {
17
			s, err := serializeSong(song)
18
			if err != nil {
19
				return err
20
			}
21
22
			_, err = m.redis.SAdd(email, s).Result()
23
			if err != nil {
24
				return err
25
			}
26
		}
27
		return
28
	}
29
30
	members, err := m.redis.SMembers(email).Result()
31
	for _, member := range members {
32
		song, err := deserializeSong([]byte(member))
33
		if err != nil {
34
			return err
35
		}
36
		*out = append(*out, song)
37
	}
38
39
	return out, nil
40
}

There is a slight problem here from a testing methodology point of view. When we test through the abstract data layer interface, we have no visibility into the data layer implementation.

For example, it's possible that there is a big flaw where the data layer completely skips the cache and always fetches the data from the DB. The tests will pass, but we don't get to benefit from the cache. I'll talk in part five about testing your cache, which is very important.  

Conclusion

In this tutorial, we covered testing against a local complex data layer that consists of multiple data stores (a relational DB and a Redis cache). We also utilized Docker to easily deploy multiple data stores for testing.

In part four, we will focus on testing against remote data stores, using snapshots of production data for our tests, and also generating our own test data. Stay tuned!

Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Code tutorials. Never miss out on learning about the next big thing.
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.