深入理解与使用go之测试--实现

深入理解与使用go之测试–实现

引子

测试是一种重要的开发实践,用于确保代码的正确性和稳定性,通过编写全面的单元测试和集成测试,你可以增强代码的可靠性、可维护性和可扩展性,以及更好地理解代码的行为和边界条件。测试是项目生命周期的一个关键方面, 与其他一些语言相比,Go具有很强的编写测试原语。那么有以下几个问题

  • go中的测试有几种
  • 单元测试和集成测试是怎么区分的
  • 你会区分环境(测试/生产)进行测试么
  • 你的基准测试一定是正确的么
  • 有哪些测试包可以提升我们的工作效率

写过代码的人都知道,我们最强大的测试方法是啥?

func add(a, b int) int {
    return a + b
}

测试

func main() {
    fmt.Println(add(3,4))
}

输出结果

7

得嘞您呐,齐活!本文结束

是不是一把打印 走天下,但是随着项目越来越庞大,开发人员越来越多,你负责的模块你是打印成功了,如果别人不小心改动你的依赖关系模块,别人不会再来逐一打印你的结果,那怎么办?

带着上面的这些问题,我们来讨论讨论今天要说的测试。

测试

测试的分类

老规矩,讲测试之前,我们先要搞清楚,我们go里面的测试有哪些分类

  1. TestFuncName 命名的函数名,且文件以 _test 结尾的测试
    • 单元测试
    • 集成测试
    • 数据竞争测试
    • 代码覆盖测试
  2. BenchmarkFuncName命名的函数名,且文件以 _test 结尾的测试
    • 基准测试

有了这些分类,我们来一个一个看

TestFuncName 测试

单元测试

单元测试是针对代码中最小的可测试单元(函数、方法、类型等)的测试。它们旨在验证单个单元的行为是否正确。单元测试通常是独立于其他代码的,并且可以快速运行

我们举上面的栗子 add_test.go

func TestAdd(t *testing.T) {
	r := Add(3, 4)
	if r != 7 {
		t.Fail()
	}
}

进入目录,执行

# go test . -v
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
集成测试

比如,我们有加法,有减法,有乘法等等项目

func Add(a, b int) int {
	return a + b
}

func Sub(a, b int) int {
	return a - b
}

func Multi(a, b int) int {
	return a * b
}

分布在三个不同的模块里,一般有两种办法

  1. 单独构建一个集成测试文件夹

    ├── tests
    │   ├── add_test.go
    │   ├── sub_test.go
    │   └── multi_test.go
    

    进入文件夹

    go test ./... -v
    
  2. 每个模块各自写集成测试文件

    ├── aa
    │   ├── add.go
    │   ├── add_test.go
    ├── bb
    │   ├── sub.go
    │   ├── sub_test.go
    ├── cc
    │   ├── multi.go
    │   ├── multi_test.go
    

    在项目根目录下执行

    go test ./... -v
    

这里是不是一脸懵,放单独文件夹还好理解,放各自模块那不跟单元测试混淆了么,怎么办呢,有三种方式

  • 添加测试构建标签

    我们可以在测试文件 add_test.go 的顶部添加 //go:build integration 并且空一行,下一行即为正常包名

    //go:build integration
    
    package aa
    

    注意:从1.17开始 //+build 就被 //go:build 替代,go1.18 gofmt 目前为了过渡两种都支持

    然后,我们在执行的时候,指定标签

    go test -v -tags=integration ./...
    
  • 使用环境变量

    func TestOther(t *testing.T) {
        if os.Getenv("INTEGRATION") != "true" {
            t.Skip("skipping integration test")
        }
        // ...
    }
    

    然后执行

    export INTEGRATION=true && go test -v ./...
    

    注意:使用环境变量测试通常可用于区分测试与生产环境的测试,比如有些资源操作,我们可以跳过生产测试

    func TestInsert(t *testing.T) {
        if os.Getenv("ENV") == "prod" {
            t.Skip("ptod skipping insert test")
        }
        // ...
    }
    func TestDelete(t *testing.T) {
        if os.Getenv("ENV") == "prod" {
            t.Skip("prod skipping delete test")
        }
        // ...
    }
    
  • 使用short模式

    func TestOther(t *testing.T) {
        if testing.Short() {
            t.Skip("skipping other test")
        }
        // ...
    }
    

    然后打印:

    go test -v -short ./...
    

    注意:短模式的一个重要用途就是有些执行时间很长的测试,如果我们每次测试都跑,其实是很浪费时间的,初始测试的时候跑成功了,在日常的频繁测试中,我们可以排除此类测试

    func TestLongTimeFunc(t *testing.T) {
        if testing.Short() {
            t.Skip("skipping long time func test")
        }
        // ...
    }
    
  • 函数名包含指定字符串

    func TestIntegrationAdd(t *testing.T) {
        // ...
    }
    

    执行

    go test -v -run Integration ./...
    
