การทดสอบแอป Your Go: เริ่มต้นอย่างถูกวิธี
เผยแพร่แล้ว: 2022-03-11เมื่อเรียนรู้สิ่งใหม่ ๆ สิ่งสำคัญคือต้องมีสภาพจิตใจที่สดใหม่
หากคุณเพิ่งเริ่มใช้ Go และมาจากภาษาต่างๆ เช่น JavaScript หรือ Ruby คุณน่าจะคุ้นเคยกับการใช้เฟรมเวิร์กที่มีอยู่ซึ่งช่วยให้คุณล้อเลียน ยืนยัน และทำการทดสอบอื่นๆ ได้
กำจัดแนวคิดของการพึ่งพาการพึ่งพาหรือเฟรมเวิร์กภายนอกให้หมดไป! การทดสอบเป็นอุปสรรคแรกที่ฉันสะดุดเมื่อเรียนรู้ภาษาการเขียนโปรแกรมที่น่าทึ่งนี้เมื่อสองสามปีก่อน ซึ่งเป็นช่วงที่มีทรัพยากรน้อยกว่ามาก
ตอนนี้ฉันรู้แล้วว่าการทดสอบความสำเร็จใน Go หมายถึงการเดินทางที่เบาบนการพึ่งพา (เช่นเดียวกับทุกสิ่ง Go) อาศัยไลบรารีภายนอกเพียงเล็กน้อย และการเขียนโค้ดที่ใช้ซ้ำได้ดี การนำเสนอประสบการณ์ของ Blake Mizerany ที่เกิดขึ้นกับไลบรารีทดสอบของบุคคลที่สามเป็นการเริ่มต้นที่ดีในการปรับความคิดของคุณ คุณจะเห็นข้อโต้แย้งที่ดีบางประการเกี่ยวกับการใช้ไลบรารีและเฟรมเวิร์กภายนอกกับการทำแบบ "Go way"
มันอาจจะดูขัดกับสัญชาตญาณในการสร้างกรอบการทดสอบของคุณเองและแนวคิดล้อเลียน แต่ง่ายกว่าที่คุณคิด และเป็นจุดเริ่มต้นที่ดีสำหรับการเรียนรู้ภาษา นอกจากนี้ คุณมีบทความนี้ที่จะแนะนำคุณเกี่ยวกับสถานการณ์การทดสอบทั่วไป เช่นเดียวกับการแนะนำเทคนิคต่างๆ ที่ฉันพิจารณาว่าเป็นแนวทางปฏิบัติที่ดีที่สุดสำหรับการทดสอบอย่างมีประสิทธิภาพและการรักษาโค้ดให้สะอาด ซึ่งแตกต่างจากตอนที่ฉันเรียนรู้
การทดสอบตารางใน Go
หน่วยทดสอบพื้นฐาน - ของชื่อเสียง 'การทดสอบหน่วย' - สามารถเป็นส่วนประกอบใดๆ ของโปรแกรมในรูปแบบที่ง่ายที่สุดซึ่งรับอินพุตและส่งคืนเอาต์พุต มาดูฟังก์ชันง่ายๆ ที่เราอยากจะเขียนแบบทดสอบกัน มันไม่สมบูรณ์แบบหรือสมบูรณ์ แต่ดีพอสำหรับการสาธิต:
avg.go
func Avg(nos ...int) int { sum := 0 for _, n := range nos { sum += n } if sum == 0 { return 0 } return sum / len(nos) }
ฟังก์ชันด้านบน func Avg(nos ...int)
คืนค่าศูนย์หรือค่าเฉลี่ยจำนวนเต็มของชุดตัวเลขที่กำหนดให้ ทีนี้มาเขียนแบบทดสอบกัน
ใน Go ถือเป็นแนวทางปฏิบัติที่ดีที่สุดในการตั้งชื่อไฟล์ทดสอบด้วยชื่อเดียวกับไฟล์ที่มีโค้ดที่กำลังทดสอบ โดยมีคำต่อท้าย _test
ที่เพิ่มเข้ามา ตัวอย่างเช่น โค้ดด้านบนอยู่ในไฟล์ชื่อ avg.go
ดังนั้นไฟล์ทดสอบของเราจึงมีชื่อว่า avg_test.go
โปรดทราบว่าตัวอย่างเหล่านี้เป็นเพียงข้อความที่ตัดตอนมาจากไฟล์จริง เนื่องจากข้อกำหนดและการนำเข้าของแพ็คเกจถูกละเว้นเพื่อความง่าย
นี่คือการทดสอบฟังก์ชัน Avg
:
avg__test.go
func TestAvg(t *testing.T) { for _, tt := range []struct { Nos []int Result int }{ {Nos: []int{2, 4}, Result: 3}, {Nos: []int{1, 2, 5}, Result: 2}, {Nos: []int{1}, Result: 1}, {Nos: []int{}, Result: 0}, {Nos: []int{2, -2}, Result: 0}, } { if avg := Average(tt.Nos...); avg != tt.Result { t.Fatalf("expected average of %v to be %d, got %d\n", tt.Nos, tt.Result, avg) } } }
มีหลายสิ่งที่ควรทราบเกี่ยวกับการกำหนดฟังก์ชัน:
- ขั้นแรก คำนำหน้าของ 'Test' ในชื่อฟังก์ชันการทดสอบ นี่เป็นสิ่งจำเป็นเพื่อให้เครื่องมือได้รับการทดสอบที่ถูกต้อง
- ส่วนหลังของชื่อฟังก์ชันโดยทั่วไปคือชื่อของฟังก์ชันหรือเมธอดที่กำลังทดสอบ ในกรณีนี้คือ
Avg
- เรายังต้องผ่านในโครงสร้างการทดสอบที่เรียกว่า
testing.T
ซึ่งช่วยให้สามารถควบคุมการไหลของการทดสอบได้ สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับ API นี้ โปรดไปที่หน้าเอกสาร
ทีนี้มาพูดถึงรูปแบบที่เขียนตัวอย่างกัน ชุดทดสอบ (ชุดการทดสอบ) กำลังรันผ่านฟังก์ชัน Avg()
และการทดสอบแต่ละรายการมีอินพุตเฉพาะและเอาต์พุตที่คาดหวัง ในกรณีของเรา การทดสอบแต่ละครั้งจะส่งส่วนของจำนวนเต็ม ( Nos
) และคาดหวังค่าส่งคืนเฉพาะ ( Result
)
การเยาะเย้ยอินเทอร์เฟซ Golang
หนึ่งในคุณสมบัติที่ยิ่งใหญ่ที่สุดและทรงพลังที่สุดที่ภาษา Go มีให้เรียกว่าอินเทอร์เฟซ นอกจากพลังและความยืดหยุ่นที่เราได้รับจากการเชื่อมต่อเมื่อสร้างโปรแกรมของเรา การเชื่อมต่อยังให้โอกาสที่ยอดเยี่ยมในการแยกส่วนประกอบของเราออกและทดสอบอย่างละเอียดที่จุดนัดพบ
ลองใช้สถานการณ์สมมติที่เราจำเป็นต้องอ่าน N ไบต์แรกจาก io.Reader และส่งคืนเป็นสตริง มันจะมีลักษณะดังนี้:
readn.go
// readN reads at most n bytes from r and returns them as a string. func readN(r io.Reader, n int) (string, error) { buf := make([]byte, n) m, err := r.Read(buf) if err != nil { return "", err } return string(buf[:m]), nil }
แน่นอน สิ่งสำคัญที่ต้องทดสอบคือฟังก์ชัน readN
เมื่อได้รับอินพุตต่างๆ จะส่งกลับเอาต์พุตที่ถูกต้อง สามารถทำได้ด้วยการทดสอบตาราง แต่มีประเด็นที่ไม่สำคัญอีกสองประการที่เราควรกล่าวถึง ซึ่งกำลังตรวจสอบว่า:
-
r.Read
ถูกเรียกด้วยบัฟเฟอร์ขนาด n -
r.Read
ส่งคืนข้อผิดพลาดหากมีการส่ง
เพื่อให้ทราบขนาดของบัฟเฟอร์ที่ส่งผ่านไปยัง r.Read
ตลอดจนควบคุมข้อผิดพลาดที่ส่งกลับ เราจำเป็นต้องจำลอง r
ที่ส่งผ่านไปยัง readN
หากเราดูเอกสาร Go ในประเภท Reader เราจะเห็นว่า io.Reader
อย่างไร:
type Reader interface { Read(p []byte) (n int, err error) }
ที่ดูค่อนข้างง่าย สิ่งที่เราต้องทำเพื่อตอบสนอง io.Reader
ก็คือต้องมีวิธีการ Read
จำลองของเราเอง ดังนั้น ReaderMock
ของเราจึงเป็นดังนี้:
type ReaderMock struct { ReadMock func([]byte) (int, error) } func (m ReaderMock) Read(p []byte) (int, error) { return m.ReadMock(p) }
มาวิเคราะห์โค้ดด้านบนกันสักหน่อย อินสแตนซ์ใดๆ ของ ReaderMock
ตอบสนองอินเทอร์เฟซ io.Reader
ได้อย่างชัดเจน เนื่องจากใช้วิธีการ Read
ที่จำเป็น ม็อคของเรายังมีฟิลด์ ReadMock
ซึ่งช่วยให้เราสามารถกำหนดพฤติกรรมที่แน่นอนของวิธีการเยาะเย้ย ซึ่งทำให้ง่ายมากสำหรับเราที่จะสร้างอินสแตนซ์ของสิ่งที่เราต้องการแบบไดนามิกแบบไดนามิก
เคล็ดลับที่ยอดเยี่ยมที่ไม่ต้องใช้หน่วยความจำในการตรวจสอบให้แน่ใจว่าอินเทอร์เฟซใช้งานได้จริง คือการแทรกสิ่งต่อไปนี้ลงในโค้ดของเรา:
var _ io.Reader = (*MockReader)(nil)
การดำเนินการนี้จะตรวจสอบการยืนยันแต่ไม่ได้จัดสรรอะไรเลย ซึ่งช่วยให้เราแน่ใจว่าอินเทอร์เฟซได้รับการติดตั้งอย่างถูกต้อง ณ เวลาคอมไพล์ ก่อน ที่โปรแกรมจะเรียกใช้ฟังก์ชันการทำงานใดๆ ก็ตามที่ใช้งานจริง เคล็ดลับที่ไม่บังคับ แต่มีประโยชน์
ต่อไปเรามาเขียนการทดสอบครั้งแรกของเราซึ่ง r.Read
ถูกเรียกด้วยบัฟเฟอร์ขนาด n
ในการดำเนินการนี้ เราใช้ ReaderMock
ของเราดังนี้:
func TestReadN_bufSize(t *testing.T) { total := 0 mr := &MockReader{func(b []byte) (int, error) { total = len(b) return 0, nil }} readN(mr, 5) if total != 5 { t.Fatalf("expected 5, got %d", total) } }
ดังที่คุณเห็นด้านบน เราได้กำหนดลักษณะการทำงานของฟังก์ชัน Read
ของ io.Reader
“ปลอม” ของเราด้วยตัวแปรขอบเขต ซึ่งสามารถนำไปใช้เพื่อยืนยันความถูกต้องของการทดสอบของเราได้ในภายหลัง ง่ายพอ
ลองดูสถานการณ์ที่สองที่เราต้องทดสอบ ซึ่งกำหนดให้เราต้องล้อเลียน Read
เพื่อส่งคืนข้อผิดพลาด:
func TestReadN_error(t *testing.T) { expect := errors.New("some non-nil error") mr := &MockReader{func(b []byte) (int, error) { return 0, expect }} _, err := readN(mr, 5) if err != expect { t.Fatal("expected error") } }
ในการทดสอบข้างต้น การเรียกใดๆ ไปที่ mr.Read
(โปรแกรมอ่านจำลองของเรา) จะส่งคืนข้อผิดพลาดที่กำหนดไว้ ดังนั้นจึงปลอดภัยที่จะถือว่าการทำงานที่ถูกต้องของ readN
จะทำเช่นเดียวกัน
ฟังก์ชันเยาะเย้ยด้วย Go
บ่อยครั้งที่เราจำเป็นต้องจำลองฟังก์ชัน เนื่องจากเรามักจะใช้โครงสร้างและอินเทอร์เฟซแทน สิ่งเหล่านี้ควบคุมได้ง่ายกว่า แต่บางครั้งเราก็สามารถเจอความจำเป็นนี้ได้ และฉันมักเห็นความสับสนในหัวข้อ บางคนถึงกับถามถึงวิธีการล้อเลียนสิ่งของอย่าง log.Println
แม้ว่าจะไม่ค่อยมีกรณีที่เราต้องทดสอบอินพุตที่กำหนดให้กับ log.Println
เราจะใช้โอกาสนี้เพื่อสาธิต

