【字节跳动青训营】Go 并发编程与单元测试
【字节跳动青训营】Go 并发编程与单元测试1. 单元测试初步2. 并发协程 Goroutine通道 Channel锁Lock与线程同步WaitGroup3. 单元测试进阶例子 A基础断言例子 B多分支覆盖Mock 测试基准测试Benchmark1. 单元测试初步Go 语言遵循一个简洁的约定测试文件与源代码文件放在同一目录下并以_test.go结尾举个例子假设当前 package 中有一个 calc.go 文件我们想测试其中的 Add 和 Mul 函数那么只需新建一个 calc_test.go 作为对应的测试文件即可。这种就近管理的方式让测试代码与业务代码始终保持同步、易于维护2. 并发协程 GoroutineGo 语言通过 go 关键字来开启一个新的运行期线程——goroutine它会以一个全新创建的 goroutine 来异步执行某个函数。同一个程序中的所有 goroutine 共享同一个地址空间goroutine 是一种轻量级线程其调度完全由 Go 运行时runtime自动管理开发者无需手动干预goroutine 语法格式go函数名(参数列表)协程演示packageconcurrenceimport(fmttime)// 函数funchello(iint){println(hello goroutine : fmt.Sprint(i))}funcHelloGoRoutine(){fori:0;i5;i{// 开启一个新的协程gofunc(jint){hello(j)}(i)}// 需要等待片刻确保协程执行完毕time.Sleep(time.Second)}单元测试packageconcurrenceimport(testing)funcTestManyGo(t*testing.T){HelloGoRoutine()}输出结果 由于多个协程是并发执行的它们之间没有先后顺序因此输出结果是无序的hello goroutine:4hello goroutine:3hello goroutine:0hello goroutine:2hello goroutine:1通道 Channel通道channel是 Go 中用于在协程之间传递数据的核心数据结构。它可以让两个goroutine通过传递指定类型的值实现同步运行与通信。操作符-用于指定通道的方向发送或接收如果不指定方向则默认为双向通道。声明并创建一个通道非常简单使用 chan 关键字即可。注意通道在使用前必须先通过 make 创建ch:make(chanint)通道可以设置缓冲区通过make的第二个参数指定缓冲区大小ch:make(chanint,100)带缓冲区的通道 允许发送端与接收端处于异步状态发送的数据可以先暂存在缓冲区中等待接收端来取。不过缓冲区容量是有限的最终仍然需要接收端来消费数据。一旦缓冲区被填满发送端就会被阻塞无法继续发送新的数据。通道示例计算 0~9 的平方packageconcurrencefuncCalSquare(){// 声明一个无缓冲通道src:make(chanint)// 声明一个带缓冲区的通道dest:make(chanint,3)gofunc(){deferclose(src)fori:0;i10;i{// 将 i 逐个发送到 src 通道src-i}}()gofunc(){deferclose(dest)// 从 src 通道逐个接收数据计算后发送到 dest 通道fori:rangesrc{// 计算 i 的平方dest-i*i}}()// 输出 dest 通道里的所有值fori:rangedest{// 此处可执行更复杂的操作println(i)}}单元测试packageconcurrenceimporttestingfuncTestCalSquare(t*testing.T){CalSquare()}测试输出0 1 4 9 16 25 36 49 64 81可以看到结果完全符合预期。这个例子很好地演示了通道在多个协程之间接力传递数据的能力锁Lock与线程同步WaitGroup在并发场景中多个协程同时读写共享变量会引发数据竞争。下面通过一个累加的例子直观对比加锁与不加锁的区别同时介绍 WaitGroup 的用法packageconcurrenceimport(synctime)var(xint64lock sync.Mutex)// 加锁累加funcaddWithLock(){fori:0;i2000;i{lock.Lock()x1lock.Unlock()}}// 不加锁累加funcaddWithoutLock(){fori:0;i2000;i{x1}}funcAdd(){x0fori:0;i5;i{goaddWithoutLock()}time.Sleep(time.Second)println(WithoutLock:,x)x0fori:0;i5;i{goaddWithLock()}time.Sleep(time.Second)println(WithLock:,x)}funcManyGoWait(){varwg sync.WaitGroup wg.Add(5)fori:0;i5;i{gofunc(jint){deferwg.Done()// 这里的 hello 是 goroutine.go 里的函数hello(j)}(i)}wg.Wait()}单元测试packageconcurrenceimporttestingfuncTestAddLock(t*testing.T){Add()}funcTestManyGoWait(t*testing.T){ManyGoWait()}不加锁时由于多个协程同时修改 x最终结果往往小于预期值 10000加锁后通过sync.Mutex保证同一时刻只有一个协程能操作 x结果稳定为 100003. 单元测试进阶例子 A基础断言业务代码主要的作用是返回一个字符串funcHelloTom()string{returnTom}单元测试——借助 testify/assert 检测返回结果是否正确funcTestHelloTom(t*testing.T){output:HelloTom()expectOutput:Tomassert.Equal(t,expectOutput,output)}使用 assert.Equal 可以让断言更加简洁直观一旦结果不符合预期会自动打印期望值与实际值的对比信息例子 B多分支覆盖业务代码主要是判断分数是否及格funcJudgePassLine(scoreint16)bool{ifscore60{returntrue}returnfalse}单元测试——分别覆盖及格和不及格两种情况funcTestJudgePassLineTrue(t*testing.T){isPass:JudgePassLine(70)assert.Equal(t,true,isPass)}funcTestJudgePassLineFail(t*testing.T){isPass:JudgePassLine(50)assert.Equal(t,false,isPass)}对于带有分支逻辑的函数应尽量覆盖每一条分支路径以提升测试的完整性和可靠性Mock 测试在实际开发中被测函数往往依赖外部资源如文件、数据库、网络等这些依赖不便于在测试环境中直接调用。此时就需要 Mock 来替换掉真实的依赖业务代码——对文件内容进行读取与处理// 读取文件首行内容funcReadFirstLine()string{// 打开当前目录下的 log 文件open,err:os.Open(log)deferopen.Close()iferr!nil{return}// 逐行读取并返回首行scanner:bufio.NewScanner(open)forscanner.Scan(){returnscanner.Text()}return}// 处理文件首行内容funcProcessFirstLine()string{line:ReadFirstLine()// 将 11 替换为 00destLine:strings.ReplaceAll(line,11,00)returndestLine}单元测试——使用 monkey 对 ReadFirstLine 进行打桩从而摆脱对真实文件的依赖// 直接测试依赖真实文件funcTestProcessFirstLine(t*testing.T){firstLine:ProcessFirstLine()// 校验是否成功替换assert.Equal(t,line00,firstLine)}// 使用 Mock 测试无需真实文件funcTestProcessFirstLineWithMock(t*testing.T){// 将 ReadFirstLine 的返回值打桩为固定值monkey.Patch(ReadFirstLine,func()string{returnline110})defermonkey.Unpatch(ReadFirstLine)line:ProcessFirstLine()assert.Equal(t,line000,line)}通过 monkey.Patch 将 ReadFirstLine 替换为一个返回固定值的函数我们就能在不依赖真实文件的前提下专注地测试 ProcessFirstLine 的处理逻辑。测试结束后记得用 Unpatch配合 defer恢复原函数避免影响其他测试基准测试Benchmark除了验证功能正确性我们有时还需要评估代码的性能这就要用到基准测试。下面对比标准库 math/rand 与字节跳动开源的 fastrand 在随机取数场景下的性能差异packagebenchmarkimport(github.com/bytedance/gopkg/lang/fastrandmath/rand)varServerIndex[10]intfuncInitServerIndex(){fori:0;i10;i{ServerIndex[i]i100}}// 使用标准库 randfuncSelect()int{returnServerIndex[rand.Intn(10)]}// 使用 fastrandfuncFastSelect()int{returnServerIndex[fastrand.Intn(10)]}基准测试// 串行基准测试funcBenchmarkSelect(b*testing.B){InitServerIndex()b.ResetTimer()fori:0;ib.N;i{Select()}}// 并行基准测试标准库 randfuncBenchmarkSelectParallel(b*testing.B){InitServerIndex()b.ResetTimer()b.RunParallel(func(pb*testing.PB){forpb.Next(){Select()}})}// 并行基准测试fastrandfuncBenchmarkFastSelectParallel(b*testing.B){InitServerIndex()b.ResetTimer()b.RunParallel(func(pb*testing.PB){forpb.Next(){FastSelect()}})}基准测试要点基准测试函数以 Benchmark 开头参数为*testing.Bb.N 由测试框架自动调整以获得稳定可靠的测量结果b.ResetTimer() 用于重置计时器避免初始化耗时如 InitServerIndex被计入统计b.RunParallel 可模拟并发场景下的性能表现在高并发场景下fastrand 由于避免了标准库 rand 内部的全局锁竞争性能通常会明显优于 math/rand