Skip to main content

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

มาลองกับ code ที่มีการเชื่อมต่อแต่ละส่วนกันบ้าง

ทีนี้ เราจะลองมาประยุกต์ใช้กับ Hexagonal Architecture ในบทที่แล้วกันบ้าง โดยความแตกต่างระหว่างการ Test แยกระหว่าง Fiber / GORM คือ

  • ในกรณีของ Hexagonal Architecture นั้นเป็นการแยกส่วนระหว่าง Port, Adapter และ Business Logic = ดังนั้นการทำ test ก็ต้องทำ Test แยกส่วนเช่นเดียวกัน
  • รวมถึงตอนนี้เรามี Service ต่อกันมากกว่า 1 Service (Fiber สู่ GORM) จึงจะต้องมีการ Mock Service แต่ละส่วนออกมาให้ถูกต้องจึงจะสามารถทำ Unit test ได้

Source code เราจะอ้างอิง structure ตามนี้คือ

├── adapters
│ ├── gorm_adapter.go
│ ├── gorm_adapter_test.go --> เพิ่ม test ของ Adapter GORM
│ ├── http_adapter.go
│ └── http_adapter_test.go --> เพิ่ม test ของ Adapter HTTP
├── core
│ ├── order.go
│ ├── order_repository.go
│ ├── order_service.go
│ └── order_service_test.go --> เพิ่ม test ของ Business Logic
├── main.go

โดย source นั้นจะเหมือนกับเนื้อหาในบทที่ 7 สามารถอ้างอิง Source ส่วน Service (ที่ไม่ใช่ file _test.go) ได้ ที่นี่

เราจะมาเริ่มเพิ่ม Test แต่ละส่วนกัน

1. เพิ่ม Unit Test ส่วน Business Logic

สิ่งที่เราจะทำ

  • ทำการสร้าง mockOrderRepo ขึ้นมาเพื่อเป็นตัวแทนของ mock instance ในส่วนของการส่งข้อมูล ฝั่ง Secondary Port
  • เมื่อมีการเรียกใช้งานเพื่อทดสอบในแต่ละเคส เราจะทำการ mock method saveFunc() ผ่าน mockOrderRepo ตามแต่ละเคสที่กำลังทดสอบอยู่
  • เคสที่เราเพิ่มนั้น เราจะเพิ่มทั้งหมด 2 เคสคือ เคส Success ทั่วไปและเคสกรณีที่ Total < 0 ต้องสามารถคืน Error ออกมาได้
order_service_test.go
package core

import (
"testing"
"github.com/stretchr/testify/assert"
)

// Mock implementation of OrderRepository
type mockOrderRepo struct {
saveFunc func(order Order) error
}

func (m *mockOrderRepo) Save(order Order) error {
return m.saveFunc(order)
}

func TestCreateOrder(t *testing.T) {
// Success case
t.Run("success", func(t *testing.T) {
repo := &mockOrderRepo{
saveFunc: func(order Order) error {
// Simulate successful save
return nil
},
}
service := NewOrderService(repo)

err := service.CreateOrder(Order{Total: 100})
assert.NoError(t, err)
})

// Failure case: Total less than 0
t.Run("total less than 0", func(t *testing.T) {
repo := &mockOrderRepo{
saveFunc: func(order Order) error {
// This won't be called due to validation
return nil
},
}
service := NewOrderService(repo)

err := service.CreateOrder(Order{Total: -10})
assert.Error(t, err)
assert.Equal(t, "total must be positive", err.Error())
})
}

2. เพิ่ม Unit Test adapter

สำหรับ Adapter เราจะต้องทำ Unit test แยกกัน 2 ส่วนคือ

  1. ฝั่ง Primary Adapter = ฝั่ง HTTP Server ที่รับ HTTP Request เข้ามา (ซึ่งก็คือตัว Fiber ที่รับ request จาก user)
  2. ฝั่ง Secondary Adapter = ฝั่ง Database ที่รับ logic และข้อมูลจาก Business Logic เข้ามาและส่งต่อไปยัง Database ผ่านคำสั่ง SQL (ซึ่งก็คือตัว GORM ที่จะรับหน้าที่เขียนข้อมูลเข้า Database)

ดังนั้น ไอเดียก็จะเหมือนกันกับหัวข้อก่อนหน้านี้ แต่ในฝั่งของ Primary Adapter ก็จะต้องอาศัยการ Mock ส่วน OrderService เพิ่ม เพื่อให้สามารถยังใช้งานส่วน Business Logic ออกมาได้

เราจะเริ่มทำไปทีละฝั่งจาก Secondary Adapter กันก่อน

Secondary Adapter = gorm_adapter_test.go

สำหรับฝั่งของ Secondary Adapter เราก็จะทำการเพิ่ม Mock ของ SQL (เหมือนกับหัวข้อก่อนหน้านี้เลย) โดย

  • ทำการเพิ่ม 2 case เข้าไปจาก success case กับ fail case โดยทำการ mock query INSERT เอาไว้ โดยจำลองว่า
    • ถ้า success (database ต่อได้ปกติ) = INSERT คืนค่าออกมาได้ปกติ
    • ถ้า fail (database มีปัญหาจากบางอย่าง) = INSERT คืนค่า Error ออกมา ก็จะทำการ handle เข้า Error case ไป