数据竞争测试

我们在很多并发的编程中,需要做数据竞争测试,这些对于我们开发时更早发现并发写等造成脏数据并及时修复提供了便捷

假设我们有下面的方法

package ti
func Add(a, b int) int {
	i := 0
	go func() { i++ }()
	return a + b + i
}

然后我们测试如下

//go:build unit

package ti
import "testing"
func TestAdd(t *testing.T) {
	r := Add(3, 4)
	if r != 8 {
		t.Fail()
	}
}

执行 -race 是检测数据竞争的标记 开启即会进行检查

go test -tags=unit -race ./...

结果如下

==================
WARNING: DATA RACE
Write at 0x00c00001c258 by goroutine 7:
  hello-world/ti.Add.func1()
      /data/www/hello-world/ti/add.go:4 +0x44

Previous read at 0x00c00001c258 by goroutine 6:
  hello-world/ti.Add()
      /data/www/hello-world/ti/add.go:5 +0xd8
----
==================
--- FAIL: TestAdd (0.00s)
FAIL
FAIL    hello-world/ti  0.364s
FAIL

很明显,不同的goroutine 6和7产生了数据竞争,需要注意的是

  • 启用数据竞争检测,内存使用可能增长5到10倍
  • 启用数据竞争检测,执行时间可能增长2到20倍
  • 检测只能检测到我们的代码可以捕获潜在的数据竞争,比如我们加锁或者其他同步策略
  • 尽量避免在生产环境去做竞争检测
代码覆盖度测试

我们以上面的 testAdd 为例,进入该目录执行

go test -tags=unit -coverprofile=coverage.out ./...

在执行

go tool cover -html=coverage.out

就会跳转到浏览器

在这里插入图片描述

看着很明显,覆盖度是 66.7%, 其中绿色的是被覆盖的,红色的则没有

自从go1.20开始,集成了内建的覆盖度测试,上面的测试只能,一个一个的进行包内测试,不是很方便,我们试用一下新功能

假设我们几个文件

├── add
│   └── add.go
└── sub
|   └── sub.go
├── go.mod
├── main.go

add.go

package add

func Add(a, b int) int {
	return a + b
}

func AddSqrt(a, b int) int {
	return a*a + b*b
}

sub.go

package sub

func Sub(a, b int) int {
	return a - b
}

main.go

import (
	"fmt"
	"hello.com/hello-cover/add"
	"hello.com/hello-cover/sub"
)

func main() {
	res := add.Add(1, 1)
	fmt.Println(res)
	res = sub.Sub(10, 9)
	fmt.Println(res)
}

然后执行

# 构建
go build -cover
# 设置覆盖度数据保存目录 并执行
export GOCOVERDIR=./coverdata && ./hello-cover
# 打印展示 coverdata 是上一条命令的目录
go tool covdata percent -i=coverdata -o=covdata.txt

我们看看命令执行屏幕最终输出结果

hello.com/hello-cover   coverage: 100.0% of statements
hello.com/hello-cover/add       coverage: 50.0% of statements
hello.com/hello-cover/sub       coverage: 100.0% of statements

其中 add包 只覆盖了 50%,符合我们的预期,因为有一个函数我们没有调用

BenchmarkFuncName 测试

基准测试
  1. 基准测试示例

基准测试一般用于测试性能

func BenchmarkAdd(b *testing.B) {
	rand.Seed(time.Now().UnixNano())
	for i := 0; i < b.N; i++ {
		ra := rand.Intn(1000000)
		rb := rand.Intn(1000000)
		got := Add(ra, rb)
		if got != ra+rb {
			b.Fail()
		}
	}
}

