Skip to main content

ประยุกต์ใช้กับ Gorm

Unit Test กับ Gorm

นอกเหนือจาก Web server แล้ว ใน Web application เองต้องจัดการกับ database ด้วยเช่นกัน ซึ่งจากหัวข้อที่เราทำกันก่อนหน้านี้ เราจะใช้ GORM ที่เป็น ORM สำหรับการคุยกับ Database

ดังนั้น ในการทำ Test เองก็ควรต้องทำกับ Database ด้วยเช่นเดียวกัน เพื่อให้มั่นใจได้ว่า function ที่จัดการกับ Database สามารถจัดการได้ตามที่เราคาดหวังแล้วหรือไม่

code Service GORM

เริ่มต้นกับ GORM เราจะลองสร้าง database table ตัวหนึ่งคือ User โดย

  • User ทำการเก็บ Fullname, Email, Age เอาไว้
  • โดย func addUser() ที่เรียกใช้ของ GORM กับ struct User นั้น เราจะเพิ่มไว้ 1 โจทย์ว่า Email ต้องห้ามซ้ำกันในระบบ
  • หากไม่ซ้ำกัน = add user ได้ปกติ
  • หากซ้ำกัน = เกิด error ออกมาและ add user ไม่ได้

เมื่อเรา implement service เป็น code ก็จะหน้าตาประมาณนี้ เริ่มต้นทำการลง package ตามนี้ก่อน (** ตัวอย่างนี้จะขอใช้ sqlite ก่อน)

go get -u gorm.io/gorm
go get -u gorm.io/driver/sqlite

และนี่คือ code main.go ที่เพิ่มตามโจทย์นั้น

main.go
package main

import (
"errors"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)

type User struct {
gorm.Model
Fullname string
Email string `gorm:"unique"`
Age int
}

// InitializeDB initializes the database and automigrates the User model.
func InitializeDB() *gorm.DB {
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
panic("Failed to connect to database")
}

db.AutoMigrate(&User{})
return db
}

// AddUser adds a new user to the database.
func AddUser(db *gorm.DB, fullname, email string, age int) error {
user := User{Fullname: fullname, Email: email, Age: age}

// Check if email already exists
var count int64
db.Model(&User{}).Where("email = ?", email).Count(&count)
if count > 0 {
return errors.New("email already exists")
}

// Save the new user
result := db.Create(&user)
return result.Error
}

func main() {
db := InitializeDB()
// Your application code
AddUser(db, "John Doe", "jane.doe@example.com", 30)
}

code unit test

เมื่อต้องเขียน Unit test ออกมาปกติจะมีด้วยกัน 2 Idea ใหญ่ๆที่มักจะทำกันคือ

  1. สร้าง DB mock ขึ้นมาใหม่และ run unit test ผ่าน DB mock นั้นแทน
  2. ทำการ Mock service ของ DB ตัวนั้นขึ้นมาใหม่

ซึ่งนี่คือตัวอย่างของวิธีที่ 1. สิ่งที่เราทำคือ

  • ทำการเพิ่ม setupTestDB() มา โดยเรียกใช้ไปยัง sqlite file::memory:?cache=shared เพื่อให้ใช้ in-memory ของ sqlite แทน
  • หลังจากนั้นทำการเพิ่ม Testcase เข้าไป 2 case คือ
  1. เคส success ปกติ
  2. เคส Email ซ้ำ

ก็จะได้ code unit test ประมาณนี้ออกมาได้

main_test.go
package main

import (
"fmt"
"testing"

"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)

func setupTestDB() *gorm.DB {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
panic(fmt.Sprintf("Failed to open database: %v", err))
}
db.AutoMigrate(&User{})
return db
}

func TestAddUser(t *testing.T) {
db := setupTestDB()

t.Run("successfully add user", func(t *testing.T) {
err := AddUser(db, "John Doe", "john.doe@example.com", 30)
assert.NoError(t, err)

var user User
db.First(&user, "email = ?", "john.doe@example.com")
assert.Equal(t, "John Doe", user.Fullname)
})

t.Run("fail to add user with existing email", func(t *testing.T) {
err := AddUser(db, "Jane Doe", "john.doe@example.com", 28)
assert.EqualError(t, err, "email already exists")
})
}

อีกวิธีหนึ่งคือการ Mock service Database ก่อนที่เราจะลอง เราจะลองเปลี่ยนมาใช้ postgreSQL เพื่อให้เห็นภาพการทำ unit test เพิ่มเติมว่า หากเราเปลี่ยน GORM ให้ไปใช้ database ตัวอื่นบ้าง จะต้องปรับอะไรใน unit test บ้างหรือไม่

