草庐IT

Golang单元测试、Mock测试以及基准测试

小菜鸡本菜 2023-04-04 原文

之前参加字节跳动青训营而整理的笔记

Golang拥有一套单元测试和性能测试系统,仅需要添加很少的代码就可以快速测试一段需求代码。

一、单元测试

单元测试主要包括:输入、测试单元、输出、期望以及与期望的校对。

测试单元包括函数或者结合了一些函数的模块等。我们通过将输出与期望值进行校对,来验证代码的正确性。

通过单元测试,可以一方面保证质量,例如在覆盖率足够的情况下,如果在旧代码中添加了新的代码,通过单元测试可以验证新的代码是否破坏了功能正确性。

另一方面,也提升了效率,例如代码中出现了bug,通过编写单元测试,我们能够在较短的时间内定位或修复问题。

1.1、golang规则

规则1:所有测试文件以_test.go结尾。

_test.go 程序不会被普通的 Go 编译器编译,所以当放应用部署到生产环境时它们不会被部署;

只有gotest会编译所有的程序:普通程序和测试程序。

规则2:测试文件中必须导入testing包,并且函数必须写为func TestXxx(*testing.T)形式。

例如,某个函数Add的测试函数为TestAdd,如下所示:

//main.go
func Add(a, b int) int {
	return a + b
}

//main_test.go
func TestAdd(t *testing.T) {
   trueOutput := Add(1, 2)
   expectOutput := 3
   if trueOutput != expectOutput {
      t.Errorf("Expected %v do not match actual %v", expectOutput, trueOutput)
   }
}

规则3:测试的初始化逻辑放到TestMain中。

这是一个比较好的用法,TestMain函数具体信息如下:

func TestMain(m *testing.M){
    //测试前:数据装载、配置初始化等前置工作
    //...
    
    code := m.Run()
    
    //测试后:释放资源等收尾工作
    //...
    
    os.Exit(code)
}

例如:

func TestMain(m *testing.M) {
    //测试前
	fmt.Println("开始了!")
	run := m.Run()
    //测试后
	fmt.Println("结束了!")
	os.Exit(run)
}

func TestAdd(t *testing.T) {
	trueOutput := Add(1, 2)
	expectOutput := 3
	if trueOutput != expectOutput {
		t.Errorf("Expected %v do not match actual %v", expectOutput, trueOutput)
	}
}

//测试结果

//开始了!
//=== RUN   TestAdd
//--- PASS: TestAdd (0.00s)
//PASS
//
//结束了!   

1.2、举例&第三方测试包

在该例子中,我们期望HelloTom函数返回“Tom”,如果返回的不是“Tom”则表示测试失败。

很明显,本次测试是失败的。

func HelloTom() string {
	return "Jerry"
}

func TestHelloTom(t *testing.T) {
   output := HelloTome()
   expectOutput := "Tom"
   if output != expectOutput {
      t.Errorf("Expected %s do not match actual %s", expectOutput, output)
   }
}

//测试结果
//=== RUN   TestHelloTom
//main_test.go:28: Expected Tom do not match actual Jerry
//--- FAIL: TestHelloTom (0.00s)

在单元测试函数中,经常需要编写判断逻辑,我们可以使用一些开源的测试包来帮助简化代码。

例如使用Testift。使用go get安装:

go get github.com/stretchr/testify

将上述例子使用Testify后,代码如下:

func TestHelloTom(t *testing.T) {
   output := HelloTom()
   assert.Equal(t, "Tom", output)
}

1.3、覆盖率

问题

  • 如何衡量代码是否经过了足够的测试?
  • 如何评价项目的测试水准?
  • 如何评估项目是否达到了高水准测试等级?

我们需要评估单元测试,于是需要引入了单元测试覆盖率。

覆盖率在一定程度上反应了测试用例的覆盖度,越完备那么代码的正确性越有保证。

例子

func JudgePassLine(score int16) bool {
   if score >= 60 {
      return true
   }
   return false
}

func TestJudgePassLine(t *testing.T) {
	isPass := JudgePassLine(70)
	expectOutput := true
	if expectOutput != isPass {
		t.Errorf("Expected %v do not match actual %v", expectOutput, isPass)
	}
}

使用命令:

