Menu

Testing ב-Golang: בדיקות יחידה, בדיקות טבלה ו-benchmarks

איך בודקים קוד Go עם החבילה הסטנדרטית testing ועם go test: קובצי _test.go, פונקציות TestXxx, t.Errorf מול t.Fatalf, בדיקות מבוססות טבלה עם t.Run, פונקציות עזר ותיקיות זמניות, כיסוי, benchmarks עם b.Loop ובדיקות example.

בדף הזה יש עורכים שאפשר להריץ - לערוך, להריץ ולראות את הפלט מיד.

הקוד שנבדק

כלי הבדיקה של Go הם חלק מה-toolchain הסטנדרטי: החבילה testing והפקודה go test. אין framework להתקין, ולא צריך ספריית assertions.

הדוגמאות בדף הזה בודקות פונקציה קטנה אחת, Slugify, שהופכת כותרת ל-slug של URL. הנה היא, מוכנה להרצה, עם בדיקה שנכתבה ביד ב-main:

העורך בדפדפן מריץ את main; הוא לא יכול להריץ go test. כל מה שבהמשך כתוב כמו שהוא נראה בפרויקט אמיתי, עם הפלט של הטרמינל אחרי כל פקודה. הכול הורץ עם Go 1.24.

הבדיקה הראשונה

בפרויקט הפונקציה נמצאת בחבילה, והבדיקות שלה נמצאות לידה בקובץ ששמו מסתיים ב-_test.go:

textutil/
├── go.mod          module example.com/textutil
├── slug.go         package textutil, func Slugify
└── slug_test.go    package textutil, the tests
// slug_test.go
package textutil

import "testing"

func TestSlugify(t *testing.T) {
	got := Slugify("Hello, World!")
	want := "hello-world"
	if got != want {
		t.Errorf("Slugify(%q) = %q, want %q", "Hello, World!", got, want)
	}
}

הכללים ש-go test משתמשת בהם:

  • רק קבצים שמסתיימים ב-_test.go הם קובצי בדיקה. go build מתעלמת מהם, כך שקוד בדיקות אף פעם לא מגיע לקובץ הבינארי.
  • בדיקה היא פונקציה בשם TestXxx (החלק שאחרי Test לא יכול להתחיל באות קטנה) שמקבלת *testing.T אחד.
  • בדיקה עוברת אלא אם היא קוראת לאחת ממתודות הכישלון או גורמת ל-panic.
go test          # the package in the current directory
go test ./...    # every package in the module
$ go test
PASS
ok  	example.com/textutil	0.318s

הפורמט של הודעת הכישלון Func(input) = got, want expected הוא המוסכמה ב-Go. שימו את הקלט בהודעה: כישלון שבו כתוב רק got "a", want "b" שולח אתכם בחזרה לקוד כדי לגלות איזה קלט זה היה.

t.Errorf מול t.Fatalf

מתודהמסמנת כישלוןעוצרת את הבדיקה
t.Error, t.Errorfכןלא, ממשיכה לרוץ
t.Fatal, t.Fatalfכןכן, מיד
t.Log, t.Logfלאלא, מדפיסה רק עם -v או בכישלון
t.Skip, t.Skipfלאכן, מדווחת כבדיקה שדולגה

השתמשו ב-Errorf כברירת מחדל, כך שהרצה אחת מדווחת על כל שדה שגוי. השתמשו ב-Fatalf כששאר הבדיקה לא יכול לעבוד, בדרך כלל אחרי שגיאה לא צפויה:

func TestCountWords(t *testing.T) {
	path := writeFile(t, "doc.txt", "the quick brown\nfox  jumps\n")

	got, err := CountWords(path)
	if err != nil {
		t.Fatalf("CountWords: unexpected error: %v", err) // stop: got is meaningless
	}
	if got != 5 {
		t.Errorf("CountWords = %d, want 5", got)
	}
}

