Loading...

Rule Table Pattern|用 TDD 馴服複雜的交叉條件邏輯

這篇文章的啟發主要是我在開發的時候遇到的一個情境,我覺得產出挺不錯的,就放上來做紀錄,雖然是使用 Golang 做開發,但我想概念適用於各個程式語言

Hi all, 當我們在開發的時候,多少會遇到需要交叉判斷的邏輯,更雖小的時候判斷條件可能還會同時包含:

  • 類別(例如:A/B、group、type…)
  • 年齡/級距(range)
  • 數值區間(range)
  • 需要回傳一個 label/level/result

接著我就用 TDD (Test Driven Development) 的方式,帶大家開發一次。

情境

因為我實際上的開發內容不方便公開,我在這邊透過一個 運費計算器 例子來展示,以下是我們的業務規則:

Rules Table(Shipping Fee Calculator)

Range 約定:全部採 左閉右開 [min, max), 例如:DistanceKm = 5.0 不屬於 [0, 5);WeightKg = 1.0 屬於 [1, 3)。

Rule IDRegionDistance Range (km)Weight Range (kg)FeeNotes
1Local[0, 5)[0, 1)60Small parcel, short distance
2Local[0, 5)[1, 3)90Medium parcel, short distance
3Local[5, 20)[0, 1)80Small parcel, medium distance
4Local[5, 20)[1, 3)120Medium parcel, medium distance
5Local[20, 999)[0, 1)150Small parcel, long distance
6Local[20, 999)[1, 3)210Medium parcel, long distance
7Remote[0, 5)[0, 1)100Remote surcharge, short distance
8Remote[0, 5)[1, 3)140Remote surcharge, short distance
9Remote[5, 20)[0, 1)130Remote surcharge, medium distance
10Remote[5, 20)[1, 3)180Remote surcharge, medium distance
11Remote[20, 999)[0, 1)220Remote surcharge, long distance
12Remote[20, 999)[1, 3)300Remote surcharge, long distance

Fallback

  • If no rule matches, return: 999

這是個蠻易懂的例子,我們會有 Regin (地區)DistanceKm (距離)WeightKg (重量) 三個因素來計算出 Expected Fee (預期運費),都搞懂的話就開始 TDD 第一步吧。

Step 0:先寫第一個最小的測試(Red)

需求:Local 區域、距離 0–5km、重量 0–1kg → 60 元。

func (s *FeeServiceSuite) TestFee_Local_0to5km_0to1k_Should_Be_60() {

	fee := s.FeeService.Calculate("Local", 3.0, 0.8)
	s.Equal(60.0, fee)
}

此時,你的 IDE肯定會是紅線爬滿整個螢幕,但一切都在預期中。

Step 1: 一分鐘綠燈

這個時候,我們用最最最簡單的方式讓這個測試綠燈:


type FeeService struct{}

func (service FeeService) Calculate(region string, distance float64, weight float64) float64 {
	return 60.0
}

這個時候跑一下測試,就會看到測試通過囉。

Step 3: 第二個紅燈

需求:Local 區域、距離 0-5km、重量 1-3kg → 90 元。

func (s *FeeServiceSuite) TestFee_Local_0to5km_1to3k_Should_Be_90() {

	fee := s.FeeService.Calculate("Local", 2.0, 2.2)
	s.Equal(90.0, fee)
}

Step 4: 一分鐘綠燈

最直覺的做法就是 if-else (我開發時候是這樣想的)

func (service FeeService) Calculate(region string, distance float64, weight float64) float64 {

	if region == "Local" &&
		distance > 0 && distance < 5 &&
		weight >= 1 && weight < 3 {
		return 90.0
	}
	return 60.0

}

Step 5: 重構

此時我會覺得,Calculate 這個方法有 Long Parameters 的 Code Smell,所以我要重構他,程式碼如下:

Production Code

type FeeService struct{}

type CalculateFeeParams struct {
	Region   string
	Distance float64
	Weight   float64
}

func (service FeeService) Calculate(params CalculateFeeParams) float64 {

	if params.Region == "Local" &&
		params.Distance > 0 && params.Distance < 5 &&
		params.Weight >= 1 && params.Weight < 3 {
		return 90.0
	}
	return 60.0

}

Test Code

此時,我們更改了 production code 的介面,立論上 test code就會壞了,我們來稍微修整一下


func (s *FeeServiceSuite) TestFee_Local_0to5km_0to1k_Should_Be_60() {

	fee := s.FeeService.Calculate(CalculateFeeParams{"Local", 3.0, 0.8})
	s.Equal(60.0, fee)
}

func (s *FeeServiceSuite) TestFee_Local_0to5km_1to3k_Should_Be_90() {

	fee := s.FeeService.Calculate(CalculateFeeParams{"Local", 2.0, 2.2})
	s.Equal(90.0, fee)
}

