Go语言工程实践之测试 | 青训营

122 阅读10分钟

一. 测试

测试关乎着系统的质量。

测试是避免事故的最后一道屏障。

image.png

测试大致分为3种类型,回归测试,集成测试,单元测试。从前到后,覆盖率依次变大,成本依次减少。

1. 单元测试

image.png 单元测试主要包括,输入,测试单元,输出,以及校对,单元的概念比较广,包括接口,函数,模块等;用最后的校对来保证代码的功能与我们的预期相符;单侧一方面可以保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性又未破坏原有代码的正确性另一方面可以提升效率,在代码有bug的情况下,通过编写单测,可以在一个较短周期内定位和修复问题。

1)规则

下面是单侧的一些基本规范:

所有测试文件以_test.go结尾这样从文件上就很好了区分源码和测试代码,

测试函数命名 func TestXxx(t *testing.T)以Test开头,且连接的第一个字母大写,

初始逻辑放在TestMain中 func TestMain(m *testing.M),里边主要做:测试前数据装载配置初始化等前置工作,测试后:释放资源等收尾工作.

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

2)例子

//c.go
package testc  
  
func HelloTom() string {  
    return "jerry"  
}
//c_test.go
package testc  
  
import "testing"  
  
func TestHelloTom(t *testing.T) {  
    output := HelloTom()  
    expectOutput := "Tom"  
    if output != expectOutput {  
        t.Errorf("Expected %v do not match actual %v", expectOutput, output)  
    }  
}

输出结果 image.png

3)assert

assert是开源有equal和noequal比较。

//c_test.go
package testc  
  
import (  
    "github.com/stretchr/testify/assert"  
    "testing"  
)  
  
func TestHelloTom(t *testing.T) {  
    output := HelloTom()  
    expectOutput := "Tom"  
    assert.Equal(t, expectOutput, output)  
}

输出结果

不通过 image.png 通过 image.png

4)覆盖率

我们可以通过覆盖率来判断代码的测试水准,评估项目是否到了高水准,代码是否经过了足够的测试。 下面是测试覆盖率的执行代码。

    go test src\testc\c_test.go src\testc\c.go --cover 

5)tips

在实际项目中,一般的要求是50%~60%覆盖率,而对于资金型服务,覆盖率可能要求达到80%;我们做单元测试,测试分支相互独立,全面覆盖,则要求函数体足够小,这样就比较简单的提升覆盖率,也符合函数设计的单一职责。

6)依赖

工程中复杂的项目,一般会依赖 Cache和file和DB,而我们的单测需要保证稳定性和幂等性稳定是指相互隔离,能在任何时间,任何环境,运行测试幂等是指每一次测试运行都应该产生与之前一样的结果。而要实现这一目的就要用到mock机制。

image.png

7)文件测试

下面举个例子,将文件中的第一行字符串中的11替换成00,执行单测,测试通过,而我们的单测需要依赖本地的文件,如果文件被修改或者删除测试就会fail。为了保证测试的稳定性,我们对读取文件函数进行mock,屏蔽对于文件的依赖。

package wenjian  
  
import (  
    "bufio"  
    "os"  
    "strings"  
)  
  
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 Pro() string {  
    line := ReadFirstLine()  
    destLine := strings.ReplaceAll(line, "11", "00")  
    return destLine  
}
//test
package wenjian  
  
import (  
    "github.com/stretchr/testify/assert"  
    "testing"  
)  
  
func TestPro(t *testing.T) {  
    firstLine := Pro()  
    assert.Equal(t, "line00", firstLine)  
}

8)Mock

mock可以理解为用一个a去替换b,b为原函数,a是打桩函数
这里我们用了Monkey,monkey是一个开源的mock测试库,可以对method,或者实例的方法进行mock,反射,指针赋值 Mockey Patch 的作用域在 Runtime,在运行时通过通过 Go 的 unsafe 包,能够将内存中函数的地址替换为运行时函数的地址,将待打桩函数或方法的实现跳转到。

package wenjian  
  
import (  
    "bou.ke/monkey"  
    "github.com/stretchr/testify/assert"  
    "testing"  
)  
  
func TestPro(t *testing.T) {  
    monkey.Patch(ReadFirstLine, func() string {  
    return "line110"  
    })  //mock所用
    defer monkey.Unpatch(ReadFirstLine)  //mock所用
    firstLine := Pro()  
    println(firstLine)  
    assert.Equal(t, "line000", firstLine)  
}

2. 基准测试

Go 语言还提供了基准测试框架,基准测试是指测试一段程序的运行性能及耗费 CPU 的程度。而我们在实际项目开发中,经常会遇到代码性能瓶颈,为了定位问题经常要对代码做性能分析,这就用到了基准测试。使用方法类似于单元测试,

1)例子

这里举一个服务器负载均衡的例子,首先我们有10个服务器列表,每次随机执行select函数随机选择一个执行。

//随机选择服务器执行
package jzcs  
  
import "math/rand"  
  
var ServerIndex [10]int  
  