Fatal עוצרת את הבדיקה בעזרת קריאה ל-runtime.Goexit, ולכן חייבים לקרוא לה מה-goroutine של הבדיקה עצמה. מ-goroutine שהפעלתם בעצמכם, דווחו עם t.Error וחזרו.

בדיקות מבוססות טבלה עם t.Run

רוב הבדיקות ב-Go הן טבלאות: slice של מקרים, לולאה אחת, ו-t.Run כדי לתת לכל מקרה שם משלו.

func TestSlugifyTable(t *testing.T) {
	tests := []struct {
		name, in, want string
	}{
		{"simple", "Go Testing", "go-testing"},
		{"punctuation", "What's new in Go 1.24?", "what-s-new-in-go-1-24"},
		{"extra spaces", "  lots   of   space  ", "lots-of-space"},
		{"non-ascii dropped", "Café au lait", "caf-au-lait"},
		{"empty", "", ""},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Slugify(tt.in); got != tt.want {
				t.Errorf("Slugify(%q) = %q, want %q", tt.in, got, tt.want)
			}
		})
	}
}

הוספת מקרה היא שורה אחת. כל subtest מדווח בנפרד, Fatal בתוך אחד מסיים רק את ה-subtest הזה, ואפשר להריץ אחד בודד לפי השם שלו. הריצו הכול עם -v:

$ go test -v
=== RUN   TestSlugify
--- PASS: TestSlugify (0.00s)
=== RUN   TestSlugifyTable
=== RUN   TestSlugifyTable/simple
=== RUN   TestSlugifyTable/punctuation
=== RUN   TestSlugifyTable/extra_spaces
=== RUN   TestSlugifyTable/non-ascii_dropped
=== RUN   TestSlugifyTable/empty
--- PASS: TestSlugifyTable (0.00s)
    --- PASS: TestSlugifyTable/simple (0.00s)
    --- PASS: TestSlugifyTable/punctuation (0.00s)
    --- PASS: TestSlugifyTable/extra_spaces (0.00s)
    --- PASS: TestSlugifyTable/non-ascii_dropped (0.00s)
    --- PASS: TestSlugifyTable/empty (0.00s)
=== RUN   TestCountWords
--- PASS: TestCountWords (0.00s)
=== RUN   TestCountWordsMissingFile
--- PASS: TestCountWordsMissingFile (0.00s)
=== RUN   ExampleSlugify
--- PASS: ExampleSlugify (0.00s)
PASS
ok  	example.com/textutil	0.182s

רווחים בשמות של subtests הופכים לקווים תחתונים. כשמקרה נכשל, הפלט מציין את ה-subtest ואת השורה:

--- FAIL: TestSlugifyTable (0.00s)
    --- FAIL: TestSlugifyTable/underscore (0.00s)
        slug_test.go:27: Slugify("snake_case_name") = "snake-case-name", want "snake_case_name"
FAIL
FAIL	example.com/textutil	0.289s

מאז Go 1.22 לכל איטרציה של הלולאה יש tt משלה, כך ש-closures בתוך t.Run רואים את המקרה הנכון גם כש-subtests רצים במקביל. בקוד ישן יותר יש לעיתים קרובות tt := tt בתחילת גוף הלולאה מהסיבה הזאת; זה כבר לא נחוץ.

כדי להריץ subtests במקביל, קראו ל-t.Parallel() בתחילת פונקציית ה-subtest. עשו את זה רק כשהמקרים בלתי תלויים ואיטיים מספיק כדי שזה ישנה משהו.

דגלים של go test שתשתמשו בהם