此時跑一下測試,會發現測試還會是全綠燈。

Step 6: 第三個紅燈

需求:Local 區域、距離 5-20km、重量 0-1kg → 80 元。

func (s *FeeServiceSuite) TestFee_Local_5to20km_0to1k_Should_Be_80() {

	fee := s.FeeService.Calculate(CalculateFeeParams{"Local", 6.0, 1.0})
	s.Equal(80.0, fee)
}

Step 7: 一分鐘綠燈

這個時候,我們不能想太多,只要想讓測試通過就好


func (service FeeService) Calculate(params CalculateFeeParams) float64 {

	if params.Region == "Local" &&
		params.Distance > 0 && params.Distance < 5 &&
		params.Weight >= 1 && params.Weight < 3 {
		return 90.0
	}

	if params.Region == "Local" &&
		params.Distance > 5 && params.Distance < 20 &&
		params.Weight >= 1 && params.Weight < 3 {
		return 80.0
	}

	return 60.0

}

跑個測試,測試全部綠燈

Step 8: 重構

這個時候,身為開發者的我們應該要聽到腦海中的聲音:

那接下來的測試全部都用 if-else串下去,不就好了嗎,

當然可以,但日子一長,規則一直加,你能有把握可以馬上看懂所有的規則嗎? (至少我不能😢)

與 AI 討論過後,我們可以這樣子來設計 Pattern:

定義兩個 range 用的 model

程式碼如下:

type DistanceRange struct {
	Min float64
	Max float64
}

func (d DistanceRange) Contains(value float64) bool {
	return value >= d.Min && value < d.Max
}

type WeightRange struct {
	Min float64
	 Max float64
}

func (w WeightRange) Contains(value float64) bool {
	return value >= w.Min && value < w.Max
}

定義 Rule model

程式碼如下:

type  FeeRules struct {
	Region string
	DistanceRange DistnaceRange
	WeightRange WeightRange
	Fee float64
}

Apply Pattern

接著我們來修改下當前的 production code


type FeeService struct {
	rules []FeeRules
}

func (service FeeService) Calculate(params CalculateFeeParams) float64 {

	for _, rule := range service.rules {
		if rule.Region == params.Region && 
			rule.DistanceRange.Contains(params.Distance) && 
			rule.WeightRange.Contains(params.Weight) {
			return rule.Fee
		}
	}

	return 60.0

}

func NewFeeService() *FeeService {
	return &FeeService{
		rules: []FeeRules{
			{Region: "Local", DistanceRange: DistanceRange{Min: 0, Max: 5}, WeightRange: WeightRange{Min: 1, Max: 3}, Fee: 90.0},
			{Region: "Local", DistanceRange: DistanceRange{Min: 5, Max: 20}, WeightRange: WeightRange{Min: 1, Max: 3}, Fee: 80.0},
		},
	}
}

可以看到我們上面多了一個新的 method NewFeeService ,這個 method 我們可以視為 constructor 的存在,此時我們在這裡定義 rules。 然而此時 test code 的 setup test 那邊也需要動點手腳,讓它是透過 NewFeeService 取得 FeeService:

func (s *FeeServiceSuite) SetupTest() {
	s.FeeService = NewFeeService()
}

此時,跑一下測試… 全數通過,讚。

免不了 AI 出場

理論上,我們應該要繼續我們的 TDD 旅程,但是~~~~ 我可是有付錢買 AI 的人耶,不用就太浪費了對吧?

所以我決定將剩下的 test case 讓 AI follow 我們的 pattern 進行撰寫, prompt 如下:

請 follow 當前的 design pattern, 針對以下 test case 進行開發

/***
這裡什麼都不用想,把 markdown table 或是 excel 直接複製貼上來
***/

假設你的 AI 有好好聽話,理論上你執行測試都要是綠燈。

結論

說到底,這次 TDD 旅程帶來最大的收穫不是「寫測試」本身,而是重構的勇氣

因為有測試保護,才敢大膽把 if-else 打掉重練(反正弄壞了,我們有 git 可以 rollback 嘛),換成更清楚的 Rule Table。對我來說,這就是 TDD 的價值:不是一口氣把 pattern 全部生出來,而是在一步步紅燈、綠燈的過程中,讓設計自然長出來。

Rule Table Pattern 的好處也在這個過程中一一浮現:

  • 每一條規則獨立存在,互不干擾
  • 新增規則不需要觸碰已有邏輯(符合 Open/Closed Principle)
  • 規則本身就是文件,幾乎不需要額外說明
  • AI 可以直接讀懂規則並協助補齊

下次遇到多條件交叉邏輯時,試試看這個 pattern,相信你也會很快愛上它的。

BTW: 相關的範例程式碼,我放在這 GitHub