执行

go test -bench=.

基于测试环境,操作系统,进程调度的制约,可能有时候测试不太准确,我们可以增加测试持续时长 和测试次数

go test -bench=. -benchtime=10s -count=10 

我们需要注意的是,这里有些额外的操作可能影响到我们耗时的统计结果,比如循环前的 随机测试,我们需要

  • 重置计时器 : ResetTimer()
rand.Seed(time.Now().UnixNano())
// 重置计时起点
b.ResetTimer()

循环过程中,我们生成随机数其实也不应该加入到耗时中,因为我们测试的是Add的性能

  • 设置起始计时点:StartTimer()
  • 设置结束计时点:StopTimer()
for i := 0; i < b.N; i++ {
		ra := rand.Intn(1000000)
		rb := rand.Intn(1000000)
		// 开始计时
    b.StartTimer()
		got := Add(ra, rb)
		if got != ra+rb {
			b.Fail()
		}
    // 结束计时
		b.StopTimer()
	}
  1. 编译优化问题

我们考虑测试下面的函数

func helloCalc(x uint64) uint64 {
	x -= (x >> 3) & 1000
	x = (x + (x >> 5)) & 1000
	return (x * 1000) >> 56
}
func BenchmarkHelloCalc(b *testing.B) {
	for i := 0; i < b.N; i++ {
		helloCalc(uint64(i))
	}
}

执行

 go test -bench=.

结果直接打满

BenchmarkHelloCalc-8    1000000000               0.3337 ns/op

我们看看问题在哪,我们尝试打印其中几个传递的参数结果

fmt.Println(helloCalc(uint64(1)))  // 0
fmt.Println(helloCalc(uint64(2)))  // 0
fmt.Println(helloCalc(uint64(3)))  // 0
fmt.Println(helloCalc(uint64(4)))  // 0

无一例外,都是0,编译器对函数进行了优化,函数内一块地方没有接收且计算结果始终为0,类似做了空操作

func BenchmarkHelloCalc(b *testing.B) {
	for i := 0; i < b.N; i++ {
		// empty 
	}
}

怎么判断是否有优化,可以参考执行

  • 禁用优化 -gcflags="-N"
  • 打印编译汇编代码 -gcflags="-S"

这两个参数来进行对比,这里不是我们讨论的重点,我们看看怎么防止优化代码的测试不准

var G uint64
func BenchmarkHelloCalc(b *testing.B) {
	var a uint64
	for i := 0; i < b.N; i++ {
		a = helloCalc(uint64(i))
	}
	G = a
}

我们定义了个变量,每次结果进行赋值, 然后重新测试

BenchmarkHelloCalc-8    941377164                1.271 ns/op

实用测试

测试表

如果我们有多个测试用例,每一个写一个测试函数,那得多费劲,而且函数都调同一个,代码都不重用, 我们可以使用slice 构造测试表

func TestAdd(t *testing.T) {
	type Params struct {
		A int
		B int
	}
	tests := map[string]struct {
		input    Params
		expected int
	}{
		"t1": {input: Params{1, 2}, expected: 3},
		"t2": {input: Params{3, 2}, expected: 5},
		"t3": {input: Params{5, 2}, expected: 7},
		"t4": {input: Params{6, 2}, expected: 8},
		"t5": {input: Params{8, 2}, expected: 10},
	}
	for tname, tt := range tests {
		tt := tt // 避免变量重置
		t.Run(tname, func(t *testing.T) {
			got := Add(tt.input.A, tt.input.B)
			if got != tt.expected {
				t.Errorf("got: %d, expected: %d", got, tt.expected)
			}
		})
	}
}
并行测试

比如,我们有个接口是秒杀接口,需要并发测试下并发请求下的数据是否正常读写,我们可以使用

  • 允许测试函数在不同的 Goroutine 中并行运行: Parallel()
