DEV Community

Tossapol Ritcharoenwattu
Tossapol Ritcharoenwattu

Posted on

workshop CI/CD : Step 1 สร้าง bank api + unit test ด้วยภาษา go

โครงสร้างของโปรเจกต์จะแบ่งออกเป็น 3 ไฟล์หลักเพื่อความชัดเจน:

  1. bank.go - ส่วนตรรกะหลักของธนาคาร (Business Logic)
  2. main.go - ส่วนของ API Server ที่เรียกใช้ Logic จาก bank.go
  3. bank_test.go - ส่วนของ Unit Test สำหรับ bank.go
  4. 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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

หลังจากสร้างไฟล์ทั้งหมดเสร็จ ให้ สร้างไฟล์ go.mod เพื่อจัดการโปรเจกต์และส่วนเสริม (dependencies) ต่างๆ ด้วยการ run command

go mod init bankapi
go mod tidy
Enter fullscreen mode Exit fullscreen mode

เสร็จแล้วครับในส่วนของการสร้าง ตอนนี้เรามา run unit test กัน ด้วยคำสั่ง

go test -cover
Enter fullscreen mode Exit fullscreen mode

ผลที้่ได้คือ

ผ่านเกณฑ์ที่ตั้งไว้ > 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) 
    }
}
Enter fullscreen mode Exit fullscreen mode

ทำไมส่วนนี้ถึงทดสอบได้ยาก?
เป็น 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)