Avatar

Create database fixtures for unit testing: a Golang kata

← Back to list
Posted on 14.11.2023
Image by AI on Midjourney
Refill!

In today's brief kata I wanted to share my views on how fixtures for unit tests must be managed. Let's say I have an "authors" service, which contains a function GetBooksAndAuthor() I need to cover with a unit test:

👉 📃  internal/service/author/author.go
package author
import (
"context"
"fixtures/internal/database/author"
"fixtures/internal/database/book"
authorDomain "fixtures/internal/domain/author"
"fixtures/internal/interfaces"
)
type Service struct {
bookRepository interfaces.BookRepository
authorRepository interfaces.AuthorRepository
}
func New(bookRepository interfaces.BookRepository, authorRepository interfaces.AuthorRepository) *Service {
return &Service{
bookRepository: bookRepository,
authorRepository: authorRepository,
}
}
func (s *Service) GetBooksAndAuthor(ctx context.Context, authorID string) (result *authorDomain.GetBooksAndAuthorResult, err error) {
result = &authorDomain.GetBooksAndAuthorResult{}
books, err := s.bookRepository.List(&book.ListParameters{
Filter: &book.ListParametersFilter{
AuthorID: &authorID,
},
})
if err != nil {
return nil, err
}
authors, err := s.authorRepository.List(&author.ListParameters{
Filter: &author.ListParametersFilter{
ID: &authorID,
},
})
if err != nil {
return nil, err
}
if len(authors) > 0 {
result.Author = authors[0].ToDomain()
}
if len(books) > 0 {
for _, dbBook := range books {
result.Books = append(result.Books, dbBook.ToDomain())
}
}
return result, nil
}
The code is licensed under the MIT license

Before running the test itself, I need to bring the database to a certain initial state: create an author and three books. I find it useful to split this functionality onto two domains: random entry creation and dumping the entries to the database.

It's inefficient two domains together into one big class (as I've seen some projects doing), because in this case the flexibility suffers, and also it becomes impossible to re-use fixture creation in other places, independently of the database. Also, it's not a good idea to mix them with the testing functionality either, as it limits their usage only to unit testing.

So, to cover the first domain I write a generator class, and for the second - the database writer.

The generator

As was mentioned above, the generator's sole purpose is to return random structures. In order to generate random fields I used the gofakeit package.

👉 📃  internal/generator/generator.go
package generator
import (
databaseAuthor "fixtures/internal/database/author"
databaseBook "fixtures/internal/database/book"
"github.com/brianvoe/gofakeit/v6"
"github.com/google/uuid"
)
func New() *Tool {
return &Tool{}
}
type Tool struct {
}
func (t *Tool) CreateUUID() uuid.UUID {
return uuid.New()
}
func (t *Tool) CreateBook() *databaseBook.Book {
return &databaseBook.Book{
ID: t.CreateUUID(),
Title: gofakeit.BookTitle(),
AuthorID: t.CreateUUID(),
IssueYear: int32(gofakeit.Year()),
}
}
func (t *Tool) CreateAuthor() *databaseAuthor.Author {
return &databaseAuthor.Author{
ID: t.CreateUUID(),
Name: gofakeit.Name(),
HasBooks: false,
}
}
The code is licensed under the MIT license

The database writer

The writer takes what was generated and appends to a slice internally. At the very end the records are actually dumped to the database one by one.

👉 📃  internal/database_writer/database_writer.go
package database_writer
import (
databaseAuthor "fixtures/internal/database/author"
databaseBook "fixtures/internal/database/book"
"gorm.io/gorm"
)
type Writer struct {
session *gorm.DB
books []*databaseBook.Book
authors []*databaseAuthor.Author
}
func New(session *gorm.DB) *Writer {
writer := &Writer{
session: session,
}
writer.resetArrays()
return writer
}
func (w *Writer) AddBook(book *databaseBook.Book) {
w.books = append(w.books, book)
}
func (w *Writer) AddAuthor(author *databaseAuthor.Author) {
w.authors = append(w.authors, author)
}
func (w *Writer) Dump() error {
for _, author := range w.authors {
w.session.Create(author)
}
for _, book := range w.books {
w.session.Create(book)
}
return nil
}
func (w *Writer) Reset() {
w.resetArrays()
}
func (w *Writer) resetArrays() {
w.books = make([]*databaseBook.Book, 0)
w.authors = make([]*databaseAuthor.Author, 0)
}
The code is licensed under the MIT license

The unit tests

Eventually, the test looks like the following:

👉 📃  internal/service/author/author_test.go
package author_test
import (
"context"
"os"
"testing"
databaseWriter "fixtures/internal/database_writer"
authorDomain "fixtures/internal/domain/author"
"fixtures/internal/generator"
authorRepository "fixtures/internal/repository/author"
bookRepository "fixtures/internal/repository/book"
authorService "fixtures/internal/service/author"
"fixtures/internal/util/db"
"github.com/stretchr/testify/assert"
"gorm.io/gorm"
)
var (
ctx context.Context
session *gorm.DB
)
func TestMain(m *testing.M) {
var err error
session, err = db.Connect()
if err != nil {
panic(err)
}
ctx = context.Background()
execCode := m.Run()
os.Exit(execCode)
}
func TestGetBooksAndAuthor(t *testing.T) {
type setup struct {
authorID string
}
type verify struct {
err error
result *authorDomain.GetBooksAndAuthorResult
}
booksRepo := &bookRepository.Repository{
Session: session,
}
authorsRepo := &authorRepository.Repository{
Session: session,
}
authorSvc := authorService.New(booksRepo, authorsRepo)
gen := generator.New()
writer := databaseWriter.New(session)
type setupFunc func(t *testing.T) *setup
type verifyFunc func(t *testing.T, setup *setup, verify *verify)
testCases := map[string]struct {
setupFunc setupFunc
verifyFunc verifyFunc
}{
"Should return a result": {
setupFunc: func(t *testing.T) *setup {
writer.Reset()
author1 := gen.CreateAuthor()
book1 := gen.CreateBook()
book1.AuthorID = author1.ID
book2 := gen.CreateBook()
book2.AuthorID = author1.ID
book3 := gen.CreateBook()
book3.AuthorID = author1.ID
writer.AddAuthor(author1)
writer.AddBook(book1)
writer.AddBook(book2)
writer.AddBook(book3)
err := writer.Dump()
assert.NoError(t, err)
return &setup{
authorID: author1.ID.String(),
}
},
verifyFunc: func(t *testing.T, setup *setup, verify *verify) {
assert.NoError(t, verify.err)
assert.Equal(t, setup.authorID, verify.result.Author.ID)
assert.Equal(t, 3, len(verify.result.Books))
},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
setup := tc.setupFunc(t)
result, err := authorSvc.GetBooksAndAuthor(ctx, setup.authorID)
tc.verifyFunc(t, setup, &verify{
err: err,
result: result,
})
})
}
}
The code is licensed under the MIT license

Some notable points here:

  • The writer is reset before every test at the beginning of the setup() function. It's possible to have a dedicated writer per a test case.
  • The entries are generated, linked to each other and later submitted for creation.
  • At the very end of the setup() function the Dump() method is called, and the error properly checked.
  • Since the IDs of the entries are random, there is no need to clean the database before each test. Thus, such tests could be executed in parallel, as long as the verify() function is properly coded, as the tests don't share any common state.

And that's it. As usual, the code is here. Enjoy.


Avatar

Sergei Gannochenko

Business-oriented fullstack engineer, in ❤️ with Tech.
Golang, React, TypeScript, Docker, AWS, Jamstack.
15+ years in dev.