func TestConcurrentRequests(t *testing.T) {
	// 创建一个 httptest.Server 作为模拟的 HTTP 服务器
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// 模拟处理请求的逻辑
		// ...

		// 返回响应
		fmt.Fprintln(w, "秒杀成功!")
	}))
	defer server.Close()

	// 定义并发请求数量
	numRequests := 10

	// 使用 WaitGroup 来等待所有测试 Goroutine 完成
	var wg sync.WaitGroup
	wg.Add(numRequests)

	for i := 0; i < numRequests; i++ {
		t.Run(fmt.Sprintf("Request %d", i), func(t *testing.T) {
			// 标记该测试函数可以并行运行
			t.Parallel()

			// 发起请求
			resp, err := http.Get(server.URL)
			if err != nil {
				t.Errorf("Request failed: %v", err)
			}
			defer resp.Body.Close()

			// 处理响应
			// ...

			// 标记当前 Goroutine 已完成
			wg.Done()
		})
	}

	// 等待所有测试 Goroutine 完成
	wg.Wait()
}

默认情况下,可以同时运行的最大并行测试数量等于GOMAXPROCS值,我们可以使用-parallel标志更改此值

go test -parallel 8 .
随机测试

有些情况下,我们在执行所有测试文件是按照固定的顺序执行a_test.go/b_test.go/c_test.go

如果顺序随机打乱 就会测试失败,这样我们就能更快的发现,在没有顺序依赖情况下的bug

  • 开启 -shuffle=on 乱序执行测试文件
 go test -shuffle=on -v .

打乱随机测试可以帮助我们发现隐藏的依赖项,这可能意味着在以相同顺序运行测试时测试错误甚至不可见的错误

扩展

三方测试包

现在比较流行测测试包是

go get github.com/stretchr/testify

参考文档:https://pkg.go.dev/github.com/stretchr/testify

我们抽取gin框架中的测试文件 gin_test.go 看看怎么用的,用了哪些方法

assert.Empty(t, router.Handlers)
assert.Len(t, list, 7)
assert.NotPanics(t, func() {
	// code here
})
assert.Equal(t, expectedTrustedCIDRs, r.trustedCIDRs)
assert.NoError(t, err)
assert.Error(t, err)
assert.Nil(t, err)
assert.NotNil(t, router.trees.get("GET"))
assert.Regexp(t, wantRoute.Handler, gotRoute.Handler)

httptest

参考文档:https://studygolang.com/static/pkgdoc/pkg/net_http_httptest.htm

下面是一个简单示例

func TestHTTPHandler(t *testing.T) {
	// 创建一个 httptest.Server 作为模拟的 HTTP 服务器
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// 处理请求并返回响应
		w.WriteHeader(http.StatusOK)
		w.Write([]byte("Hello, World!"))
	}))
	defer server.Close()

	// 创建一个 HTTP 请求到模拟的服务器
	req, err := http.NewRequest(http.MethodGet, server.URL, nil)
	if err != nil {
		t.Fatalf("Failed to create request: %v", err)
	}

	// 发送请求并获取响应
	client := http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		t.Fatalf("Request failed: %v", err)
	}
	defer resp.Body.Close()

	// 验证响应状态码是否为 200 OK
	if resp.StatusCode != http.StatusOK {
		t.Errorf("Expected status 200 OK, got: %v", resp.Status)
	}

	// 验证响应内容是否正确
	expectedBody := "Hello, World!"
	actualBody := make([]byte, len(expectedBody))
	_, err = resp.Body.Read(actualBody)
	if err != nil {
		t.Fatalf("Failed to read response body: %v", err)
	}
	if string(actualBody) != expectedBody {
		t.Errorf("Expected body '%s', got: '%s'", expectedBody, string(actualBody))
	}
}

iotest

参考文档:https://studygolang.com/static/pkgdoc/pkg/testing_iotest.htm

下面是一个简单示例:

func TestWriteErrorHandling(t *testing.T) {
	// 模拟一个写入器,总是返回特定的错误
	writer := iotest.ErrWriter(errors.New("custom error"))

	// 写入数据
	_, err := writer.Write([]byte("data"))

	// 验证错误是否符合预期
	if err == nil || err.Error() != "custom error" {
		t.Errorf("Expected custom error, got: %v", err)
	}
}

gin框架test

参考文档 : https://gin-gonic.com/zh-cn/docs/testing/

结合了 testify 与 httptest包

