โครงสร้างของโปรเจกต์จะแบ่งออกเป็น 3 ไฟล์หลักเพื่อความชัดเจน:
- bank.go - ส่วนตรรกะหลักของธนาคาร (Business Logic)
- main.go - ส่วนของ API Server ที่เรียกใช้ Logic จาก bank.go
- bank_test.go - ส่วนของ Unit Test สำหรับ bank.go
- main_test.go - ส่วนของ Unit test สำหรับ main.go
File : bank.go
// bank.go
package main
import (
"errors"
"sync"
)
// định nghĩa các lỗiเฉพาะเพื่อให้ง่ายต่อการจัดการ
var (
ErrAccountNotFound = errors.New("ไม่พบบัญชีนี้ในระบบ")
ErrInsufficientFunds = errors.New("ยอดเงินในบัญชีไม่เพียงพอ")
ErrInvalidAmount = errors.New("จำนวนเงินต้องเป็นค่าบวก")
)
// Account struct định nghĩaโครงสร้างของบัญชีธนาคาร
type Account struct {
ID int `json:"id"`
Name string `json:"name"`
Balance float64 `json:"balance"`
}
// Bank struct ทำหน้าที่เป็น "ตู้เซฟ" เก็บข้อมูลบัญชีทั้งหมด
// ใช้ sync.RWMutex เพื่อจัดการการเข้าถึงข้อมูลพร้อมกัน (Concurrency) อย่างปลอดภัย
type Bank struct {
mu sync.RWMutex
accounts map[int]*Account
nextAccountID int
}
// NewBank สร้าง instance ใหม่ของธนาคาร
func NewBank() *Bank {
return &Bank{
accounts: make(map[int]*Account),
nextAccountID: 1,
}
}
// CreateAccount เปิดบัญชีใหม่
func (b *Bank) CreateAccount(name string) *Account {
b.mu.Lock()
defer b.mu.Unlock()
acc := &Account{
ID: b.nextAccountID,
Name: name,
Balance: 0,
}
b.accounts[acc.ID] = acc
b.nextAccountID++
return acc
}
// GetAccount ดึงข้อมูลบัญชีด้วย ID
func (b *Bank) GetAccount(id int) (*Account, error) {
b.mu.RLock()
defer b.mu.RUnlock()
acc, ok := b.accounts[id]
if !ok {
return nil, ErrAccountNotFound
}
return acc, nil
}
// Deposit ฝากเงินเข้าบัญชี
func (b *Bank) Deposit(id int, amount float64) (*Account, error) {
if amount <= 0 {
return nil, ErrInvalidAmount
}
b.mu.Lock()
defer b.mu.Unlock()
acc, ok := b.accounts[id]
if !ok {
return nil, ErrAccountNotFound
}
acc.Balance += amount
return acc, nil
}
// Withdraw ถอนเงินจากบัญชี
func (b *Bank) Withdraw(id int, amount float64) (*Account, error) {
if amount <= 0 {
return nil, ErrInvalidAmount
}
b.mu.Lock()
defer b.mu.Unlock()
acc, ok := b.accounts[id]
if !ok {
return nil, ErrAccountNotFound
}
if acc.Balance < amount {
return nil, ErrInsufficientFunds
}
acc.Balance -= amount
return acc, nil
}
File : main.go
package main
import (
"encoding/json"
"errors"
"log"
"net/http"
"strconv"
"strings"
)
var bank = NewBank()
// NewRouter สร้างและคืนค่า router ที่ตั้งค่าแล้ว
func NewRouter() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/accounts", handleAccounts)
mux.HandleFunc("/accounts/", handleAccountActions)
return mux
}
func main() {
router := NewRouter()
log.Println("Bank API server is running on :8080")
if err := http.ListenAndServe(":8080", router); err != nil {
log.Fatal(err)
}
}
// handleAccounts จัดการการ "เปิดบัญชี" (POST /accounts)
func handleAccounts(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
acc := bank.CreateAccount(req.Name)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(acc)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// handleAccountActions จัดการ actions ทั้งหมดที่เกี่ยวกับบัญชีเดียว
// GET /accounts/{id} -> เช็คยอดเงิน
// POST /accounts/{id}/deposit -> ฝากเงิน
// POST /accounts/{id}/withdraw -> ถอนเงิน
func handleAccountActions(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 3 {
http.NotFound(w, r)
return
}
id, err := strconv.Atoi(parts[2])
if err != nil {
http.Error(w, "Invalid account ID", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
// เช็คยอดเงิน
acc, err := bank.GetAccount(id)
handleResult(w, acc, err)
case http.MethodPost:
// ตรวจสอบว่าเป็น deposit หรือ withdraw
if len(parts) == 4 {
var req struct {
Amount float64 `json:"amount"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var acc *Account
var actionErr error
switch parts[3] {
case "deposit":
acc, actionErr = bank.Deposit(id, req.Amount)
case "withdraw":
acc, actionErr = bank.Withdraw(id, req.Amount)
default:
http.NotFound(w, r)
return
}
handleResult(w, acc, actionErr)
} else {
http.NotFound(w, r)
}
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// handleResult เป็นฟังก์ชันช่วยในการส่ง response กลับไป
func handleResult(w http.ResponseWriter, acc *Account, err error) {
w.Header().Set("Content-Type", "application/json")
if err != nil {
switch {
case errors.Is(err, ErrAccountNotFound):
http.Error(w, err.Error(), http.StatusNotFound)
case errors.Is(err, ErrInsufficientFunds):
http.Error(w, err.Error(), http.StatusBadRequest)
case errors.Is(err, ErrInvalidAmount):
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
json.NewEncoder(w).Encode(acc)
}
File bank_test.go
// bank_test.go
package main
import (
"testing"
)
// setupTest จะล้างข้อมูลธนาคารทุกครั้งก่อนรันเทสใหม่ เพื่อให้แต่ละเทสไม่เกี่ยวข้องกัน
func setupTest() *Bank {
return NewBank()
}
func TestCreateAccount(t *testing.T) {
bank := setupTest()
name := "John Doe"
acc := bank.CreateAccount(name)
if acc.Name != name {
t.Errorf("Expected name %s, got %s", name, acc.Name)
}
if acc.Balance != 0 {
t.Errorf("Expected balance 0, got %f", acc.Balance)
}
if acc.ID != 1 {
t.Errorf("Expected ID 1, got %d", acc.ID)
}
}
func TestGetAccount(t *testing.T) {
bank := setupTest()
acc1 := bank.CreateAccount("Jane Doe")
// Test case: บัญชีมีอยู่จริง
retrievedAcc, err := bank.GetAccount(acc1.ID)
if err != nil {
t.Fatalf("Should not have failed to get account: %v", err)
}
if retrievedAcc.ID != acc1.ID {
t.Errorf("Mismatched account ID")
}
// Test case: บัญชีไม่มีอยู่จริง
_, err = bank.GetAccount(999)
if !errors.Is(err, ErrAccountNotFound) {
t.Errorf("Expected ErrAccountNotFound, got %v", err)
}
}
func TestDeposit(t *testing.T) {
bank := setupTest()
acc := bank.CreateAccount("Alice")
// Test case: ฝากเงินถูกต้อง
updatedAcc, err := bank.Deposit(acc.ID, 100.50)
if err != nil {
t.Fatalf("Deposit failed: %v", err)
}
if updatedAcc.Balance != 100.50 {
t.Errorf("Expected balance 100.50, got %f", updatedAcc.Balance)
}
// Test case: ฝากเงินติดลบ
_, err = bank.Deposit(acc.ID, -50)
if !errors.Is(err, ErrInvalidAmount) {
t.Errorf("Expected ErrInvalidAmount for negative deposit, got %v", err)
}
// Test case: ฝากเงินเข้าบัญชีที่ไม่มีอยู่จริง
_, err = bank.Deposit(999, 100)
if !errors.Is(err, ErrAccountNotFound) {
t.Errorf("Expected ErrAccountNotFound, got %v", err)
}
}
func TestWithdraw(t *testing.T) {
bank := setupTest()
acc := bank.CreateAccount("Bob")
bank.Deposit(acc.ID, 200) // ฝากเงินเข้าไปก่อน
// Test case: ถอนเงินถูกต้อง
updatedAcc, err := bank.Withdraw(acc.ID, 75)
if err != nil {
t.Fatalf("Withdraw failed: %v", err)
}
if updatedAcc.Balance != 125 {
t.Errorf("Expected balance 125, got %f", updatedAcc.Balance)
}
// Test case: ถอนเงินเกินจำนวน
_, err = bank.Withdraw(acc.ID, 150)
if !errors.Is(err, ErrInsufficientFunds) {
t.Errorf("Expected ErrInsufficientFunds, got %v", err)
}
// Test case: ถอนเงินติดลบ
_, err = bank.Withdraw(acc.ID, -25)
if !errors.Is(err, ErrInvalidAmount) {
t.Errorf("Expected ErrInvalidAmount for negative withdrawal, got %v", err)
}
// Test case: ถอนเงินจากบัญชีที่ไม่มีอยู่จริง
_, err = bank.Withdraw(999, 50)
if !errors.Is(err, ErrAccountNotFound) {
t.Errorf("Expected ErrAccountNotFound, got %v", err)
}
}
File : main_test.go
// main_test.go
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestAccountHandlers(t *testing.T) {
// Setup: สร้าง router และรีเซ็ต state ของธนาคาร
router := NewRouter()
bank = NewBank()
// 1. ทดสอบสร้างบัญชี (POST /accounts)
createBody := `{"name": "Test User"}`
req, _ := http.NewRequest("POST", "/accounts", bytes.NewBufferString(createBody))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusCreated {
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusCreated)
}
var createdAccount Account
json.Unmarshal(rr.Body.Bytes(), &createdAccount)
if createdAccount.ID != 1 {
t.Errorf("Expected account ID to be 1, got %d", createdAccount.ID)
}
// 2. ทดสอบเช็คยอดเงิน (GET /accounts/1)
req, _ = http.NewRequest("GET", "/accounts/1", nil)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req) // << ใช้ router ที่เราสร้าง
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
}
var fetchedAccount Account
json.Unmarshal(rr.Body.Bytes(), &fetchedAccount)
if fetchedAccount.Name != "Test User" {
t.Errorf("Expected name 'Test User', got '%s'", fetchedAccount.Name)
}
// 3. ทดสอบฝากเงิน (POST /accounts/1/deposit)
depositBody := `{"amount": 100}`
req, _ = http.NewRequest("POST", "/accounts/1/deposit", bytes.NewBufferString(depositBody))
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req) // << ใช้ router ที่เราสร้าง
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
}
json.Unmarshal(rr.Body.Bytes(), &fetchedAccount)
if fetchedAccount.Balance != 100 {
t.Errorf("Expected balance 100, got %f", fetchedAccount.Balance)
}
// 4. ทดสอบถอนเงิน (POST /accounts/1/withdraw)
withdrawBody := `{"amount": 25}`
req, _ = http.NewRequest("POST", "/accounts/1/withdraw", bytes.NewBufferString(withdrawBody))
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req) // << ใช้ router ที่เราสร้าง
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
}
json.Unmarshal(rr.Body.Bytes(), &fetchedAccount)
if fetchedAccount.Balance != 75 {
t.Errorf("Expected balance 75, got %f", fetchedAccount.Balance)
}
}
func TestErrorCases(t *testing.T) {
// Setup
router := NewRouter()
bank = NewBank()
bank.CreateAccount("Existing User") // Account ID 1
// Case 1: GET non-existent account
req, _ := http.NewRequest("GET", "/accounts/999", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("Expected status 404 for non-existent account, got %d", rr.Code)
}
// Case 2: POST to wrong method
req, _ = http.NewRequest("GET", "/accounts", nil)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Errorf("Expected status 405 for wrong method, got %d", rr.Code)
}
// Case 3: Invalid JSON body
invalidJSON := `{"name": "Test"`
req, _ = http.NewRequest("POST", "/accounts", strings.NewReader(invalidJSON))
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("Expected status 400 for invalid JSON, got %d", rr.Code)
}
// Case 4: Insufficient funds
withdrawBody := `{"amount": 50}` // Account has 0 balance
req, _ = http.NewRequest("POST", "/accounts/1/withdraw", strings.NewReader(withdrawBody))
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("Expected status 400 for insufficient funds, got %d", rr.Code)
}
}
หลังจากสร้างไฟล์ทั้งหมดเสร็จ ให้ สร้างไฟล์ go.mod เพื่อจัดการโปรเจกต์และส่วนเสริม (dependencies) ต่างๆ ด้วยการ run command
go mod init bankapi
go mod tidy
เสร็จแล้วครับในส่วนของการสร้าง ตอนนี้เรามา run unit test กัน ด้วยคำสั่ง
go test -cover
ผ่านเกณฑ์ที่ตั้งไว้ > 80%
แต่ว่า ทำไมถึงไม่ได้ 100% และจำเป็นต้องได้ 100% ไหม
เพราะว่า มีส่วนของโค้ดที่ไม่ถูกทดสอบ
โค้ดส่วนสำคัญที่การทดสอบของเราเข้าไม่ถึงคือ if block ภายในฟังก์ชัน main
// main.go
func main() {
router := NewRouter()
log.Println("Bank API server is running on :8080")
// บรรทัดนี้จะทำงานตลอดไป ทำให้ส่วน if ไม่ถูกเรียก
if err := http.ListenAndServe(":8080", router); err != nil {
// โค้ดส่วนนี้คือส่วนที่ "ไม่ถูกทดสอบ"
log.Fatal(err)
}
}
ทำไมส่วนนี้ถึงทดสอบได้ยาก?
เป็น Code ที่ทำงานเมื่อ Server ล่ม: โค้ด log.Fatal(err) จะทำงานก็ต่อเมื่อ http.ListenAndServe ไม่สามารถเริ่มทำงานได้เท่านั้น เช่น มีโปรแกรมอื่นใช้พอร์ต :8080 อยู่แล้ว หรือไม่มีสิทธิ์ในการเปิดพอร์ตนั้น.
จำลองสถานการณ์ยาก: การจำลองสถานการณ์ "เซิร์ฟเวอร์สตาร์ทไม่ขึ้น" ภายใน Unit Test (go test) นั้นทำได้ยากและซับซ้อนมาก เพราะมันไม่ใช่การทดสอบ logic ของแอปพลิเคชัน แต่เป็นการทดสอบสภาพแวดล้อม (environment) ที่แอปพลิเคชันรันอยู่.
Blocking Call: http.ListenAndServe เป็นคำสั่งที่ทำงานค้างไว้ (blocking) ตลอดไปเพื่อรอรับ request มันจะไม่คืนค่า error ออกมาในสภาวะปกติ.
100% Coverage จำเป็นไหม?
ในทางปฏิบัติ การตั้งเป้าหมาย coverage ไว้ที่ 100% ไม่ใช่สิ่งที่จำเป็นเสมอไป และบางครั้งก็ให้ผลตอบแทนที่ไม่คุ้มค่า (diminishing returns) เพราะต้องเขียนเทสที่ซับซ้อนมากเพื่อครอบคลุมโค้ดส่วนเล็กๆ ที่แทบจะไม่มีโอกาสทำงานผิดพลาดเลย.
โดยทั่วไปในวงการพัฒนาซอฟต์แวร์ การมี code coverage ที่ 80%-90% ถือว่า ยอดเยี่ยมมากแล้ว เพราะมันหมายความว่าเราได้ทดสอบตรรกะหลักๆ (business logic) และเส้นทางการทำงานที่สำคัญของแอปพลิเคชันเกือบทั้งหมดแล้ว.
Top comments (0)