One of the approaches to do integration tests with Elasticsearch is to use testcontainers. It is recommended by Elastic, it works well and provides a high level of isolation between tests. But, it comes with drawbacks: Elasticsearch containers are heavy and take time to start.
What if we use a different kind of isolation? The index seems pretty isolated. Instead of starting a new container for each test, we can just start one container and create a new index for each test case.
It is pretty simple to implement. Let’s go through it.
A Bookstore
We want to create a storage for books on top of Elasticsearch and want to index and search books by title and author. Here are the mappings for the index:
{
"properties": {
"title": {
"type": "text"
},
"author": {
"type": "text"
}
}
}
And the full code for the storage is in storage.go (it is too long to include here in full).
Setting up Elasticsearch
Assume we have a running Elasticsearch instance in a container. You can use the docker-compose.yml for starters.
What I like to do is to have test helpers that abstract away the details of setting up infrastructure for tests.
We will do just that. We will create a storagetest package with a helper to create a new storage with a unique index for each test.
We need to pass the Elasticsearch address to communicate with it. The simplest way is to use an environment variable:
address := os.Getenv("ELASTICSEARCH_ADDRESS")
The next step is to create a unique index name for each test. There are some rules for the index name, and we will do our best to follow them: take the test name, lowercase it, remove unsupported characters, and add a random suffix, so the index name is unique.
func newIndexName(t *testing.T) string
func newIndexName(t *testing.T) string {
name := strings.ToLower(t.Name())
if len(name) > 247 { // 247 = 255 (max 255 bytes index name) - 8 (random suffix) - 1 (underscore)
name = name[:247]
}
mapper := func(r rune) rune {
// allow only [a-z0-9_]
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' {
return r
}
return '_'
}
name = strings.Map(mapper, name)
return name + "_" + randString()
}
Then, we create a new index and apply a mapping to it:
response, err := client.Indices.Create(indexName)
...
response, err := client.Indices.PutMapping(
[]string{indexName},
strings.NewReader(storage.Mappings),
)
...
Here we are using embed to embed the mapping file into the binary. But you can read it from anywhere else, like a local file if it is in the same repo or from a remote git repository.
The most useful part comes next: we delete the index only if the test passed. If it failed, we can inspect the index and see what went wrong. We’ll see it in action later.
t.Cleanup(func() {
if t.Failed() {
t.Logf("test failed, keeping index %s for inspection", indexName)
return
}
deleteIndex(t, client, indexName)
})
And that’s pretty much it. Here is the full code for the helper in storagetest/storage.go.
Writing tests
The test itself looks short and sweet, all with the help of our helper. Store a document and search for it:
func TestStorage(t *testing.T) {
store := storagetest.NewBookstore(t)
book := storage.Book{
Title: "The Great Gatsby",
Author: "F. Scott Fitzgerald",
}
if err := store.IndexBook(context.TODO(), book); err != nil {
t.Fatalf("index document: %s", err)
}
books, err := store.Search(context.TODO(), "Gatsby")
if err != nil {
t.Fatalf("search: %s", err)
}
if len(books) != 1 {
t.Fatalf("expected 1 book, got %d", len(books))
}
if books[0] != book {
t.Fatalf("expected %+v, got %+v", book, books[0])
}
}
The full code is available in storage_test.go.
Run the test with the Elasticsearch address set:
ELASTICSEARCH_ADDRESS=http://localhost:9200 go test ./...
Inspecting failed tests
If you change the search query to something that does not match the indexed document:
store.Search(context.TODO(), "Batman")
You will see a helpful log message with the index name to inspect:
--- FAIL: TestStorage (0.43s)
storage_test.go:12: using index: teststorage_023f73bc409aa215
...
And you are free to explore why the test failed, for example, to see what is inside the index:
$ curl -s \
-X POST http://localhost:9200/teststorage_023f73bc409aa215/_search \
-H "Content-Type: application/json" \
-d '{"query": {"match_all": {}}}' \
| jq
{
"took": 1,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 1,
"relation": "eq"
},
"max_score": 1.0,
"hits": [
{
"_index": "teststorage_023f73bc409aa215",
"_id": "zDLOA5oBrce7jb_Hwkex",
"_score": 1.0,
"_source": {
"title": "The Great Gatsby",
"author": "F. Scott Fitzgerald"
}
}
]
}
}
The full source code is available on GitHub