func TestPingRoute(t *testing.T) {
	router := setupRouter()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("GET", "/ping", nil)
	router.ServeHTTP(w, req)

	assert.Equal(t, 200, w.Code)
	assert.Equal(t, "pong", w.Body.String())
}
  • 29
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
### 回答1: 《Go语言设计与实现》是一本专门介绍Go语言设计和实现原理的书籍,由Alan A. A. Donovan和Brian W. Kernighan联合撰写,共计460页。这本书分为五部分,分别是Go编程基础、Go语言编译器、运行时、标准库以及Go语言实践。 其中第一部分介绍了Go语言的基础知识,包括基本语法、数据类型、函数、指针等,适合新手入门学习,同时也会深入探讨一些高级的概念,如接口、协程和并发编程等。 第二部分主要介绍了Go语言编译器的结构和原理,包括词法分析器、语法分析器、类型检查器和代码生成器等,了解这些内容对于理解Go语言的底层原理和运行机制非常有帮助。 第三部分主要讲解了Go语言的运行时系统,包括垃圾回收机制、并发调度、信道和调试器等,这些内容对于编写高质量的Go程序非常有帮助。 第四部分则介绍了Go标准库的使用方法和实现原理,包括各种常用的包和工具,如IO、网络、加密、时间等。 最后一部分是针对Go语言的实践经验,介绍了常见的编程技巧和注意事项,让读者可以更加高效地使用Go语言进行编程。 总的来说,《Go语言设计与实现》这本书对于想要深入了解Go语言开发者来说是一本非常优秀的参考书籍,不仅涵盖了Go语言的基础知识,更深入探讨了Go语言的底层原理和实现机制,对于提高开发者的编程能力和思维能力有着非常重要的作用。 ### 回答2: 《Go语言设计与实现》是一本介绍Go语言原理和实现的高水平著作。本书共分为4个部分,分别是语言结构、并发机制、包与工具、运行时。作者深入浅出地介绍了Go语言的设计理念和内部机制,探讨了其中的思考过程,分析了Go语言实现细节和底层结构,以及Go语言执行和并发机制的工作原理。 本书的第一部分从基本语言构造开始,介绍语言的元素,包括类型定义、变量、常量、函数、程序流程控制等等,把Go语言的基础打牢,为后续学习打下坚实的基础。第二部分重点介绍了Go语言的并发模型,包括goroutine、channel等等,让读者深入理解Go语言优秀的并发特性。第三部分讲解了Go语言的包管理和工具,并讲解了编写、测试、部署的全流程。 第四部分讲解了Go语言的运行时机制,包括垃圾收集、内存管理等等,让读者明白Go语言是如何运行的。 此外,《Go语言设计与实现》还介绍了过程组合和错误处理的方法、数据结构、系统调用等,让读者领略到Go语言的强大和优雅之处。 总之,这本书很适合想深入学习Go语言的人阅读。它既介绍了Go语言如何应用,又剖析了Go语言内部机制。如果你希望更好地掌握Go语言使用和原理,这本书值得一读。 ### 回答3: Go语言设计与实现是Go语言的正式指导性文档,首次发布于2012年。这个文档的目的是深入阐述Go语言的语法、语义、并发、类型、包、测试、编译、运行时和标准库等方面。Go语言的成功归因于其简洁、高效、可靠和易用的特性,这个文档展示了这些特性背后的原理和设计思想。 该文档被分为三个部分:语言规范、包实现和编译器实现。第一部分介绍了Go语言的语法和语义,包括基本类型、函数、变量、控制结构、方法、接口、通道,以及内存模型、类型转换和运算等。第二部分描述了Go语言包的机制和设计,包括包的导入、可见性、包初始化、错误处理、包的生命周期、安全问题和包的组织等。第三部分为编译器的实现提供了详尽的说明,包括词法分析、语法分析、类型检查、代码生成和链接等过程。此外,该文档还包含了一些实例代码,可以帮助开发者更好地理解Go语言。 Go语言设计与实现的特点在于其详细描述了Go语言的底层实现,而不仅仅是语言的语法和库函数。这有助于开发者更好地理解Go语言的设计,更好地利用Go语言的特性编写高效的代码。同时,该文档也是Go语言社区的重要参考资料,为Go语言的发展和推广做出了巨大的贡献。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值