กรณีกับ postgreSQL

ทีนี้ ลองเปลี่ยน driver มาเป็น postgreSQL ดู โดยเอา config จากหัวข้อที่ 3 ของ postgreSQL ใน GO API Essential มาใช้เลย

ก็จะได้ code ออกมาหน้าตาประมาณนี้ (ส่วน logic เหมือนเดิมหมด ปรับแค่ส่วน config ที่มีการเรียกใช้ database แค่นั้น)

main.go
package main

import (
"errors"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"fmt"
)

const (
host = "localhost" // or the Docker service name if running in another container
port = 5432 // default PostgreSQL port
user = "myuser" // as defined in docker-compose.yml
password = "mypassword" // as defined in docker-compose.yml
dbname = "mydatabase" // as defined in docker-compose.yml
)

type User struct {
gorm.Model
Fullname string
Email string `gorm:"unique"`
Age int
}

// InitializeDB initializes the database and automigrates the User model.
func InitializeDB() *gorm.DB {
// Configure your PostgreSQL database details here
dsn := fmt.Sprintf("host=%s port=%d user=%s "+
"password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
panic("failed to connect to database")
}
db.AutoMigrate(&User{})
return db
}

// AddUser adds a new user to the database.
func AddUser(db *gorm.DB, fullname, email string, age int) error {
user := User{Fullname: fullname, Email: email, Age: age}

// Check if email already exists
var count int64
db.Model(&User{}).Where("email = ?", email).Count(&count)
if count > 0 {
return errors.New("email already exists")
}

// Save the new user
result := db.Create(&user)
return result.Error
}

func main() {
db := InitializeDB()
// Your application code
AddUser(db, "John Doe", "jane.doe@example.com", 30)
}

ทีนี้ เมื่อเราลอง run unit test ดูเลย (โดยยังไม่ปรับ code เลย) เราจะค้นพบว่า

  • เราสามารถใช้ unit test จาก driver sqlite ตัวเดียวกันได้ (ทั้งๆที่ application เราเขียนด้วย postgres) นั้นก็เพราะว่า GORM มีคุณสมบัติในการจัดการ SQL เบื้องหลังผ่านคำสั่งของ GORM นั่นเอง
  • แต่ทีนี้ เพื่อป้องกัน feature ของ SQL ที่อาจจะไม่เหมือนกัน เราจะลองมาแชร์อีก 1 วิธีนั่นคือ 2. ทำการ Mock service ของ DB ตัวนั้นขึ้นมาใหม่

Unit test ด้วยวิธี mock repository service

Ref: https://www.codingexplorations.com/blog/testing-gorm-with-sqlmock

วิธีการคือ เราจะต้องอาศัย library การ mock ผลลัพธ์ SQL ออกมาแทนซึ่งในทีนี้เราจะใช้ตัว go-sqlmock ในการเพิ่มมา

https://github.com/DATA-DOG/go-sqlmock

สิ่งที่เราทำคือ

  • ทำการ mock คำสั่ง ของ SQL Query ตาม GORM โดย
    • mock db.Model(&User{}).Where("email = ?", email).Count(&count) = mock SELECT query
    • mock db.Create(&user) = mock INSERT query
  • เพื่อให้สามารถ test function ที่อยู่ภายใน AddUser() ได้ เพื่อให้เราสามารถ focus กับจุดที่เป็น logic จัดการของ AddUser() ออกมาได้
main_test.go
package main

import (
"regexp"
"testing"

"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)

func TestAddUser(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
defer db.Close()

gormDB, err := gorm.Open(postgres.New(postgres.Config{Conn: db}), &gorm.Config{})
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a gorm database", err)
}

t.Run("add user successfully", func(t *testing.T) {
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "users" WHERE email = $1 AND "users"."deleted_at" IS NULL`)).
WithArgs("john.doe@example.com").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))

// Define your expectations for SQL operations
mock.ExpectBegin()
mock.ExpectQuery("^INSERT INTO \"users\" (.+)$").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
mock.ExpectCommit()

err := AddUser(gormDB, "John Doe", "john.doe@example.com", 30)
assert.NoError(t, err)

assert.NoError(t, mock.ExpectationsWereMet())
})

t.Run("fail to add user with existing email", func(t *testing.T) {
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "users" WHERE email = $1 AND "users"."deleted_at" IS NULL`)).
WithArgs("john.doe@example.com").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))

err := AddUser(gormDB, "John Doe", "john.doe@example.com", 30)
assert.EqualError(t, err, "email already exists")

assert.NoError(t, mock.ExpectationsWereMet())
})
}