פקודהמה היא עושה
go test -vמדפיסה את השם, התוצאה והפלט של t.Log של כל בדיקה
go test -run TestSlugifyמריצה בדיקות שהשמות שלהן מתאימים לביטוי הרגולרי
go test -run 'TestSlugifyTable/empty'מריצה subtest אחד
go test -count=1מתעלמת מתוצאות שמורות במטמון ומריצה שוב
go test -raceמריצה עם גלאי ה-data races
go test -shortאומרת לבדיקות ארוכות לדלג על עצמן (if testing.Short() { t.Skip() })
go test -failfastעוצרת אחרי הבדיקה הראשונה שנכשלת
go test -timeout 30sנכשלת אם ההרצה לוקחת יותר זמן (ברירת המחדל 10 דקות)
go test -coverמדפיסה כיסוי פקודות
go test -bench=.מריצה גם benchmarks

כשמציינים חבילות (go test ., go test ./...), go test שומרת במטמון תוצאות של חבילות שהקוד והקלטים שלהן לא השתנו, ומדפיסה אחריהן (cached). go test רגילה בלי ארגומנטים אף פעם לא משתמשת במטמון. המטמון עוקב אחרי הקבצים ומשתני הסביבה שבדיקה קוראת דרך החבילה os, אבל לא אחרי מצב חיצוני כמו מסד נתונים או שירות רשת, כך שבדיקה שתלויה באחד מהם יכולה לעבור מהמטמון כשעכשיו היא הייתה נכשלת; -count=1 מכריחה הרצה אמיתית.

פונקציות עזר, תיקיות זמניות וניקוי

הקריאה ל-writeFile ב-TestCountWords למעלה היא פונקציית עזר לבדיקות:

// writeFile is a test helper: t.Helper makes failures point at the caller.
func writeFile(t *testing.T, name, content string) string {
	t.Helper()
	path := filepath.Join(t.TempDir(), name) // removed automatically after the test
	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
		t.Fatalf("writing %s: %v", name, err)
	}
	return path
}
  • t.Helper() מסמנת את הפונקציה כפונקציית עזר, כך שכישלון מדווח בשורה בבדיקה שקראה לה, ולא בתוך פונקציית העזר.
  • t.TempDir() יוצרת תיקייה חדשה ומוחקת אותה כשהבדיקה נגמרת. בדיקות אף פעם לא צריכות לנקות קבצים בעצמן.
  • t.Cleanup(func() { ... }) רושמת כל פירוק אחר (סגירת שרת, מחיקת טבלה). פעולות הניקוי רצות אחרי הבדיקה וה-subtests שלה, האחרונה שנרשמה ראשונה.
  • t.Setenv("KEY", "value") מגדירה משתנה סביבה רק לבדיקה הזאת ומשחזרת אותו אחר כך.
  • t.Context() (Go 1.24) מחזירה context שמבוטל ממש לפני שפעולות הניקוי רצות.

קובצי fixtures של בדיקות נכנסים לתיקייה בשם testdata ליד הבדיקות. הכלי go מתעלם ממנה כחבילה, ובדיקות רצות כשתיקיית החבילה היא תיקיית העבודה, כך ש-os.ReadFile("testdata/input.json") עובדת.

בדיקת שגיאות ו-HTTP handlers

בדקו שגיאות עם errors.Is או errors.As, באותה צורה שהקוד הקורא היה עושה:

func TestCountWordsMissingFile(t *testing.T) {
	_, err := CountWords(filepath.Join(t.TempDir(), "nope.txt"))
	if !errors.Is(err, fs.ErrNotExist) {
		t.Errorf("err = %v, want fs.ErrNotExist", err)
	}
}

השוואת מחרוזות של שגיאות נשברת ברגע שמישהו מנסח הודעה מחדש.

ל-HTTP handlers, net/http/httptest נותנת ResponseWriter מזויף כדי שתוכלו לקרוא ל-handler ישירות, בלי רשת:

func TestHealth(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/health", nil)
	rec := httptest.NewRecorder()

	healthHandler(rec, req)

	if rec.Code != http.StatusOK {
		t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
	}
	if body := rec.Body.String(); body != "ok\n" {
		t.Errorf("body = %q, want %q", body, "ok\n")
	}
}

httptest.NewServer מפעילה שרת אמיתי על localhost לבדיקת קוד של לקוח. הדף על HTTP client משתמש בה בכל דוגמה.

