มือใหม่หัด Go: Basic Unit Testing
Tags: go, beginners
Published: 2023-05-13 14:40:30

วันนี้จะมาลองใช้ Go เพื่อทำ unit testing อย่างง่าย เพื่อทำความเข้าใจสำหรับมือใหม่ให้เห็นภาพไปพร้อมกัน ด้วยความที่ Go มี function ที่อำนวยความสะดวกในการทำ testing รวมถึงมีคำสั่งในการ run test ซึ่งเรียกใช้ได้เลยโดยไม่ต้องลง plug in เพิ่มเติม จึงเหมาะมากที่จะใช้เป็นตัวอย่างสำหรับ TDD

## Test Driven Development (TDD)
TDD มีหลักการอย่างง่าย คือ ให้สร้าง test สำหรับ function ที่ยังไม่ implement ขึ้นมาก่อน จากนั้นให้ค่อยๆ เริ่มเขียนโค้ดเพิ่มเติมเพื่อให้ได้ผลลัพธ์ตามที่เราคาดหวัง โดยมากแล้วมักมีวงจรดังนี้
- **Red**: ประกาศตัวแปร, function ที่ใช้ในการทดสอบ (testcase) ซึ่งผลลัพธ์ที่คาดหวังคือ fail
- **Green**: เขียนโค้ด (code) เพื่อทำให้ทดสอบ pass โดยอาจเขียนอย่างง่ายไว้ก่อน
- **Refactor**: ปรับปรุงโค้ดให้ทำงานได้ดี ถูกต้อง เข้าใจง่าย รวมถึงหากรณีอื่นๆ เพื่อนำไปสู่ testcase ใหม่ให้ครอบคลุมมากขึ้น วนซ้ำไปเรื่อยๆ

เอาล่ะ ต่อไปจะเริ่มลงมือเขียนโค้ดกันเพื่อให้เข้าใจมากขึ้น เริ่มจากสร้างไฟล์ตามนี้
```bash
mkdir water
cd ./water
touch water.go
touch water_test.go
```

ในบทความนี้จะทำการเขียน function เพื่อรับค่า temperature เป็นองศสาเซลเซียส แล้วคืนค่าออกมาเป็นสถานะของน้ำ

### 🔴Red: เริ่มต้นจากล้มเหลว
เริ่มต้นเขียนจาก testcase ให้ใส่ temperature = 25 แล้วคาดหวังว่าควรจะได้รับคืนค่าเป็น "liquid"
```go
// water_test.go

func TestWaterStatus(t *testing.T) {
	temperature := 25
	want := "liquid"
	got := water.WaterStatus(temperature)
	if got != want {
		t.Errorf("WaterStatus(%d) = %q; want %q", temperature, got, want)
	}
}
```

### 🟢Green: แก้ไขให้รันผ่าน
ในที่นี้จะเขียนแบบง่าย หรือโกงให้รันทดสอบให้ผ่านก่อน คือ return "liquid" ไปตรงๆ เลย
```go
// water.go

func WaterStatus(temperature int) string {
	return "liquid"
}
```

รันเทสโดยใช้ `go test -v ./...`

### 🔵Refactor: ปรับปรุงจนดีเลิศ
คราวนี้คือส่วนที่ต้องปรับปรุงโค้ดให้มันดีและสมเหตุสมผลมากขึ้น เนื่องจากเราทราบว่าน้ำโดยปกติจะมี 3 สถานะ ได้แก่ solid, liquid และ gas
```go
// water.go

func WaterStatus(temperature int) string {
	if temperature <= 0 {
		return "solid"
	} else if temperature >= 100 {
		return "gas"
	} else {
		return "liquid"
	}
}
```

รันเทสอีกรอบพร้อมเช็ค coverage โดยใช้ `go test -v -cover ./...` จะเห็นว่ารันผ่าน แต่ coverage ยังไม่ครบ 100% ทำให้เราต้องกลับมาที่ Red เพื่อเขียน testcase เพิ่ม

กลับมาที่ไฟล์ `water_test.go` เราสามารถเขียนแยก function ใหม่สำหรับ case ของ solid และ gas ได้ 
```go
// water_test.go

func TestWaterStatusLiquid(t *testing.T) {
	temperature := 25
	want := "liquid"
	got := water.WaterStatus(temperature)
	if got != want {
		t.Errorf("WaterStatus(%d) = %q; want %q", temperature, got, want)
	}
}

func TestWaterStatusSolid(t *testing.T) {
	temperature := -10
	want := "solid"
	got := water.WaterStatus(temperature)
	if got != want {
		t.Errorf("WaterStatus(%d) = %q; want %q", temperature, got, want)
	}
}

func TestWaterStatusGas(t *testing.T) {
	temperature := 110
	want := "gas"
	got := water.WaterStatus(temperature)
	if got != want {
		t.Errorf("WaterStatus(%d) = %q; want %q", temperature, got, want)
	}
}
```

แต่พิจารณาดูแล้วก็เหมือนเขียนโค้ดคล้ายกัน จะดีกว่าไหมหากเราเขียนภายใน function และเปลี่ยนแค่ testcase ที่เราต้องการทดสอบ แบบนี้
```go
// water_test.go

func TestWaterStatus(t *testing.T) {
	type testcase struct {
		name        string
		temperature int
		expected    string
	}

	cases := []testcase{
		{"solid", -10, "solid"},
		{"liquid", 25, "liquid"},
		{"gas", 110, "gas"},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			actual := WaterStatus(tc.temperature)
			if actual != tc.expected {
				t.Errorf("Expected %s, got %s", tc.expected, actual)
			}
		})
	}
}
```

ลองรัน `go test -v -cover ./...` จะเห็นว่าเป็น pass และ coverage: 100%

หวังว่าการ demo ในบทความนี้จะเป็นประโยชน์สำหรับผู้เริ่มต้นกันนะครับ