Go: Go Testing
Go's standard library comes with a built-in testing إطار عمل—no third-party testing libraries or assert libraries are needed; you can write high-quality tests using just the basic
testingpackage.
Go's built-in testing toolchain is unique in the industry: go test automatically detects test functions, table-driven testing is a hallmark of the Go community, and benchmarks and code coverage are available right out of the box. In this lesson, you'll master all the core aspects of Go testing.
1. You will learn
testing.T: Unit Testing Basics- Table-Driven Tests
- Subtest (
t.Run) testing.BBenchmarkTestMain: Test entry point and setup/teardown- Coverage
go test -cover httptestHTTP Testing- Comprehensive Case Study: Payment Module Test Suite
2. A True Story of a Refactoring Engineer
(1) Pain point: Changing one دالة caused three modules to crash simultaneously
Alice is a واجهة خلفية engineer on the payments team. She has been asked to refactor the tax calculation logic in the payments module:
"I just changed the name of a field, thinking it would be fine. But after the release, 30% of the orders had incorrect tax calculations—the PM said we lost $5,000. Since we didn't test it, no one knew where they had made the change."
She opened the payment module's code and discovered that the entire project had zero test files:
// payment.go — no corresponding payment_test.go
func CalculateTax(amount float64, country string) float64 {
// No one knows if this function is correct—there are no tests
switch country {
case "US":
return amount * 0.08
case "CN":
return amount * 0.13
default:
return amount * 0.10
}
}
After running the test, Alice immediately spotted the problem—she had previously set the tax rate for "UK" to 20% instead of the correct VAT rate of 20% (it was actually 20%, but under the edge case where amount=0, it returned NaN).
(2) Go Solution: Built-in Testing Framework
// payment_test.go
package main
import "testing"
// Table-driven test
func TestCalculateTax(t *testing.T) {
tests := []struct {
name سلسلة
amount float64
country سلسلة
want float64
}{
{"US standard", 100.0, "US", 8.0},
{"CN standard", 100.0, "CN", 13.0},
{"UK default", 100.0, "UK", 10.0},
{"zero amount", 0.0, "US", 0.0},
{"negative amount", -50.0, "US", -4.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CalculateTax(tt.amount, tt.country)
if got != tt.want {
t.Errorf("CalculateTax(%v, %s) = %v, want %v",
tt.amount, tt.country, got, tt.want)
}
})
}
}
Run Test:
$ go test -v
=== RUN TestCalculateTax/US_standard
=== RUN TestCalculateTax/CN_standard
=== RUN TestCalculateTax/UK_default
=== RUN TestCalculateTax/zero_amount
=== RUN TestCalculateTax/negative_amount
--- PASS: TestCalculateTax (0.00s)
--- PASS: TestCalculateTax/US_standard (0.00s)
--- PASS: TestCalculateTax/CN_standard (0.00s)
--- PASS: TestCalculateTax/UK_default (0.00s)
--- PASS: TestCalculateTax/zero_amount (0.00s)
--- PASS: TestCalculateTax/negative_amount (0.00s)
PASS
ok payment 0.123s
(3) Results: With Testing vs. Without Testing
| Dimension | No test | Test |
|---|---|---|
| Rebuilding Confidence | Afraid to Change Even a Single Line | Run go test Immediately After Making Changes |
| Positioning Issues | User Error Reports After Launch | Development Phase Failures |
| Code Quality | Intuition | Data-Driven |
| Getting Started | Afraid to Make Changes | Once You've Made the Changes and Run the Tests, You Can Rest Easy |
| Return Costs | Manual Verification | Automation |
3. testing.T: Unit Testing Basics
(1) Test Function Rules
// Rules:
// 1. File name must end with _test.go
// 2. Function signature must be func TestXxx(t *testing.T)
// 3. Xxx must start with an uppercase letter
// math_test.go
package main
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2,3) = %d, want %d", got, want)
}
}
func TestSubtract(t *testing.T) {
got := Subtract(5, 3)
want := 2
if got != want {
t.Errorf("Subtract(5,3) = %d, want %d", got, want)
}
}
(2) Common Methods in testing.T
| Method | Action | continue? |
|---|---|---|
t.Log(args...) |
Print a log (displayed only when -v is specified) | ✅ |
t.Error(args...) |
Mark as failed + continue execution | ✅ |
t.Errorf(format, args...) |
Formatted error | ✅ |
t.Fatal(args...) |
Mark as failed + Stop the current test | ❌ |
t.Fatalf(format, args...) |
Formatted Fatal | ❌ |
t.Skip(args...) |
Skip this test | ✅ |
▶ Example: Four Ways to Write a Test Function
package main
import (
"fmt"
"testing"
)
// Function under test
func Divide(a, b float64) (float64, خطأ) {
if b == 0 {
return 0, ErrDivisionByZero
}
return a / b, nil
}
var ErrDivisionByZero = fmt.Errorf("division by zero")
// Style 1: Simple assertion
func TestDivideBasic(t *testing.T) {
result, err := Divide(10, 2)
if err != nil {
t.Fatalf("unexpected خطأ: %v", err)
}
if result != 5.0 {
t.Errorf("got %f, want %f", result, 5.0)
}
}
// Style 2: Error vs Fatal
func TestDivideByZero(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Fatal("expected خطأ, got nil")
}
if err.Error() != "division by zero" {
t.Errorf("wrong خطأ message: %v", err)
}
}
// Style 3: Table-driven test
func TestDivideTable(t *testing.T) {
tests := []struct {
name سلسلة
a, b float64
want float64
wantErr bool
}{
{"10/2", 10, 2, 5, false},
{"0/5", 0, 5, 0, false},
{"-6/3", -6, 3, -2, false},
{"1/0", 1, 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
if tt.wantErr {
if err == nil {
t.Error("expected خطأ")
}
return
}
if err != nil {
t.Fatalf("unexpected خطأ: %v", err)
}
if got != tt.want {
t.Errorf("got %f, want %f", got, tt.want)
}
})
}
}
4. Table-Driven Testing (Go's Signature Style)
(1) Standard Template
package main
import "testing"
func TestMax(t *testing.T) {
// 1. Define test table
tests := []struct {
name string // Test name (for subtests)
a, b int // Input
want int // Expected output
}{
{name: "first larger", a: 10, b: 3, want: 10},
{name: "second larger", a: 3, b: 10, want: 10},
{name: "equal", a: 5, b: 5, want: 5},
{name: "negative", a: -3, b: -10, want: -3},
{name: "zero", a: 0, b: 5, want: 5},
}
// 2. Iterate through table
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 3. Execute + assert
got := Max(tt.a, tt.b)
if got != tt.want {
t.Errorf("Max(%d, %d) = %d, want %d",
tt.a, tt.b, got, tt.want)
}
})
}
}
func Max(a, b int) int {
if a > b {
return a
}
return b
}
▶ Example: Table-driven + complex input
package main
import (
"testing"
)
// Function under test: validate password strength
type PasswordStrength int
const (
Weak PasswordStrength = iota
Medium
Strong
)
func CheckPassword(pwd string) PasswordStrength {
if len(pwd) < 6 {
return Weak
}
if len(pwd) >= 12 {
return Strong
}
return Medium
}
func TestCheckPassword(t *testing.T) {
tests := []struct {
name string
pwd string
want PasswordStrength
}{
{"short", "abc", Weak},
{"medium 6", "abcdef", Medium},
{"medium 8", "abcdefgh", Medium},
{"strong 12", "abcdefghijkl", Strong},
{"empty", "", Weak},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CheckPassword(tt.pwd)
if got != tt.want {
t.Errorf("CheckPassword(%q) = %v, want %v",
tt.pwd, got, tt.want)
}
})
}
}
5. testing.B Benchmark Test
(1) Benchmark Basics
package main
import (
"testing"
)
// Benchmark دالة: func BenchmarkXxx(b *testing.B)
func BenchmarkAdd(b *testing.B) {
a, c := 100, 200
for i := 0; i < b.N; i++ {
Add(a, c)
}
}
$ go test -bench=.
goos: darwin
goarch: amd64
pkg: example
BenchmarkAdd-8 1000000000 0.25 ns/op
PASS
ok example 0.3s
▶ Example: Comparing the performance of two سلسلة concatenation methods
// concat_test.go
package main
import (
"strings"
"testing"
)
func ConcatPlus(n int) string {
s := ""
for i := 0; i < n; i++ {
s += "a"
}
return s
}
func ConcatBuilder(n int) string {
var sb strings.Builder
sb.Grow(n)
for i := 0; i < n; i++ {
sb.WriteByte('a')
}
return sb.String()
}
func BenchmarkConcatPlus(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatPlus(1000)
}
}
func BenchmarkConcatBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatBuilder(1000)
}
}
$ go test -bench=. -benchmem
BenchmarkConcatPlus-8 13134 91238 ns/op 530296 allocs/op
BenchmarkConcatBuilder-8 283321 4221 ns/op 56 allocs/op
+ 1,000 times is 20 times slower than using the Builder and allocates 10,000 times more memory—the -benchmem option lets you see the difference in memory allocation.
(3) Interpreting Benchmark Results
| Output Item | Meaning |
|---|---|
BenchmarkConcatBuilder-8 |
Test Name-8 (8 CPUs) |
283321 |
b.N = 283321 iterations |
4221 ns/op |
4221 nanoseconds per operation |
56 allocs/op |
56 memory allocations per operation |
6. TestMain: Test Entry Point
(1) TestMain: setup / teardown
// main_test.go
package main
import (
"fmt"
"os"
"testing"
)
func TestMain(m *testing.M) {
// Setup (runs once for the entire package)
fmt.Println("=== Setup: Initializing قاعدة بيانات connection ===")
// Run all tests
code := m.Run()
// Teardown
fmt.Println("=== Teardown: Closing قاعدة بيانات connection ===")
os.Exit(code)
}
func TestSomething(t *testing.T) {
t.Log("Test A")
}
func TestAnother(t *testing.T) {
t.Log("Test B")
}
$ go test -v
=== Setup: Initializing database connection ===
=== RUN TestSomething
main_test.go:16: Test A
--- PASS: TestSomething (0.00s)
=== RUN TestAnother
main_test.go:20: Test B
--- PASS: TestAnother (0.00s)
=== Teardown: Closing database connection ===
PASS
ok example 0.1s
▶ Example: testing.Helper helper دالة
package main
import "testing"
func Add(a, b int) int { return a + b }
func assertEqual(t testing.TB, got, want interface{}) {
t.Helper()
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}
func TestWithHelper(t *testing.T) {
assertEqual(t, Add(2, 3), 5)
assertEqual(t, Add(0, 0), 0)
assertEqual(t, Add(-1, 1), 0)
}
t.Helper() ensures that error messages are traced back to the caller's line number, rather than within the helper function itself. This is a key best practice when writing test utility functions.
7. Coverage
(1) Basis for Coverage
// math.go
package main
func IsEven(n int) bool {
return n%2 == 0
}
func IsPositive(n int) bool {
return n > 0
}
// math_test.go
package main
import "testing"
func TestIsEven(t *testing.T) {
tests := []struct {
n int
want bool
}{
{2, true},
{3, false},
}
for _, tt := range tests {
if got := IsEven(tt.n); got != tt.want {
t.Errorf("IsEven(%d) = %v", tt.n, got)
}
}
}
$ go test -coverprofile=coverage.out
ok example 0.1s coverage: 50.0% of statements
$ go tool cover -html=coverage.out # Open coverage report in browser
(2) go test -cover: Common Commands
| Command | Function |
|---|---|
go test -cover |
Coverage displayed in the terminal |
go test -coverprofile=c.out |
Output coverage profile file |
go tool cover -html=c.out |
View the visual report in a browser |
go test -covermode=count |
Records the number of times each line is executed |
8. httptest HTTP Testing
(1) httptest.Server + httptest.ResponseRecorder
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// Handler under test
type UserHandler struct{}
func (h UserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
resp := map[string]string{"status": "ok", "message": "hello"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// Test 1: httptest.ResponseRecorder (testing the handler itself)
func TestUserHandler(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
handler := UserHandler{}
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("got status %d, want %d", rec.Code, http.StatusOK)
}
var resp map[string]string
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp["status"] != "ok" {
t.Errorf("got status %q, want %q", resp["status"], "ok")
}
}
// Test 2: httptest.Server (testing a complete HTTP service)
func TestUserHandlerWithServer(t *testing.T) {
server := httptest.NewServer(UserHandler{})
defer server.Close()
resp, err := http.Get(server.URL)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("got %d, want %d", resp.StatusCode, http.StatusOK)
}
}
▶ Example: httptest + table-driven
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// Handler under test
func greetingHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "World"
}
resp := map[string]string{"message": "Hello, " + name + "!"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func TestGreetingHandler(t *testing.T) {
tests := []struct {
name string
query string
want string
status int
}{
{"with name", "name=Alice", "Hello, Alice!", 200},
{"empty name", "", "Hello, World!", 200},
{"with special chars", "name=Go+Lang", "Hello, Go Lang!", 200},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/?"+tt.query, nil)
rec := httptest.NewRecorder()
greetingHandler(rec, req)
if rec.Code != tt.status {
t.Errorf("status = %d, want %d", rec.Code, tt.status)
}
var resp map[string]string
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp["message"] != tt.want {
t.Errorf("message = %q, want %q", resp["message"], tt.want)
}
})
}
}
(3) Two modes of httptest
| Mode | httptest.NewRecorder | httptest.NewServer |
|---|---|---|
| Test Subject | Single Handler | Complete HTTP Service |
| Startup overhead | None | Yes (listens on a random port) |
| Use Cases | Unit Testing | Integration Testing |
| Can middleware be tested? | ✅ Manually constructed | ✅ Automatically through the entire pipeline |
9. Complete Example: Refactoring the Payment Module Test Suite
// payment_test.go
package main
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// ---------- Code under test ----------
type PaymentRequest struct {
UserID string `json:"user_id"`
Amount float64 `json:"amount"`
Country string `json:"country"`
}
type PaymentResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Tax float64 `json:"tax,omitempty"`
Total float64 `json:"total,omitempty"`
}
func CalculateTax(amount float64, country string) (float64, error) {
if amount < 0 {
return 0, errors.New("negative amount")
}
switch country {
case "US":
return amount * 0.08, nil
case "CN":
return amount * 0.13, nil
case "DE":
return amount * 0.19, nil
default:
return amount * 0.10, nil
}
}
func paymentHandler(w http.ResponseWriter, r *http.Request) {
var req PaymentRequest
json.NewDecoder(r.Body).Decode(&req)
tax, err := CalculateTax(req.Amount, req.Country)
if err != nil {
json.NewEncoder(w).Encode(PaymentResponse{
Success: false,
Message: err.Error(),
})
return
}
json.NewEncoder(w).Encode(PaymentResponse{
Success: true,
Tax: tax,
Total: req.Amount + tax,
})
}
// ---------- Test code ----------
func jsonBody(s string) *strings.Reader {
return strings.NewReader(s)
}
// 1. Unit test: CalculateTax table-driven
func TestCalculateTax(t *testing.T) {
tests := []struct {
name string
amount float64
country string
want float64
wantErr bool
}{
{"US $100", 100, "US", 8.0, false},
{"CN $100", 100, "CN", 13.0, false},
{"DE $100", 100, "DE", 19.0, false},
{"UK default", 100, "UK", 10.0, false},
{"zero amount", 0, "US", 0, false},
{"negative amount", -100, "US", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := CalculateTax(tt.amount, tt.country)
if tt.wantErr {
if err == nil {
t.Error("expected error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("CalculateTax(%v, %s) = %v, want %v",
tt.amount, tt.country, got, tt.want)
}
})
}
}
// 2. Benchmark: different ways to calculate tax
func BenchmarkCalculateTax(b *testing.B) {
for i := 0; i < b.N; i++ {
CalculateTax(100.0, "US")
}
}
// 3. HTTP test: paymentHandler
func TestPaymentHandler(t *testing.T) {
tests := []struct {
name string
body string
wantStatus int
wantSuccess bool
}{
{"US payment", `{"user_id":"u1","amount":100,"country":"US"}`, 200, true},
{"CN payment", `{"user_id":"u2","amount":200,"country":"CN"}`, 200, true},
{"negative amount", `{"user_id":"u3","amount":-50,"country":"US"}`, 200, false},
{"invalid JSON", `not json`, 200, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "/pay",
jsonBody(tt.body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
paymentHandler(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
var resp PaymentResponse
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Success != tt.wantSuccess {
t.Errorf("success = %v, want %v", resp.Success, tt.wantSuccess)
}
})
}
}
sequenceDiagram
participant Dev
participant Terminal as go test
participant Package as Package Under Test
participant Coverage as coverage.out
Dev->>Terminal: go test -v -cover
Terminal->>Package: 1. Find *_test.go
Terminal->>Package: 2. Call TestMain(m)
Package->>Package: 3. Setup
Package->>Package: 4. Execute all TestXxx
Package->>Package: 5. Execute BenchmarkXxx
Package->>Package: 6. Teardown
Package-->>Terminal: PASS / FAIL
Terminal-->>Coverage: Coverage data
Terminal-->>Dev: Result summary
_test.go extension; otherwise, go test will not run. The function signature must strictly be func TestXxx(t *testing.T)—the parameter is *testing.T, not *testing.TT or testing.T.
❓ FAQ
testing package?testing.T (unit tests), testing.B (benchmark tests), testing.M (test entry point), testing.Helper() (helper function annotation), testing.Short() (skips long tests).[]struct as the test table, where each case contains name + input + expected output. Iterate through the table and use t.Run(tt.name, ...) to execute subtests. The Go community considers this the standard way to write tests.go test -bench=. runs all benchmarks; go test -bench=FuncName runs a specific function; -benchmem displays memory allocation information. b.N is automatically determined by the framework.go test -cover displays the percentage; go test -coverprofile=c.out outputs a file; go tool cover -html=c.out provides a browser-based visualization. The standard is 70%+, and 90%+ is recommended for core logic.httptest.NewRecorder() to test the handler directly (unit testing), and httptest.NewServer(handler) to start a real HTTP server for testing (integration testing). We recommend using the Recorder first.m.Run(), and Teardown is performed afterward. Note: Output is cached, so you must use os.Exit(code) to exit.go test cache?go test caches test results (based on the code and environment). If there are no changes when you run it again, it displays (cached). Use go test -count=1 to force a re-run.t.Skip("reason") skips the current test; testing.Short() works with go test -short to skip tests that take too long; t.Skipf(format, args...) for formatted skips.📖 Summary
- Go includes a built-in
testingpackage, so no third-party testing framework is needed - Table-driven testing is a hallmark of the Go community:
[]struct+t.Run - The
t.Runsub-test provides a clear hierarchy of test results testing.Bbenchmark:b.Nauto-adjusts,-benchmemchecks memoryTestMain(m)provides package-level setup and teardowngo test -covermeasures line coveragehttptest.NewRecorderandhttptest.NewServerfor testing HTTP- Test file named
_test.go, function signaturefunc TestXxx(t *testing.T)
📝 Exercises
-
Basic Problem (Difficulty ⭐): Write a table-driven test for
max(nums ...int) intfrom Lesson 4 (Functions), covering the following five cases: positive numbers, negative numbers, a mix of positive and negative numbers, a single element, and empty arguments. -
Advanced Exercise (Difficulty ⭐⭐): Write tests for the
Exporterfrom Lesson 10 (File I/O and JSON): Useos.CreateTempto create a temporary file to test the export; usejson.Unmarshalto verify that the content is correct; Uset.Cleanupto clean up the temporary file. -
Challenge (Difficulty: ⭐⭐⭐): Write a complete test suite for the multi-payment gateway system from Lesson 7: (1) Table-driven testing for
MockPaymentGateway(returns fixed results); (2) Benchmark the speed of the Stripe and PayPal implementations; (3) Usehttptestto test the JSON response format of the payment handler; (4) Code coverage ≥ 90%.