How to Mock Database In GO With GO-SQLMOCK

How and why to use SQLMOCK

Pawut Jingjit
5 min readJan 4, 2023
YENWEN/GETTY IMAGES

เพื่อนๆอาจจะเคยมีประสบการณ์เกี่ยวกับ Unit Test มาแล้ว ซึ่งเจ้าของบทความมั่นใจว่า เพื่อนๆที่ทำ Unit Test เกี่ยวกับ Database อยู่จะพบว่า

มัน Mock ไม่ได้ !

*sql.DB จะ Mock ยังไงดี

What is Mock

Unlike fakes, mocks don’t have working implementations. Instead, they have pre-programmed expectations about how they will be used in the code.

ต่างกับ fake , mock ไม่ได้ implementation แต่จะใช้วิธี pre-programmed ว่าจะถูกใช้อย่างใดใน Code แทน

Insert User ที่คุ้นเคยกันดี
สังเกตที่ Line 16 ExpectQuery With Args หมายความว่า ถ้า db เรียกใช้ QueryRow Method (Line 18 ภาพแรก) ด้วย Args “Anuchit0” , 19 โดยแทนที่จะไปต่อ Database จริงๆ QueryRow (Mock) จะ Return Rows (Line14) แทน

จะว่าไป Mock ค่อนข้างจะใกล้เคียงกับ Stub แต่แทนที่จะกำหนดค่าที่ Return 1 ค่าตั้งแต่ตอนสร้าง (Constructor) เราสามารถกำหนดได้ว่า จะ Return ค่าอะไร เมื่อได้รับ Parametor อะไร (pre-programmed)