go test judgment_test.go judgment.go --cover

结果:

=== RUN   TestJudgePassLine
--- PASS: TestJudgePassLine (0.00s)
PASS

coverage: 40.0% of statements in ./...

如果使用Goland的话,会显示出测试代码的范围。很明显,JudgePassLine函数的前两行(例子中第2、3行)已经被验证,而return false并没有被验证。

我们可以再写一个分支的单元测试,来提高覆盖率。

func TestJudgePassLine(t *testing.T) {
   isPass := JudgePassLine(70)
   expectOutput := true
   if expectOutput != isPass {
      t.Errorf("Expected %v do not match actual %v", expectOutput, isPass)
   }
}

func TestJudgePassLineFail(t *testing.T) {
   isPass := JudgePassLine(50)
   expectOutput := false
   if expectOutput != isPass {
      t.Errorf("Expected %v do not match actual %v", expectOutput, isPass)
   }
}

//结果

//=== RUN   TestJudgePassLine
//--- PASS: TestJudgePassLine (0.00s)
//=== RUN   TestJudgePassLineFail
//--- PASS: TestJudgePassLineFail (0.00s)
//PASS
//
//coverage: 60.0% of statements in ./...

从结果可以看出,目前覆盖率已经达到60%了(还有其他函数没有写单元测试)。

当然,在实际项目中,要达到100%的覆盖率是一个可望不可及的目标,一般来说,覆盖率在50%~60%能够认为在一些主流的情况下是没有问题的,但是可能还有有一些异常分支没有覆盖到,对一些例如”提现“等资金类的操作,对覆盖率会要求更高,一般会要求达到覆盖率80%以上。

为了能够提高覆盖率,有一些好的实践:

  1. 测试分支相互独立、全面覆盖。
  2. 测试单元粒度足够小,因此要求函数单一职责。

二、Mock测试

2.1、项目中的依赖

在一些复杂项目中,会依赖一些数据库、文件或缓存等,这些属于项目的一个强依赖。

单元测试的主要目标有2个:

  1. 幂等。幂等指重复运行一个测试的结果与之前是一致的。
  2. 稳定。指单元测试是能够相互隔离的,单元测试中的函数能在任何时间任何地点独立运行。

如果单元测试中直接调用数据库等外部依赖,那测试是不稳定的,例如:

func ReadFirstLine() string {
   open, err := os.Open("log")
   defer open.Close()
   if err != nil {
      return ""
   }
   scanner := bufio.NewScanner(open)
   for scanner.Scan() {
      return scanner.Text()
   }
   return ""
}

func ProcessFirstLine() string {
   line := ReadFirstLine()
   destLine := strings.ReplaceAll(line, "11", "00")
   return destLine
}

//Test
func TestProcessFirstLine(t *testing.T) {
	firstLine := ProcessFirstLine()
	expectOutput := "line00"
	if firstLine != expectOutput {
		t.Errorf("Expected %s do not match actual %s", expectOutput, firstLine)
	}
}

从这个例子中可以看出,测试依赖于外部文件,假如外部文件被删除或篡改了,那么这个测试就不可运行了。

因此就需要引入mock机制。

2.2、Mock

常用的开源Mock包monkey:https://github.com/bouk/monkey

该包提供了快速Mock函数:

  • 为一个函数打桩
  • 为一个方法打桩

打桩可以理解为用一个函数A去替换一个函数B,B就是原函数,A就是打桩函数。

例子

将上述读取文件单元测试代码修改,对ReadFirstLine打桩测试,使测试不再依赖本地文件。

func TestProcessFirstLine(t *testing.T) {
   //mock打桩
   monkey.Patch(ReadFirstLine, func() string {
      return "line00"
   })
   defer monkey.Unpatch(ReadFirstLine)
    
   //
   firstLine := ProcessFirstLine()
   expectOutput := "line00"
   if firstLine != expectOutput {
      t.Errorf("Expected %s do not match actual %s", expectOutput, firstLine)
   }
}

mock在运行时实现,基于go的unsafe包,将内存中函数的地址替换成运行时函数地址。

三、基准测试

go提供了基准测试框架,基准测试是指测试一段程序运行时的性能。