כיסוי

$ go test -cover
PASS
coverage: 100.0% of statements
ok  	example.com/textutil	0.578s

$ go test -coverprofile=cover.out
$ go tool cover -func=cover.out
example.com/textutil/slug.go:11:	Slugify		100.0%
example.com/textutil/words.go:9:	CountWords	100.0%
total:					(statements)	100.0%

$ go tool cover -html=cover.out    # opens a browser with covered lines in green

כיסוי מראה אילו שורות אף פעם לא רצו, וזה שימושי כדי למצוא ענפים שלא נבדקו. מספר גבוה לא אומר שה-assertions טובות: בדיקה שקוראת לכל פונקציה ולא בודקת כלום מגיעה ל-100%.

Benchmarks

benchmark הוא func BenchmarkXxx(b *testing.B). Go 1.24 הוסיפה את b.Loop, שהיא עכשיו הצורה המומלצת:

func BenchmarkSlugify(b *testing.B) {
	for b.Loop() {
		Slugify("The Go Programming Language, 2nd Edition")
	}
}

b.Loop מריצה את הגוף כמה פעמים שצריך כדי לקבל מדידה יציבה, מוציאה מהמדידה קוד הכנה שלפני הלולאה, ומונעת מהמהדר להסיר את הקריאה באופטימיזציה. קוד שנכתב לפני Go 1.24 משתמש ב-for i := 0; i < b.N; i++, שעדיין עובד אבל דורש b.ResetTimer() אחרי הכנה יקרה ויכול להיות מוטעה על ידי dead-code elimination.

benchmarks לא רצים עם go test רגילה. בקשו אותם, ודלגו על הבדיקות הרגילות עם -run='^$':

$ go test -bench=. -benchmem -run='^$'
goos: darwin
goarch: arm64
pkg: example.com/textutil
cpu: Apple M4
BenchmarkSlugify-10    	 5061945	       238.1 ns/op	     168 B/op	       5 allocs/op
PASS
ok  	example.com/textutil	1.410s

העמודות הן: השם עם GOMAXPROCS בסופו, מספר האיטרציות שרצו, זמן לכל קריאה, בתים שהוקצו לכל קריאה, הקצאות לכל קריאה. המספרים תלויים במכונה; השוו הרצות על אותה מכונה. כדי להשוות באופן אמין לפני ואחרי שינוי, הריצו כל צד כמה פעמים עם -count=10 והעבירו את שני הפלטים ל-benchstat (golang.org/x/perf/cmd/benchstat).

גם examples הם בדיקות

פונקציית example מדפיסה משהו ומצהירה בהערה על הפלט הצפוי. go test מריצה אותה ונכשלת אם הפלט שונה, ו-go doc ו-pkg.go.dev מציגים אותה כתיעוד:

// example_test.go
package textutil_test

import (
	"fmt"

	"example.com/textutil"
)

func ExampleSlugify() {
	fmt.Println(textutil.Slugify("Hello, World!"))
	// Output: hello-world
}

שם החבילה textutil_test הופך את זה לבדיקה חיצונית: היא יכולה להשתמש רק ב-API המיוצא, כמו קורא אמיתי. קבצים כאלה יכולים לשבת באותה תיקייה של החבילה. בלי הערת // Output: ה-example מהודר אבל לא רץ. השתמשו ב-// Unordered output: כששורות יכולות להופיע בכל סדר.

Fuzzing בקצרה

Go 1.18 הוסיפה fuzz tests, שמייצרים קלטים כדי למצוא קריסות ואינווריאנטים שבורים:

func FuzzSlugify(f *testing.F) {
	f.Add("Hello, World!") // seed input
	f.Fuzz(func(t *testing.T, s string) {
		slug := Slugify(s)
		if strings.Contains(slug, "--") || strings.HasPrefix(slug, "-") {
			t.Errorf("Slugify(%q) = %q: bad hyphens", s, slug)
		}
	})
}

