Membuat Pengujian dengan Basis Data Terisolasi
Latar Belakang
Sebelumnya, saya jarang sekali menulis tes untuk kode yang saya buat, sampai di kantor mulai diwajibkan untuk menulis tes. Sejak itu, menulis tes menjadi kebiasaan baru. Dari beragam pendekatan tes, salah satu preferensi saya adalah menggunakan basis data "asli" daripada mock. Alasannya sebenarnya sederhana:
- Mayoritas query adalah query CRUD pada umumnya, bukan query stored procedure, trigger, dll.
- Basis data sudah terpasang di mesin saya.
- Lebih mudah untuk melakukan tes langsung di basis data daripada melakukan akrobat dengan mock.
Bukan berarti saya anti dengan mock, tapi jika memungkinkan dan mudah dilakukan tanpa mock, saya lebih memilih tanpa mock.
Pengujian menggunakan basis data secara langsung memiliki beberapa keuntungan, tetapi tentu saja juga memiliki kekurangan. Kekurangan yang saya maksud adalah kecenderungan menghasilkan tes yang bisa gagal tiba-tiba secara acak. Contohnya, jika kita memiliki struktur kode dan skenario seperti ini:
Kode yang saya tulis sebagian hanyalah pseudo-code atau bukan kode sesungguhnya, hanya untuk menggambarkan konteks agar lebih jelas.
Misalnya, kita memiliki dua layer:
- Storage/repository/model, yang bertugas untuk berkomunikasi dengan basis data.
- Service, yang memanfaatkan layer storage.
//storage.go
func (s *Storage) InsertUser(ctx context.Context, fullname string, username string, email string, password string) (int, error) {
var id int
currentTime := time.Now()
err := s.pool.QueryRow(ctx,
`INSERT INTO users (fullname, username, email, password_hash, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`,
fullname, username, email, password, currentTime, currentTime).Scan(&id)
return id, err
}
//service.go
func (s *Service) Create(ctx context.Context, in *lib.PayloadIn) *lib.RespOut {
resp := lib.RespOut{}
// validation
tx, err := s.storage.BeginTxWriter(ctx)
if err != nil {
log.Error(in.Trace).Err(err).Msg("Failed begin tx")
return &resp
}
defer tx.Rollback(ctx)
insertedId, err := s.storage.InsertUser(ctx,
in.Fullname, in.Username, in.Email, hashedPassword)
if err != nil {
log.Error(in.Trace).Err(err).Msg("failed insert user")
return &resp
}
err = tx.Commit(ctx)
if err != nil {
log.Error(in.Trace).Err(err).Msg("failed to commit")
return &resp
}
resp.Success = true
resp.InsertedId = insertedId
return &resp
}
Berdasarkan dua fungsi tersebut, saya membuat dua tes: satu untuk storage dan satu untuk service.
//storage_test.go
func TestInsertUser(t *testing.T) {
t.Run("insert and get data", func(t *testing.T) {
storage, err := userStorage.BeginTxWriter(con.Context)
assert.Nil(t, err)
defer storage.Rollback(con.Context)
initial, err := userStorage.GetAllUser(con.Context)
assert.Nil(t, err)
expected := &data.User{
Fullname: "User Test",
Username: "testuser",
Email: "[email protected]",
Password: "hashedpassword1",
}
id, err := userStorage.InsertUser(con.Context, expected.Fullname, expected.Username, expected.Email, expected.Password)
...
})
}
//service_test.go
func TestServiceCreate(t *testing.T) {
t.Parallel()
...
scenarios := []testCase{
{
name: "successfully create user",
input: input{
in: &lib.PayloadIn{
Trace: trace,
Fullname: "testing",
Username: "apaan",
Email: "[email protected]",
Password: "securelahpassword",
},
},
expected: expected{
success: true,
},
},
// fail scenario
{
name: "fullname empty",
input: input{
in: &lib.PayloadIn{
Trace: trace,
Fullname: "",
Username: "apaan",
Email: "[email protected]",
Password: "securelahpassword",
},
},
expected: expected{
success: false,
errMsg: "fullname wajib diisi.",
},
},
}
service = NewService(userStorage)
runTest := func(t *testing.T, sc testCase, svc lib.ServiceInterface) {
t.Helper()
result := svc.Create(con.Context, sc.input.in)
assert.Equal(t, sc.expected.success, result.Success)
assert.Equal(t, sc.expected.errMsg, result.Message)
...
}
// Run scenarios
for _, sc := range scenarios {
sc := sc // Capture range variable
t.Run(sc.name, func(t *testing.T) {
t.Parallel()
runTest(t, sc, service)
})
}
}
Saat menggunakan basis data secara langsung, kode di atas memiliki kemungkinan besar untuk rusak, terutama jika menggunakan parameter yang sama. Misalnya, jika di storage test kita menggunakan email [email protected]
, lalu di service test ada yang membuat user dengan email serupa, salah satu tes akan gagal karena terkena validasi unique constraint pada tabel user.
Pendekatan Solusi
Dari masalah tersebut, sebenarnya solusinya sederhana:
- Sebelum tes, kita harus memastikan bahwa kondisi awal basid data berada dalam kondisi yang "diketahui".
- Setelah test, kita juga harus memastikan bahwa kondisi database kembali ke kondisi awal.
Pendekatan Pertama
Pendekatan pertama yang saya lakukan adalah membuat beberapa tahap dalam setiap tes seperti berikut:
func TestInsert(t *testing.T) {
// prepare connection
// fixture
// run test
// rollback/reset
}
Namun, cara ini tidak selalu berjalan sempurna, terutama jika tes dijalankan secara bersamaan. Lalu, saya menemukan sebuah blog dengan pendekatan yang cukup sederhana dan konsisten memenuhi dua kriteria yang saya tulis.
mtekmir.com/blog/golang-sql-integration-test-isolation/
Pendekatan Kedua
Pendekatan kedua ini cukup menarik dan sederhana. Karena saya menggunakan PostgreSQL, yang mendukung banyak schema, pendekatan yang digunakan dalam cara kedua ini adalah sebagai berikut:
- Menyambungkan ke basis data tes.
- Membuat schema baru berdasarkan testName.
- Menjalankan semua berkas SQL di dalam schema tersebut.
- Memastikan semua koneksi yang menggunakan setup ini menggunakan schema yang tepat.
Jadi, contoh kode test seperti berikut:
//storage_test.go
func TestInsertUser(t *testing.T) {
con := test.DbTestPool(t) // setup database testing
...
}
func TestServiceCreate(t *testing.T) {
con := test.DbTestPool(t) // setup database testing
...
}
Kode di atas akan menghasilkan:
TestInsertUser
akan menggunakan schema"insertuser"
.TestServiceCreate
akan menggunakan schema"servicecreate"
.
Karena kedua test akhirnya terisolasi di masing-masing schema, semua test pasti akan memiliki kondisi database yang diketahui.
Kesimpulan
Dengan pendekatan ini, setiap test memiliki isolasi yang baik tanpa mengganggu tes lainnya, serta memastikan kondisi basis data tetap konsisten sebelum dan sesudah test berjalan.
Terima kasih telah membaca sampai sini! Berikut adalah kode lengkap untuk mendapatkan hasil yang diinginkan:
func DbTestPool(t *testing.T) *DbTestSuite {
t.Helper()
ctx := context.Background()
//Normalize t.Name()
schemaName := strings.ToLower(strings.Replace(t.Name(), "/", "_", -1))
connectionString := "postgresql://postgres:localdb123@localhost/test_db_1?sslmode=disable"
poolConfig, err := pgxpool.ParseConfig(connectionString)
if err != nil {
t.Fatalf("failed to parse config: %v", err)
}
// Set up BeforeAcquire before creating the pool
// this part make sure for every connection always use the correct schema
poolConfig.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool {
// Set search_path for every new connection
_, err := conn.Exec(ctx, fmt.Sprintf("SET search_path TO %s;", schemaName))
if err != nil {
t.Fatalf("set schema failed. err: %v", err)
}
return true
}
pool, err := pgxpool.ConnectConfig(ctx, poolConfig)
if err != nil {
t.Fatalf("failed to connect to database: %v", err)
}
// Make sure every test using dbtest suite always close the pool & drop the schema
t.Cleanup(func() {
_, err = pool.Exec(ctx, "DROP SCHEMA "+schemaName+" CASCADE")
if err != nil {
t.Fatalf("db cleanup failed. err: %v", err)
}
pool.Close()
})
// create the schema
_, err = pool.Exec(ctx, "CREATE SCHEMA "+schemaName)
if err != nil {
t.Fatalf("schema creation failed. err: %v", err)
}
// use schema
query := fmt.Sprintf("SET search_path TO %s;", schemaName)
_, err = pool.Exec(ctx, query)
if err != nil {
t.Fatalf("error while switching to schema. err: %v", err)
}
// populate the table
schemaDir := "../../schema" // Adjust path if needed
files, err := filepath.Glob(filepath.Join(schemaDir, "*.sql"))
if err != nil {
t.Fatalf("failed to read schema directory: %v", err)
}
if len(files) == 0 {
t.Fatalf("no SQL files found in schema directory")
}
for _, schemaPath := range files {
file, err := os.ReadFile(schemaPath)
if err != nil {
t.Fatalf("error reading file %s: %v", schemaPath, err)
}
_, err = pool.Exec(ctx, string(file))
if err != nil {
fmt.Printf("error executing %s: %v\n", schemaPath, err)
t.Fatal("Failed to execute file")
} else {
fmt.Printf("executed %s successfully\n", schemaPath)
}
}
return &DbTestSuite{
Pool: pool,
Context: ctx,
}
}