在基准测试中,函数会被调用 N 次(N 是非常大的数,如 N = 1000000),并展示 N 的值和函数执行的平均时间,单位为 ns(纳秒,ns/op)。

  • 使用基准测试能够优化代码,当然,这需要对当前代码分析。

例子:

负载均衡例子,随机选择执行服务器

var ServerIndex [10]int

func InitServerIndex() {
   for i := 0; i < 10; i++ {
      ServerIndex[1] = i + 100
   }
}

func Select() int {
   return ServerIndex[rand.Intn(10)]
}

//测试

//串行的基准测试
func BenchmarkSelect(b *testing.B) {
	InitServerIndex()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		Select()
	}
}

//并行的基准测试
func BenchmarkSelectParallel(b *testing.B) {
	InitServerIndex()
	b.ResetTimer()
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			Select()
		}
	})
}

测试结果:

BenchmarkSelect-16 180309266(N) 6.596 ns/op(函数执行的平均时间)

BenchmarkSelectParallel-16 29328594 42.33 ns/op

可以看到在并行状态下,性能较为低下,因为Select利用了rand函数,而rand函数为了保证随机性和并发安全,持有一把全局锁,这样就降低了并发性能。

为了提升这个函数的性能,可以用fastrand

func BenchmarkFastSelectParallel(b *testing.B) {
   InitServerIndex()
   b.ResetTimer()
   b.RunParallel(func(pb *testing.PB) {
      for pb.Next() {
         FastSelect()
      }
   })
}

结果:

BenchmarkFastSelectParallel-16 1000000000 0.5274 ns/op

四、总结

  1. Golang提供了简单而强大的测试工具,而且根据Golang的规则,也使得开发人员能够一眼就明白某个单元测试对应于哪个函数。
  2. 使用第三方单元测试工具包能够简化我们的代码。
  3. 在需要使用到外部依赖的情况下,我们可以利用Mock测试来模拟外部依赖,避免发生不必要的错误。
  4. 基准测试能够得出一段程序的运行性能,便于开发者进行优化,例如上文给出的“随机选择执行服务器”例子。