go test רגילה מריצה רק את קלטי ה-seed (ערכי f.Add וכל קובץ שמור תחת testdata/fuzz). go test -fuzz=FuzzSlugify ממשיכה לייצר קלטים חדשים עד שהיא מוצאת כישלון או שעוצרים אותה, ושומרת קלטים שנכשלו תחת testdata/fuzz כך שהם הופכים למקרי בדיקה רגילים.

טעויות נפוצות

  • קובץ בדיקה במקום הלא נכון או עם שם שגוי. הוא חייב להסתיים ב-_test.go ולשבת בתיקיית החבילה. slug_tests.go מהודר לתוך החבילה, ולא רץ כבדיקות.
  • אות קטנה אחרי Test. Testslugify היא לא בדיקה. TestSlugify ו-Test_slugify כן.
  • t.Fatal מ-goroutine אחרת. דווחו שם עם t.Error, וחכו ל-goroutine לפני שהבדיקה חוזרת.
  • הודעות בלי הקלט. ציינו מה הועבר, מה יצא ומה היה צפוי.
  • אמון במעבר שנשמר במטמון. השתמשו ב--count=1 כשבדיקה תלויה במשהו מחוץ לחבילה.
  • בדיקות שתלויות בסדר של בדיקות אחרות או במשתנים גלובליים משותפים. כל בדיקה צריכה להכין את המצב שלה בעצמה, ובשביל זה קיימות t.TempDir, t.Setenv ו-t.Cleanup.

שאלות נפוצות

איך כותבים בדיקת יחידה ב-Go?

צרו קובץ שמסתיים ב-_test.go באותה תיקייה של הקוד, ייבאו את testing, וכתבו פונקציה func TestName(t *testing.T) שקוראת לקוד שלכם ומדווחת על אי התאמות עם t.Errorf. הריצו go test בתיקייה הזאת, או go test ./... לכל המודול. לא צריך framework או ספריית assertions.

מה ההבדל בין t.Error לבין t.Fatal ב-Go?

t.Error ו-t.Errorf מסמנות את הבדיקה כנכשלת וממשיכות להריץ אותה, כך שהרצה אחת יכולה לדווח על כמה בעיות. t.Fatal ו-t.Fatalf מסמנות אותה כנכשלת ועוצרות את הבדיקה מיד. השתמשו ב-Fatal כשאין טעם להמשיך, למשל אחרי שגיאה לא צפויה שמשאירה את התוצאה לא שמישה.

איך מריצים בדיקה אחת ב-Go?

העבירו ביטוי רגולרי ל--run: go test -run TestSlugify מריצה כל בדיקה שהשם שלה מתאים, ו-go test -run 'TestSlugifyTable/punctuation' מריצה subtest אחד (רווחים בשמות של subtests הופכים לקווים תחתונים). הוסיפו -v כדי לראות את השם והתוצאה של כל בדיקה, ו--count=1 כדי לעקוף את המטמון של הבדיקות.

איך כותבים benchmark ב-Go?

כתבו func BenchmarkName(b *testing.B) בקובץ _test.go ושימו את הקוד למדידה בתוך לולאת for b.Loop() { ... } (Go 1.24; קוד ישן יותר משתמש ב-for i := 0; i < b.N; i++). הריצו עם go test -bench=. -benchmem, שמדווחת על ננו שניות, בתים והקצאות לכל פעולה.

איך רואים את כיסוי הבדיקות ב-Go?

go test -cover מדפיסה את אחוז הפקודות שהבדיקות הריצו. לפרטים, כתבו פרופיל עם go test -coverprofile=cover.out, ואז הריצו go tool cover -func=cover.out למספרים לפי פונקציה או go tool cover -html=cover.out כדי לראות בדפדפן שורות מכוסות ולא מכוסות.

איור של שפות התכנות ב-Coddy

ללמוד תכנות עם Coddy

להתחיל