การทดสอบแอป Your Go: เริ่มต้นอย่างถูกวิธี

เผยแพร่แล้ว: 2022-03-11

เมื่อเรียนรู้สิ่งใหม่ ๆ สิ่งสำคัญคือต้องมีสภาพจิตใจที่สดใหม่

หากคุณเพิ่งเริ่มใช้ Go และมาจากภาษาต่างๆ เช่น JavaScript หรือ Ruby คุณน่าจะคุ้นเคยกับการใช้เฟรมเวิร์กที่มีอยู่ซึ่งช่วยให้คุณล้อเลียน ยืนยัน และทำการทดสอบอื่นๆ ได้

กำจัดแนวคิดของการพึ่งพาการพึ่งพาหรือเฟรมเวิร์กภายนอกให้หมดไป! การทดสอบเป็นอุปสรรคแรกที่ฉันสะดุดเมื่อเรียนรู้ภาษาการเขียนโปรแกรมที่น่าทึ่งนี้เมื่อสองสามปีก่อน ซึ่งเป็นช่วงที่มีทรัพยากรน้อยกว่ามาก

ตอนนี้ฉันรู้แล้วว่าการทดสอบความสำเร็จใน Go หมายถึงการเดินทางที่เบาบนการพึ่งพา (เช่นเดียวกับทุกสิ่ง Go) อาศัยไลบรารีภายนอกเพียงเล็กน้อย และการเขียนโค้ดที่ใช้ซ้ำได้ดี การนำเสนอประสบการณ์ของ Blake Mizerany ที่เกิดขึ้นกับไลบรารีทดสอบของบุคคลที่สามเป็นการเริ่มต้นที่ดีในการปรับความคิดของคุณ คุณจะเห็นข้อโต้แย้งที่ดีบางประการเกี่ยวกับการใช้ไลบรารีและเฟรมเวิร์กภายนอกกับการทำแบบ "Go way"

ต้องการเรียนรู้ Go? ดูบทแนะนำ Golang ของเรา

มันอาจจะดูขัดกับสัญชาตญาณในการสร้างกรอบการทดสอบของคุณเองและแนวคิดล้อเลียน แต่ง่ายกว่าที่คุณคิด และเป็นจุดเริ่มต้นที่ดีสำหรับการเรียนรู้ภาษา นอกจากนี้ คุณมีบทความนี้ที่จะแนะนำคุณเกี่ยวกับสถานการณ์การทดสอบทั่วไป เช่นเดียวกับการแนะนำเทคนิคต่างๆ ที่ฉันพิจารณาว่าเป็นแนวทางปฏิบัติที่ดีที่สุดสำหรับการทดสอบอย่างมีประสิทธิภาพและการรักษาโค้ดให้สะอาด ซึ่งแตกต่างจากตอนที่ฉันเรียนรู้

ทำสิ่งต่าง ๆ “วิถีแห่งการเดินทาง” ขจัดการพึ่งพาเฟรมเวิร์กภายนอก
ทวีต

การทดสอบตารางใน 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 มีประโยชน์อย่างมากที่นี่

GoQuery ใช้ API ที่เหมือน jQuery เพื่อสืบค้นโครงสร้าง HTML ซึ่งจำเป็นสำหรับการทดสอบความถูกต้องของผลลัพธ์ของมาร์กอัปของโปรแกรมของคุณ

ตอนนี้เราสามารถเขียนแบบทดสอบของเราในลักษณะนี้:

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 ของคุณ เป็นโอกาสที่ดีในการตรวจสอบให้แน่ใจว่าคุณได้ส่งโค้ดที่ดีและได้รับประสบการณ์ที่มีคุณภาพ

เมื่อใดก็ตามที่คุณไม่แน่ใจเกี่ยวกับฟังก์ชันที่ซับซ้อนมากขึ้นในโค้ดของคุณ การทดสอบก็มีประโยชน์เพื่อให้เกิดความมั่นใจ และยังรับประกันว่าชิ้นส่วนต่างๆ จะทำงานร่วมกันได้ดีเมื่อทำการปรับเปลี่ยนส่วนต่างๆ ของระบบที่ใหญ่ขึ้น

ฉันหวังว่าบทความนี้จะเป็นประโยชน์สำหรับคุณ และคุณสามารถแสดงความคิดเห็นได้หากคุณทราบเทคนิคการทดสอบอื่นๆ