When writing integration tests that run against your database, fixtures are an extremely powerful way to populate the database with sane defaults amongst others. As an example, you would not want to manually recreate and populate 10 different tables just to run one single test.

Take this as an example, you are building a SaaS with users and each user can belong to a workspace and items created by users can only be assigned to workspaces. So you have this dependency where to create an item, you have to create a user but to create a user, a workspace must be existing already.

The above scenario can get cumbersome and repetitive and can lead to tests not being written. When its hard to get tests going, it becomes hard for devs to write them.

What are fixtures

Fixtures are essentially YAML files you use to provide and describe the _shape of your data. This data and file will ultimately be processed by your test process and write that into your database.

Creating a fixture

I like to put them in the testdata/fixtures path but you can put it anywhere really. The next step is to take a look at your database table and map it into yaml format. As an example, most SaaS products will include a plans table that can be modeled as

type Plan struct {
	ID uuid.UUID

	PlanName string

	Reference      string
	Metadata       PlanMetadata
	DefaultPriceID string
	Amount         int64

	CreatedAt time.Time
	UpdatedAt time.Time
	DeletedAt *time.Time
}

You can then map this into your YAML file as below:

---
- id: 4590c0bb-7dc1-4ec0-abef-61eb305059d1
  reference: prod_QmtErtydaJZymT
  plan_name: Core
  default_price_id: price_1PvJSMIuzgc0GUapiNyXBEaH
  amount: 4000
  metadata: "{}"
  created_at: "2024-09-04 13:46:35.612449+00"
  updated_at: "2024-09-04 13:46:35.612449+00"
  deleted_at: null
- id: 4ef53bbf-c660-4cf6-b737-6add758343df
  reference: prod_QmtFLR9JvXLryD
  plan_name: Scale
  default_price_id: price_1PvJT8Iuzgc0GUapIRBpxCyJ
  amount: 9900
  metadata: "{}"
  created_at: "2024-09-04 13:46:35.612449+00"
  updated_at: "2024-09-04 13:46:35.612449+00"
  deleted_at: null

In the example above, i have described and created 2 plans - Core and Scale.

Loading this into your database

package postgres

import (
	"database/sql"
	"fmt"
	"testing"

	testfixtures "github.com/go-testfixtures/testfixtures/v3"
	"github.com/golang-migrate/migrate/v4"
	"github.com/golang-migrate/migrate/v4/database/postgres"
	_ "github.com/golang-migrate/migrate/v4/source/file"
	"github.com/stretchr/testify/require"
)

func prepareTestDatabase(t *testing.T, dsn string) {
	t.Helper()

	var err error

	db, err := sql.Open("postgres", dsn)
	require.NoError(t, err)

	require.NoError(t, db.Ping())

	driver, err := postgres.WithInstance(db, &postgres.Config{})
	require.NoError(t, err)

	// Run your migrations to create the tables first
	migrator, err := migrate.NewWithDatabaseInstance(
		fmt.Sprintf("file://%s", "migrations"), "postgres", driver)
	require.NoError(t, err)

	if err := migrator.Up(); err != nil && err != migrate.ErrNoChange {
		t.Fatal(err)
	}

	fixtures, err := testfixtures.New(
		testfixtures.Database(db),
		testfixtures.Dialect("postgres"),
		testfixtures.Directory("testdata/fixtures"),
	)
	require.NoError(t, err)

	err = fixtures.Load()
	require.NoError(t, err)
}

I like to run this as a setup on every test-case.