gorm_adapter_test.go
package adapters

import (
"mike/core"
"testing"

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

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

// Expectation for the sqlite version check
mock.ExpectQuery("select sqlite_version()").WillReturnRows(sqlmock.NewRows([]string{"version"}).AddRow("3.31.1"))

dialector := sqlite.Dialector{Conn: sqlDB}
gormDB, err := gorm.Open(dialector, &gorm.Config{})
if err != nil {
t.Fatalf("Failed to open gorm database: %v", err)
}

repo := NewGormOrderRepository(gormDB)

// Success case
t.Run("success", func(t *testing.T) {
// Setup expectations
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()

err := repo.Save(core.Order{Total: 100})
assert.NoError(t, err)

// Ensure all expectations were met
assert.NoError(t, mock.ExpectationsWereMet())
})

// Failure case
t.Run("failure", func(t *testing.T) {
// Setup expectations
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO").WillReturnError(gorm.ErrInvalidData)
mock.ExpectRollback()

err := repo.Save(core.Order{Total: 100})
assert.Error(t, err)

// Ensure all expectations were met
assert.NoError(t, mock.ExpectationsWereMet())
})
}

Primary Adapter = http_adapter_test.go

สำหรับ Primary Adapter เราจะต้องทำการเพิ่ม MockOrderService ออกมา

  • ตัว MockOrderService นั้นเป็นตัวแทนสำหรับการคุยกับ Business Logic ซึ่งตัวนี้จะทำการส่งต่อไปยัง Repository เพื่อสร้างข้อมูลอีกที
  • ดังนั้น ไอเดียของการทำ mock MockOrderService คือ การจำลองว่า คำสั่งของ Business Logic เมื่อเคสปกติ กับ เคส fail (เช่น service ต่อไม่ได้, body ไม่ถูกต้อง) สามารถ handle ออกมาได้ปกติหรือไม่
  • ส่วน ไอเดียการ Mock Fiber นั้น จริงๆสามารถทำเหมือนหัวข้อก่อนหน้าได้ (ทำการ import setup() เข้ามา) แต่เนื่องจากใน Hexagonal นี้ router ที่ใช้ทำ fiber มีไม่เยอะ เราเลยทำการสร้าง fiber app โดยตรงจาก unit test แทน

Case ที่เราจะ handle มีทั้งหมด 4 case คือ

  1. Success case ปกติ
  2. Total < 0 = ควรเข้า Error case return status code 500
  3. body request ไม่ถูกต้อง = ควรเข้า Error case return 400 (Bad Request)
  4. กรณีมี service ภายใน Error (เช่นต่อ database ไม่ได้) = ควรเข้า Error case return status code 500 ออกมา
http_adapter_test.go
package adapters

import (
"bytes"
"errors"
"mike/core"
"net/http/httptest"
"testing"

"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)

// MockOrderService is a mock implementation of core.OrderService
type MockOrderService struct {
mock.Mock
}

func (m *MockOrderService) CreateOrder(order core.Order) error {
args := m.Called(order)
return args.Error(0)
}

// TestCreateOrderHandler tests the CreateOrder handler of HttpOrderHandler
func TestCreateOrderHandler(t *testing.T) {
mockService := new(MockOrderService)
handler := NewHttpOrderHandler(mockService)

app := fiber.New()
app.Post("/orders", handler.CreateOrder)

// Test case: Successful order creation
t.Run("successful order creation", func(t *testing.T) {
mockService.On("CreateOrder", mock.AnythingOfType("core.Order")).Return(nil)

req := httptest.NewRequest("POST", "/orders", bytes.NewBufferString(`{"total": 100}`))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)

assert.NoError(t, err)
assert.Equal(t, fiber.StatusCreated, resp.StatusCode)
mockService.AssertExpectations(t)
})

t.Run("fail order creation (total less than 0)", func(t *testing.T) {
mockService.ExpectedCalls = nil
mockService.On("CreateOrder", mock.AnythingOfType("core.Order")).Return(errors.New("total must be positive"))

req := httptest.NewRequest("POST", "/orders", bytes.NewBufferString(`{"total": -200}`))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)

assert.NoError(t, err)
assert.Equal(t, fiber.StatusInternalServerError, resp.StatusCode)
mockService.AssertExpectations(t)
})

// Test case: Invalid request body
t.Run("invalid request body", func(t *testing.T) {
req := httptest.NewRequest("POST", "/orders", bytes.NewBufferString(`{"total": "invalid"}`))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)

assert.NoError(t, err)
assert.Equal(t, fiber.StatusBadRequest, resp.StatusCode)
})

// Test case: Order service returns an error
t.Run("order service error", func(t *testing.T) {
mockService.ExpectedCalls = nil
mockService.On("CreateOrder", mock.AnythingOfType("core.Order")).Return(errors.New("service error"))

req := httptest.NewRequest("POST", "/orders", bytes.NewBufferString(`{"total": 100}`))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)

assert.NoError(t, err)
assert.Equal(t, fiber.StatusInternalServerError, resp.StatusCode)
mockService.AssertExpectations(t)
})
}