func InutServerIndex() {  
    for i := 0; i < 0; i++ {  
        ServerIndex[i] = i + 100  
    }  
}  
func Select() int {  
    return ServerIndex[rand.Intn(10)]  //rand持有了一把全局锁
}

基准测试以Benchmark开头,入参是testing.B, 用b中的N值反复递增循环测试 (对一个测试用例的默认测试时间是 1 秒,当测试用例函数返回时还不到 1 秒,那么 testing.B 中的 N 值将按 1、2、5、10、20、50…递增,并以递增后的值重新进行用例函数测试。) Resttimer重置计时器,我们再reset之前做了init或其他的准备操作,这些操作不应该作为基准测试的范围;runparallel是多协程并发测试;执行2个基准测试,发现代码在并发情况下存在劣化,主要原因是rand为了保证全局的随机性和并发安全,持有了一把全局锁。

package jzcs  
  
import "testing"  
  
func BenchmarkSelect(b *testing.B) {  
    InutServerIndex()  //准备时间除去
    b.ResetTimer()  
    for i := 0; i < b.N; i++ {  
        Select()  
    }  
    }  //串行压力测试
func BenchmarkSelectParallal(b *testing.B) {  
    InutServerIndex()  
    b.ResetTimer()  
    b.RunParallel(func(pb *testing.PB) {  
        for pb.Next() {  
            Select()  
        }  
    })  //并行压力测试
}

2)优化

为了解决这一随机性能问题,我们可以用一个开源的高性能随机数方法fastrandgithub.com/bytedance/g…

package jzcs  
  
import (  
    "github.com/bytedance/gopkg/lang/fastrand"  
)  
  
var ServerIndex [10]int  
  
func InutServerIndex() {  
    for i := 0; i < 0; i++ {  
        ServerIndex[i] = i + 100  
    }  
}  
func Select() int {  
    return ServerIndex[fastrand.Intn(10)]  
}

二. 实战项目

都看到过这个掘金的社区话题入口页面,页面的功能包括话题详情,回帖列表,支持回帖,点赞,和回帖回复,我们今天就以此为需求模型,开发一个该页面交涉及的服务端小功能。

1. 需求设计

1)需求描写

展示话题 (标题,文字描述) 和回帖列表

暂不考虑前端页面实现,仅仅实现一个本地web服务

话题和回帖数据用文件存储

2)需求用例

用户浏览消费,涉及页面的展示,包括话题内容和回帖的列表 image.png

3)ER图

Er图是用来描述现实世界的概念模型。 有了模型实体,属性以及之间的联系,对我们后续做开发就提供了比较清晰的思路。 image.png

4)分层结构

image.png 整体分为三层,repository数据层,service逻辑层,controoler视图层,

数据层关联底层数据模型,也就是这里的model,封装外部数据的增删改查,我们的数据存储在本地文件,通过文件操作拉取话题,帖子数据;数据层面向逻辑层,对service层透明,屏蔽下游数据差异,也就是不管下游是文件,还是数据库,还是微服务等,对service层的接口模型是不变的。

Servcie逻辑层处理核心业务逻辑,计算打包业务实体entiy,对应我们的需求,就是话题页面,包括话题和回帖列表,并上送给视图层;

Controller视图层负责处理和外部的交互逻辑,以view视图的形式返回给客户端,对于我们需求,我们封装json格式化的请求结果,api形式访问就好,

2. 代码开发

1)组件工具

go web github.com/gin-gonic/g…

首先是gin,高性能开源的go web框架,我们基于gin 搭建web服务器。

因为引入了web框架,所以就涉及go module依赖管理,我们首先通过go mod是初始化go mod管理配置文件,然后go get下载gin依赖。

go mod init 初始化go.mod文件

2)Repository

我们可以根据之前的er图先定义struct QueryTopicByld,QueryPostsByParentld文件中每行的格式如图所示。

QueryTopicByld image.png QueryPostsByParentld image.png

3)Repository - index

一方面查询我们可以用全扫描遍历的方式,但是这虽然能达到我们的目的,但是并非高效的方式,所以这里引出索引的概念,索引就像书的目录,可以引导我们快速查找定位我们需要的结果;这里我们用map实现内存索引,在服务对外暴露前,利用文件元数据初始化全局内存索引,这样就可以实现0(1)的时间复杂度查找操作。

下面是具体的实现,我们过一下,首先是打开文件,基于file 初始化scanner,通过迭代器方式遍历数据行,转化为结构体存储至内存map,

package repository  
  
import (  
    "bufio"  
    "encoding/json"  
    "fmt"  
    "os"  
)  
  
var (  
    topicIndexMap map[int64]*Topic  
    postIndexMap map[int64][]*Post  
)  
  