ดังตัวอย่างที่ยกมา เราสามารถกำหนดได้ว่า ถ้า DB เรียก QueryRow ด้วย Args (“Anuchit0” , 19) แล้ว QueryRow จะ Return Rows (0,”Anuchit0",19) เสมอ

สำหรับ Test Double แบบต่างๆ เพื่อนๆสามารถอ่านเพิ่มเติมได้ที่บทความที่แล้วของเจ้าของบทความ

Why is GO-SQLMOCK

Mock Database เฉยๆอาจไม่ได้ยากอะไรมาก(โดยการใช้ Interface ให้มี QueryRow ก็ Mock ได้แล้ว) แต่เราต้อง Mock “ROW” ซึ่งเป็นผลลัพท์จาก DB ด้วย แล้วต้อง Mock Method Scan อีก นี่ยังไม่รวมว่า sql.DB ไม่ได้มีเพียง QueryRow

จะเห็นได้ว่า เป็นไปได้ยากที่จะ Mock *sql.DB ขึ้นมา จึงมี 3rd Party ซึ่งเห็นปัญหานี้ ได้สร้าง Libary สำหรับแก้ปัญหานี้อย่าง SQLMock มาให้

ข้อควรระวังคือ SQLMock ใช้สำหรับการ Mock SQL ขึ้นมา หมายความว่า ถ้าจริงๆแล้ว SQL ของเราทำงานผิด เราจะไม่ทราบเลย ว่า SQL เราทำงานผิด จนกระทั่งเราทำการ Manual Test หรือ Integral Test เลย

ซึ่งหมายความว่า มันจึงแทบไม่มีประโยชน์อะไรสำหรับคนที่ใช้ Repository Pattern ซึ่งแยกส่วนที่ติดต่อ Database ไว้ใน Repository อยู่แล้ว เพราะ Repository เราเขียนเอง Mock ได้ไม่ยาก

อย่างไรก็ตาม สำหรับคนที่รวมส่วน Business Logic ไว้กับ ส่วนที่ติดต่อกับ Database (ซึ่งรวมถึงเพื่อนๆที่เขียนตรงๆตามบทเรียนด้วย lol ) การใช้ SQL Mock จะทำให้เราสามารถ Focus การ Test ส่วนของ Business Logic ได้มากขึ้น

อนึ่ง สำหรับเพื่อนๆที่อยากจะ Test SQL ว่าทำงานถูกไหม โดยไม่กระทบกับระบบ สามารถ Follow บทความที่แล้วได้เลย (แต่ถึกมาก TT)

Import

package main
import (
"database/sql"
"log"
_ "github.com/go-sql-driver/mysql"
)

type User struct {
ID int
Name string
Age int
}
package main

import (
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
)

QueryRow and ExpectQuery


// ที่ควรรู้คือ จริงๆ Insert เขาไม่ค่อยใช้ QueryRow กัน ปกติจะใช้เป็น Exec
// แต่ในที่นี้ใช้เป็น QueryRow เพราะต้องการแสดงตัวอย่างของ ExpectQuery
func CreateUsers(db *sql.DB, user User) (User, error) {

// สังเกตว่า QueryRow จะคืนค่าเป็น row โดยไม่มี Error เลย (ถ้ามี Error จะเกิดตอน Scan)
row := db.QueryRow(`INSERT INTO users (name, age) values ($1, $2) RETURNING id,name,age`, user.Name, user.Age)
result := User{}

err := row.Scan(&result.ID, &result.Name, &result.Age)

if err != nil {
return User{}, err
}
return result, nil
}
// จุดสำคัญคือ QueryRow ต้องใช้คู่กับ ExpectQuery เท่านั้น

func TestCreateUser(t *testing.T) {
user := User{Name: "SomeUser", Age: 27}
db, mock, _ := sqlmock.New()

// กำหนด rows ที่ต้องการ return
rows := sqlmock.NewRows([]string{"id", "name", "age"}).AddRow(0, "SomeUser", 27)

// เมื่อ DB เรียก QueryRow ที่มีการ "INSERT INTO users"
// โดยมี Args 2 ตัว คือ "SomeUser" และ 27
// จะได้ Return ค่าเป็น rows
mock.ExpectQuery("INSERT INTO users").WithArgs("SomeUser", 27).WillReturnRows(rows)

result, err := CreateUsers(db, user)
assert.Nil(t, err)
assert.EqualValues(t, result.Name, user.Name)
assert.EqualValues(t, result.Age, user.Age)
}

Query and ExpectQuery

// Query ต่างกับ QueryRow คือ Query จะ return เป็น rows 
func FindAllUsers(db *sql.DB) ([]User, error) {
rows, err := db.Query("SELECT id, name, age FROM USERS")

result := []User{}

if err != nil {
return result, err
}
// rows จาก Query แตกต่างกับ row จาก QueryRow คือ
// rows เสมือน row ที่เชื่อมเป็น Linklist โดยจะเรียกตัวต่อไปผ่าน rows.Next()
for rows.Next() {
user := User{}
err := rows.Scan(&user.ID, &user.Name, &user.Age)
if err != nil {
return result, err
}
result = append(result, user)
}
return result, nil
}
// อย่างไรก็ตาม ทั้ง Query , QueryRow ต้องใช้คู่กับ ExpectQuery เช่นกัน
func TestFindAllUser(t *testing.T) {
db, mock, _ := sqlmock.New()
// สังเกตการ New Rows จะใช้วิธีการเช่นเดียวกับ QueryRow
// สามารถ .AddRow ต่อได้ ในกรณีนี้ rows จะมีสมาชิก 3 ตัว (SomeUser0 , SomeUser1 , SomeUser2)
rows := sqlmock.NewRows([]string{"id", "name", "age"}).AddRow(0, "SomeUser0", 0).AddRow(0, "SomeUser1", 1).AddRow(0, "SomeUser2", 2)
mock.ExpectQuery("SELECT id, name, age FROM USERS").WillReturnRows(rows)

result, err := FindAllUsers(db)
assert.Nil(t, err)
assert.EqualValues(t, len(result), 3)
}

Prepare and ExpectQuery

// ตัวอย่างนี้จะใช้ Prepare ร่วมด้วย
func FindOneUser(db *sql.DB, id int) (User, error) {
result := User{}

stmt, err := db.Prepare("SELECT id, name, age FROM users where id=$1")
if err != nil {
log.Fatal("can'tprepare query one row statment", err)
return result, err
}

row := stmt.QueryRow(id)
err = row.Scan(&result.ID, &result.Name, &result.Age)

if err != nil {
log.Fatal("can't Scan row into variables", err)
return result, err
}

return result, nil

}
func TestFindOneUser(t *testing.T) {

db, mock, _ := sqlmock.New()

row := sqlmock.NewRows([]string{"id", "name", "age"}).AddRow(0, "SomeUser", 0)
// ถ้าเรา Prepare มา เราต้องใช้ ExpectPrepare ก่อนจะเป็น ExpectQuery
mock.ExpectPrepare("SELECT id, name, age FROM users").ExpectQuery().WithArgs(0).WillReturnRows(row)
result, err := FindOneUser(db, 0)

assert.Nil(t, err)
assert.EqualValues(t, result.Name, "SomeUser")

}

Exec and ExpectExec

// ตัวอย่างนี้จะใช้ Exec ที่ต้องเข้าใจคือ 
// Exec จะ Return Result
// Result มี 2 Field คือ LastInsertId , RowsAffected
// ดูจาก Field ของ Result สามารถเข้าใจได้ว่า Exec นั้น เหมาะกับ Update , Create , Delete
// ซึ่งเราไม่สนใจว่าจะมีค่าอะไร แต่สนใจว่าต้องไม่ Error
func CreateTable(db *sql.DB) error {
createTb := `
CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, name TEXT, age INT );
`
// ใน Case นี้ แน่นอนว่าเราไม่สนใจว่า Result จะเป็นอะไร แต่จะสนใจว่าเราไม่มี Error เท่านั้น
_, err := db.Exec(createTb)

if err != nil {
log.Fatal("can't create table", err)
}

return nil
}
// Exec คู่กับ ExpectExec และ WillReturnResult
// 0 , 0 ในที่นี้คือ LastInsertId , RowsAffected ซึ่งในความจริง เราอาจใส่ค่าอื่นแทนได้ (เพราะ Code หลักเราไม่สนใจ)
func TestCreateTable(t *testing.T) {
db, mock, _ := sqlmock.New()

mock.ExpectExec("CREATE TABLE").WillReturnResult(sqlmock.NewResult(0, 0))

err := CreateTable(db)
assert.Nil(t, err)เ

}

บทส่งท้าย

https://www.kbtgkampus.tech/classnest/go-software-engineering-bootcamp

บทความนี้เป็นเนื้อหาเพิ่มเติมจาก KBTG Kampus โดยที่มั่นใจว่า เพื่อนๆที่เรียน Course เดียวกัน ต้องเจอปัญหาเช่นเดียวกับเจ้าของบทความแน่นอน จึงอยากจะ Share Solution ที่ใช้กับเพื่อนๆครับ

เป็นบทความที่จะว่าเขียนง่ายก็เขียนง่ายนะครับ เพราะเอา Code ที่ไปลองๆมาแล้ว Work มานั่งอธิบาย

ช่วงนี้เป็นช่วงปั่น Project ของ KBTG Kampus ก็ขอให้เพื่อนๆโชคดี โปรเจคท์สำเร็จ เวลานอนอยู่ครบ นะครับ ส่วนเพื่อนๆที่อ่านบทความนี้ตอนหลังจบค่ายแล้ว ก็อยากจะบอกว่า ถ้ามีโอกาส ก็อยากให้มาเข้าร่วม KBTG Kampus นะครับ ค่ายที่ความรู้เยอะ อาจารย์สอนดี และโปรเจคท์สุดมันส์เช่นนี้ หาไม่ได้ง่ายๆเลยครับ

สุดท้ายก็ขอขอบคุณเพื่อนๆที่อ่านบทความนี้จนจบ รวมไปถึงทาง KBTG ที่มอบโอกาสดีๆนี้ให้ครับ

ถ้าเจ้าของบทความผิดพลาดประการใด สามารถแนะนำเพิ่มเติมได้ที่ DM หรือทาง Page ของเจ้าของบทความได้นะครับ // ซึ่งน่าจะมีอยู่นะ Lib เขา อาจจะเข้าใจผิดได้ง่ายๆเลย

Code และ Structure ในบทความนี้ เพื่อนๆสามารถดูฉบับเต็มได้ที่นี่นะครับ

Reference

https://github.com/pawutj/go_mocksql

--

--