พิจารณาคำสั่ง if
ง่ายๆ ด้านล่างนี้ที่บันทึกเอาต์พุตตามค่าของ n
:
func printSize(n int) { if n < 10 { log.Println("SMALL") } else { log.Println("LARGE") } }
ในตัวอย่างข้างต้น เราถือว่าสถานการณ์ไร้สาระที่เราทดสอบเฉพาะ log.Println
ถูกเรียกด้วยค่าที่ถูกต้อง เพื่อให้เราสามารถจำลองฟังก์ชันนี้ได้ เราต้องล้อมฟังก์ชันนี้ไว้ในตัวของเราก่อน:
var show = func(v ...interface{}) { log.Println(v...) }
การประกาศฟังก์ชันในลักษณะนี้ - เป็นตัวแปร - ทำให้เราสามารถเขียนทับมันในการทดสอบของเรา และกำหนดพฤติกรรมใดๆ ที่เราต้องการให้กับมัน โดยปริยาย บรรทัดที่อ้างถึง log.Println
จะถูกแทนที่ด้วย show
ดังนั้นโปรแกรมของเราจึงกลายเป็น:
func printSize(n int) { if n < 10 { show("SMALL") } else { show("LARGE") } }
ตอนนี้เราสามารถทดสอบ:
func TestPrintSize(t *testing.T) { var got string oldShow := show show = func(v ...interface{}) { if len(v) != 1 { t.Fatalf("expected show to be called with 1 param, got %d", len(v)) } var ok bool got, ok = v[0].(string) if !ok { t.Fatal("expected show to be called with a string") } } for _, tt := range []struct{ N int Out string }{ {2, "SMALL"}, {3, "SMALL"}, {9, "SMALL"}, {10, "LARGE"}, {11, "LARGE"}, {100, "LARGE"}, } { got = "" printSize(tt.N) if got != tt.Out { t.Fatalf("on %d, expected '%s', got '%s'\n", tt.N, tt.Out, got) } } // careful though, we must not forget to restore it to its original value // before finishing the test, or it might interfere with other tests in our // suite, giving us unexpected and hard to trace behavior. show = oldShow }
Takeaway ของเราไม่ควรเป็น 'mock log.Println
' แต่ในสถานการณ์ที่เป็นครั้งคราวเมื่อเราจำเป็นต้องจำลองฟังก์ชันระดับแพ็คเกจด้วยเหตุผลที่ถูกต้อง วิธีเดียวที่จะทำเช่นนั้น (เท่าที่ฉันทราบ) คือ โดยการประกาศให้เป็นตัวแปรระดับแพ็คเกจเพื่อให้เราสามารถควบคุมค่าของมันได้
อย่างไรก็ตาม หากเราจำเป็นต้องเยาะเย้ยสิ่งต่าง ๆ เช่น log.Println
เราสามารถเขียนโซลูชันที่หรูหรากว่านี้ได้ถ้าเราใช้ตัวบันทึกแบบกำหนดเอง
ไปทดสอบการแสดงผลเทมเพลต
สถานการณ์สมมติทั่วไปอีกประการหนึ่งคือการทดสอบว่าผลลัพธ์ของเทมเพลตที่แสดงผลนั้นเป็นไปตามความคาดหวัง ลองพิจารณาคำขอ GET เพื่อ http://localhost:3999/welcome?name=Frank
ซึ่งส่งคืนเนื้อหาต่อไปนี้:
<html> <head><title>Welcome page</title></head> <body> <h1 class="header-name"> Welcome <span class="name">Frank</span>! </h1> </body> </html>
ในกรณีที่ไม่ชัดเจนเพียงพอในตอนนี้ ก็ไม่ใช่เรื่องบังเอิญที่ name
พารามิเตอร์การค้นหาตรงกับเนื้อหาของ span
ที่จัดประเภทเป็น "ชื่อ" ในกรณีนี้ การทดสอบที่ชัดเจนคือการตรวจสอบว่าสิ่งนี้เกิดขึ้นอย่างถูกต้องทุกครั้งในเอาต์พุตหลายรายการ ฉันพบว่าห้องสมุด GoQuery มีประโยชน์อย่างมากที่นี่
ตอนนี้เราสามารถเขียนแบบทดสอบของเราในลักษณะนี้:
welcome__test.go
func TestWelcome_name(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } doc, err := goquery.NewDocumentFromResponse(resp) if err != nil { t.Fatal(err) } if v := doc.Find("h1.header-name span.name").Text(); v != "Frank" { t.Fatalf("expected markup to contain 'Frank', got '%s'", v) } }
ขั้นแรก เราตรวจสอบว่ารหัสตอบกลับคือ 200/OK ก่อนดำเนินการต่อ
ฉันเชื่อว่าไม่ไกลเกินเอื้อมที่จะถือว่าข้อมูลโค้ดที่เหลือด้านบนอธิบายตนเองได้: เราดึง URL โดยใช้แพ็คเกจ http
และสร้างเอกสารที่เข้ากันได้กับ goquery ใหม่จากการตอบกลับ ซึ่งเราใช้เพื่อค้นหา DOM ที่ส่งคืน เราตรวจสอบว่า span.name
ภายใน h1.header-name
ข้อความ 'Frank'
การทดสอบ JSON APIs
Go มักใช้ในการเขียน API บางประเภท ดังนั้นสุดท้ายแต่ไม่ท้ายสุด มาดูวิธีการทดสอบ JSON API ในระดับสูงกัน
พิจารณาว่าจุดปลายเคยส่งคืน JSON แทนที่จะเป็น HTML หรือไม่ ดังนั้นจาก http://localhost:3999/welcome.json?name=Frank
เราคาดว่าเนื้อหาการตอบสนองจะมีลักษณะดังนี้:
{"Salutation": "Hello Frank!"}
การยืนยันการตอบสนองของ JSON อย่างที่ใครๆ ก็เดาได้ ไม่ได้แตกต่างไปจากการยืนยันการตอบกลับของเทมเพลตมากนัก ยกเว้นว่าเราไม่ต้องการไลบรารีหรือการพึ่งพาภายนอกใดๆ ไลบรารีมาตรฐานของ Go ก็เพียงพอแล้ว นี่คือการทดสอบของเราที่ยืนยันว่ามีการส่งคืน JSON ที่ถูกต้องสำหรับพารามิเตอร์ที่กำหนด:
welcome__test.go
func TestWelcome_name_JSON(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome.json?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != 200 { t.Fatalf("expected 200, got %d", resp.StatusCode) } var dst struct{ Salutation string } if err := json.NewDecoder(resp.Body).Decode(&dst); err != nil { t.Fatal(err) } if dst.Salutation != "Hello Frank!" { t.Fatalf("expected 'Hello Frank!', got '%s'", dst.Salutation) } }
หากมีสิ่งใดนอกเหนือจากโครงสร้างที่เราถอดรหัสถูกส่งคืน json.NewDecoder
จะส่งคืนข้อผิดพลาดและการทดสอบจะล้มเหลว เมื่อพิจารณาว่าการตอบสนองถอดรหัสกับโครงสร้างได้สำเร็จ เราจึงตรวจสอบว่าเนื้อหาของฟิลด์เป็นไปตามที่คาดไว้ - ในกรณีของเรา "สวัสดีแฟรงค์!"
ตั้งค่าและรื้อถอน
การทดสอบด้วย Go นั้นง่าย แต่มีปัญหาหนึ่งข้อกับทั้งการทดสอบ JSON ด้านบนและการทดสอบการแสดงเทมเพลตก่อนหน้านั้น พวกเขาทั้งคู่ถือว่าเซิร์ฟเวอร์กำลังทำงานอยู่ และสิ่งนี้จะสร้างการพึ่งพาที่ไม่น่าเชื่อถือ นอกจากนี้ ไม่ควรขัดแย้งกับเซิร์ฟเวอร์ "ใช้งานจริง"
โชคดีที่ Go มีแพ็คเกจ httptest เพื่อสร้างเซิร์ฟเวอร์ทดสอบ การทดสอบจุดประกายให้เซิร์ฟเวอร์แยกจากกัน เป็นอิสระจากเซิร์ฟเวอร์หลักของเรา ดังนั้นการทดสอบจะไม่รบกวนการทำงานจริง
ในกรณีเหล่านี้ เหมาะเป็นอย่างยิ่งที่จะสร้าง setup
ทั่วไปและฟังก์ชั่นการ teardown
ส่วนเพื่อเรียกใช้โดยการทดสอบทั้งหมดที่ต้องใช้เซิร์ฟเวอร์ที่ทำงานอยู่ ตามรูปแบบใหม่ที่ปลอดภัยกว่านี้ การทดสอบของเราจะมีลักษณะดังนี้:
func setup() *httptest.Server { return httptest.NewServer(app.Handler()) } func teardown(s *httptest.Server) { s.Close() } func TestWelcome_name(t *testing.T) { srv := setup() url := fmt.Sprintf("%s/welcome.json?name=Frank", srv.URL) resp, err := http.Get(url) // verify errors & run assertions as usual teardown(srv) }
จดบันทึกการอ้างอิง app.Handler()
นี่คือฟังก์ชันแนวปฏิบัติที่ดีที่สุดที่ส่งคืน http.Handler ของแอปพลิเคชัน ซึ่งสามารถสร้างอินสแตนซ์ของเซิร์ฟเวอร์ที่ใช้งานจริงหรือเซิร์ฟเวอร์ทดสอบได้
บทสรุป
การทดสอบใน Go เป็นโอกาสที่ดีที่จะได้ใช้มุมมองภายนอกของโปรแกรมและทดสอบกับผู้เยี่ยมชมของคุณ หรือในกรณีส่วนใหญ่ ผู้ใช้ API ของคุณ เป็นโอกาสที่ดีในการตรวจสอบให้แน่ใจว่าคุณได้ส่งโค้ดที่ดีและได้รับประสบการณ์ที่มีคุณภาพ
เมื่อใดก็ตามที่คุณไม่แน่ใจเกี่ยวกับฟังก์ชันที่ซับซ้อนมากขึ้นในโค้ดของคุณ การทดสอบก็มีประโยชน์เพื่อให้เกิดความมั่นใจ และยังรับประกันว่าชิ้นส่วนต่างๆ จะทำงานร่วมกันได้ดีเมื่อทำการปรับเปลี่ยนส่วนต่างๆ ของระบบที่ใหญ่ขึ้น
ฉันหวังว่าบทความนี้จะเป็นประโยชน์สำหรับคุณ และคุณสามารถแสดงความคิดเห็นได้หากคุณทราบเทคนิคการทดสอบอื่นๆ