ประยุกต์ใช้กับ 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
ที่เพิ่มตามโจทย์นั้น
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", "[email protected]", 30)
}
code unit test
เมื่อต้องเขียน Unit test ออกมาปกติจะมีด้วยกัน 2 Idea ใหญ่ๆที่มักจะทำกันคือ
- สร้าง DB mock ขึ้นมาใหม่และ run unit test ผ่าน DB mock นั้นแทน
- ทำการ Mock service ของ DB ตัวนั้นขึ้นมาใหม่
ซึ่งนี่คือตัวอย่างของวิธีที่ 1. สิ่งที่เราทำคือ
- ทำการเพิ่ม
setupTestDB()
มา โดยเรียกใช้ไปยัง sqlitefile::memory:?cache=shared
เพื่อให้ใช้ in-memory ของ sqlite แทน - หลังจากนั้นทำการเพิ่ม Testcase เข้าไป 2 case คือ
- เคส success ปกติ
- เคส Email ซ้ำ
ก็จะได้ code unit test ประมาณนี้ออกมาได้
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", "[email protected]", 30)
assert.NoError(t, err)
var user User
db.First(&user, "email = ?", "[email protected]")
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", "[email protected]", 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 แค่นั้น)
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", "[email protected]", 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)
= mockSELECT query
- mock
db.Create(&user)
= mockINSERT query
- mock
- เพื่อให้สามารถ test function ที่อยู่ภายใน
AddUser()
ได้ เพื่อให้เราสามารถ focus กับจุดที่เป็น logic จัดการของAddUser()
ออกมาไ ด้
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("[email protected]").
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", "[email protected]", 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("[email protected]").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
err := AddUser(gormDB, "John Doe", "[email protected]", 30)
assert.EqualError(t, err, "email already exists")
assert.NoError(t, mock.ExpectationsWereMet())
})
}