func InitTopicIndexMap(filePath string) error {  
    open, err := os.Open(filePath + "topic.json")  
    if err != nil {  
        return err  
    }  
    scanner := bufio.NewScanner(open)  
    topicTmpMap := make(map[int64]*Topic)  
    for scanner.Scan() {  
        text := scanner.Text()  
        var topic Topic  
        err := json.Unmarshal([]byte(text), &topic)  
        if err != nil {  
            return err  
        }  
        topicTmpMap[topic.Id] = &topic  
    }  
    topicIndexMap = topicTmpMap  
    fmt.Printf("%#v", topicIndexMap)  
    return nil  
}

func InitPostIndexMap(filePath string) error {  
open, err := os.Open(filePath + "post.json")  
if err != nil {  
return err  
}  
scanner := bufio.NewScanner(open)  
PostTmpMap := make(map[int64][]*Post)  
for scanner.Scan() {  
text := scanner.Text()  
var post Post  
err := json.Unmarshal([]byte(text), &post)  
if err != nil {  
return err  
}  
PostTmpMap[post.Id][post.ParentId] = &post  
}  
postIndexMap = PostTmpMap  
return nil  
}

4)Repository - 查询

有了内存索引,下一步就是实现查询操作就比较简单了,直接根据查询key获得map中的value就好了,这里用到了sync.once,主要适用高并发的场景下只执行一次的场景。

package repository  
  
import "sync"  
  
type Topic struct {  
Id int64 `json:"id"`  
Title string `json:"title"`  
Content string `json:"content"`  
CreateTime string `json:"create_time"`  
}  
type Post struct {  
Id int64 `json:"id"`  
ParentId int64 `json:"parent_id"`  
Content string `json:"content"`  
CreateTime string `json:"create_time"`  
}  
type TopicDao struct {  
}  
type PostDao struct {  
}  
  
var (  
topicDao *TopicDao  
postDao *PostDao  
topicOnce sync.Once  
postOnce sync.Once  
)  
  
func NewTopicDaoInstance() *TopicDao {  
topicOnce.Do(func() {  
topicDao = &TopicDao{}  
})  
return topicDao  
}  
func NewPostDaoInstance() *PostDao {  
postOnce.Do(  
func() {  
postDao = &PostDao{}  
})  
return postDao  
}  
  
func (*TopicDao) QueryTopicById(id int64) *Topic {  
return topicIndexMap[id]  
}

5)Service

有了reposity层以后,下面我们开始实现service层,首先我们定义servcie层实体,包括postlist和topic

type PageInfo struct {  
Topic *repository.Topic  
PostList []*repository.Post  
}

下面是具体的servcie流程编排,通过err控制流程退出,正常会返回页面信息,err为nil

func (f *QueryPageInfoFlow) Do() (*PageInfo, error) {  
    if err := f.checkParam(); err!=nil {  
        return nil, err  
    }  
    if err := f.prepareInfo(); err!=nil {  
        return nil, err  
    }  
    if err := f.packPageInfo(); err!=nil {  
        return nil, err  
    }  
    return f.pageInfo, nil  
}

关于prepareInfo方法,话题和回帖信息的获取都依赖topicid,这样2者就可以并行执行,提高执行效率。

func (f *QueryPageInfoFlow) prepareInfo() error {
	//获取topic信息
	var wg sync.WaitGroup
	wg.Add(2)
	var topicErr, postErr error
	go func() {
		defer wg.Done()
		topic, err := repository.NewTopicDaoInstance().QueryTopicById(f.topicId)
		if err != nil {
			topicErr = err
			return
		}
		f.topic = topic
	}()
	//获取post列表
	go func() {
		defer wg.Done()
		posts, err := repository.NewPostDaoInstance().QueryPostByParentId(f.topicId)
		if err != nil {
			postErr = err
			return
		}
		f.posts = posts
	}()
	wg.Wait()
        return nil
 }

Service实现完成,下面就是controller层。这里我们定义一个view对象,通过code msg打包业务状态信息,用data承载业务实体信息.


type PageData struct {  
    Code int64 `json:"code"`  
    Msg string `json:"msg"`  
    Data interface{} `json:"data"`  
}
func PublishPost(uidStr, topicIdStr, content string) *PageData {
	//参数转换
	uid, _ := strconv.ParseInt(uidStr, 10, 64)
	topic, _ := strconv.ParseInt(topicIdStr, 10, 64)
	//获取service层结果
	postId, err := service.PublishPost(topic, uid, content)
	if err != nil {
		return &PageData{
			Code: -1,
			Msg:  err.Error(),
		}
	}
	return &PageData{
		Code: 0,
		Msg:  "success",
		Data: map[string]int64{
			"post_id": postId,
		},
	}
}

最后是web服务的引擎配置,包括初始化数据索引, 初始化引擎配置, 构建路由, 启动服务,path映射到具体的controller。通过path变量传递话题id

func main() {
	if err := Init(); err != nil {
		os.Exit(-1)
	}
	r := gin.Default()
	r.GET("/community/page/get/:id", func(c *gin.Context) {
		topicId := c.Param("id")
		data := handler.QueryPageInfo(topicId)
		c.JSON(200, data)
	})
        err := r.Run()
        if err != nil{
        return
        }