Create database fixtures for unit testing: a Golang kata
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:
package authorimport ("context""fixtures/internal/database/author""fixtures/internal/database/book"authorDomain "fixtures/internal/domain/author""fixtures/internal/interfaces")type Service struct {bookRepository interfaces.BookRepositoryauthorRepository 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}
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.
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.
package generatorimport (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 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.
package database_writerimport (databaseAuthor "fixtures/internal/database/author"databaseBook "fixtures/internal/database/book""gorm.io/gorm")type Writer struct {session *gorm.DBbooks []*databaseBook.Bookauthors []*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) AddBooks(books ...*databaseBook.Book) *Writer {for _, books := range books {w.AddBook(books)}return w}func (w *Writer) AddAuthor(author *databaseAuthor.Author) {w.authors = append(w.authors, author)}func (w *Writer) AddAuthors(authors ...*databaseAuthor.Author) *Writer {for _, author := range authors {w.AddAuthor(author)}return w}func (w *Writer) Submit() 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() *Writer {w.resetArrays()return w}func (w *Writer) resetArrays() {w.books = make([]*databaseBook.Book, 0)w.authors = make([]*databaseAuthor.Author, 0)}
Eventually, the test looks like the following:
package author_testimport ("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.Contextsession *gorm.DB)func TestMain(m *testing.M) {var err errorsession, 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 errorresult *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) *setuptype verifyFunc func(t *testing.T, setup *setup, verify *verify)testCases := map[string]struct {setupFunc setupFuncverifyFunc verifyFunc}{"Should return a result": {setupFunc: func(t *testing.T) *setup {author1 := gen.CreateAuthor()book1 := gen.CreateBook()book1.AuthorID = author1.IDbook2 := gen.CreateBook()book2.AuthorID = author1.IDbook3 := gen.CreateBook()book3.AuthorID = author1.IDassert.NoError(t,writer.Reset().AddAuthors(author1).AddBooks(book1, book2, book3).Submit(),)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,})})}}
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 Submit() 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.
Sergei Gannochenko
Golang, React, TypeScript, Docker, AWS, Jamstack.
19+ years in dev.