有关Golang单元测试、Mock测试以及基准测试的更多相关文章

  1. ruby-on-rails - 使用 Ruby on Rails 进行自动化测试 - 最佳实践 - 2

    很好奇,就使用ruby​​onrails自动化单元测试而言,你们正在做什么?您是否创建了一个脚本来在cron中运行rake作业并将结果邮寄给您?git中的预提交Hook?只是手动调用?我完全理解测试,但想知道在错误发生之前捕获错误的最佳实践是什么。让我们理所当然地认为测试本身是完美无缺的,并且可以正常工作。下一步是什么以确保他们在正确的时间将可能有害的结果传达给您? 最佳答案 不确定您到底想听什么,但是有几个级别的自动代码库控制:在处理某项功能时,您可以使用类似autotest的内容获得关于哪些有效,哪些无效的即时反馈。要确保您的提

  2. ruby - 什么是填充的 Base64 编码字符串以及如何在 ruby​​ 中生成它们? - 2

    我正在使用的第三方API的文档状态:"[O]urAPIonlyacceptspaddedBase64encodedstrings."什么是“填充的Base64编码字符串”以及如何在Ruby中生成它们。下面的代码是我第一次尝试创建转换为Base64的JSON格式数据。xa=Base64.encode64(a.to_json) 最佳答案 他们说的padding其实就是Base64本身的一部分。它是末尾的“=”和“==”。Base64将3个字节的数据包编码为4个编码字符。所以如果你的输入数据有长度n和n%3=1=>"=="末尾用于填充n%

  3. ruby - 使用 C 扩展开发 ruby​​gem 时,如何使用 Rspec 在本地进行测试? - 2

    我正在编写一个包含C扩展的gem。通常当我写一个gem时,我会遵循TDD的过程,我会写一个失败的规范,然后处理代码直到它通过,等等......在“ext/mygem/mygem.c”中我的C扩展和在gemspec的“扩展”中配置的有效extconf.rb,如何运行我的规范并仍然加载我的C扩展?当我更改C代码时,我需要采取哪些步骤来重新编译代码?这可能是个愚蠢的问题,但是从我的gem的开发源代码树中输入“bundleinstall”不会构建任何native扩展。当我手动运行rubyext/mygem/extconf.rb时,我确实得到了一个Makefile(在整个项目的根目录中),然后当

  4. ruby - Ruby 的 Hash 在比较键时使用哪种相等性测试? - 2

    我有一个围绕一些对象的包装类,我想将这些对象用作散列中的键。包装对象和解包装对象应映射到相同的键。一个简单的例子是这样的:classAattr_reader:xdefinitialize(inner)@inner=innerenddefx;@inner.x;enddef==(other)@inner.x==other.xendenda=A.new(o)#oisjustanyobjectthatallowso.xb=A.new(o)h={a=>5}ph[a]#5ph[b]#nil,shouldbe5ph[o]#nil,shouldbe5我试过==、===、eq?并散列所有无济于事。

  5. ruby - RSpec - 使用测试替身作为 block 参数 - 2

    我有一些Ruby代码,如下所示:Something.createdo|x|x.foo=barend我想编写一个测试,它使用double代替block参数x,这样我就可以调用:x_double.should_receive(:foo).with("whatever").这可能吗? 最佳答案 specify'something'dox=doublex.should_receive(:foo=).with("whatever")Something.should_receive(:create).and_yield(x)#callthere

  6. ruby - Sinatra:运行 rspec 测试时记录噪音 - 2

    Sinatra新手;我正在运行一些rspec测试,但在日志中收到了一堆不需要的噪音。如何消除日志中过多的噪音?我仔细检查了环境是否设置为:test,这意味着记录器级别应设置为WARN而不是DEBUG。spec_helper:require"./app"require"sinatra"require"rspec"require"rack/test"require"database_cleaner"require"factory_girl"set:environment,:testFactoryGirl.definition_file_paths=%w{./factories./test/

  7. ruby-on-rails - 迷你测试错误 : "NameError: uninitialized constant" - 2

    我遵循MichaelHartl的“RubyonRails教程:学习Web开发”,并创建了检查用户名和电子邮件长度有效性的测试(名称最多50个字符,电子邮件最多255个字符)。test/helpers/application_helper_test.rb的内容是:require'test_helper'classApplicationHelperTest在运行bundleexecraketest时,所有测试都通过了,但我看到以下消息在最后被标记为错误:ERROR["test_full_title_helper",ApplicationHelperTest,1.820016791]test

  8. ruby - 即使失败也继续进行多主机测试 - 2

    我已经构建了一些serverspec代码来在多个主机上运行一组测试。问题是当任何测试失败时,测试会在当前主机停止。即使测试失败,我也希望它继续在所有主机上运行。Rakefile:namespace:specdotask:all=>hosts.map{|h|'spec:'+h.split('.')[0]}hosts.eachdo|host|begindesc"Runserverspecto#{host}"RSpec::Core::RakeTask.new(host)do|t|ENV['TARGET_HOST']=hostt.pattern="spec/cfengine3/*_spec.r

  9. ruby-on-rails - 如何使辅助方法在 Rails 集成测试中可用? - 2

    我在app/helpers/sessions_helper.rb中有一个帮助程序文件,其中包含一个方法my_preference,它返回当前登录用户的首选项。我想在集成测试中访问该方法。例如,这样我就可以在测试中使用getuser_path(my_preference)。在其他帖子中,我读到这可以通过在测试文件中包含requiresessions_helper来实现,但我仍然收到错误NameError:undefinedlocalvariableormethod'my_preference'.我做错了什么?require'test_helper'require'sessions_hel

  10. ruby-on-rails - Cucumber 是否只是 rspec 的包装器以帮助将测试组织成功能? - 2

    只是想确保我理解了事情。据我目前收集到的信息,Cucumber只是一个“包装器”,或者是一种通过将事物分类为功能和步骤来组织测试的好方法,其中实际的单元测试处于步骤阶段。它允许您根据事物的工作方式组织您的测试。对吗? 最佳答案 有点。它是一种组织测试的方式,但不仅如此。它的行为就像最初的Rails集成测试一样,但更易于使用。这里最大的好处是您的session在整个Scenario中保持透明。关于Cucumber的另一件事是您(应该)从使用您的代码的浏览器或客户端的角度进行测试。如果您愿意,您可以使用步骤来构建对象和设置状态,但通常您

随机推荐