课程目录
-
语言进阶(并发编程)
-
依赖管理(go module)
3. 测试(单元测试)
4. 项目实战
单元测试
测试是避免事故的最后一道屏障
测试分类
回归测试:一般回到终端层面进行软件测试
集成测试:对系统功能进行测试
单元测试:开发者对单独函数模块进行的测试
组成部分
- 输入
- 测试单元(比较宽泛)
- 输出
- 期望值
规则
- 所有测试文件以 _test.go 结尾
- func TestXxx(t *testing.T) 函数签名规范
- 初始化逻辑放到 TestMain 中
代码实例
func HelloTom() string {
return "Jerry"
}
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
if output != expectOutput {
t.Errorf("Expected %s do not match actual %s", expectOutput, output)
}
}
assert
import (
"github.com/stretchr/tesetify/assert"
"testing"
)
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
assert.Equal(t, expectOutput, output)
}
func HelloTom() string {
return "Tom"
}
这里调用第三方包中的 assert 函数来判断,期望值和实际输出值是否相同,solidity 语言中 assert(a > b, "error message") 和这个其实差不多,满足 a > b 条件就正常执行,否则就输出自定义的错误消息。
代码覆盖率
func JudgePassLine(score int16) bool {
if score >= 60 {
return true
}
return false
}
func TestJudgePassLineTrue(t *testing.T) {
isPass := JudgePassLine(70)
assert.Equal(t, true, isPass)
}
测试 JudgePassLine 的代码覆盖率 (return false 是没有被覆盖的)
go test judgment_test.go judgement.go --cover
如何提升代码覆盖率
func TestJudgePassLineTrue(t *testing.T) {
isPass := JudgePassLineTrue(70)
assert.Equal(t, true, isPass)
}
func TestJudgePassLineFalse(t *testing.T) {
isPass := JudgePassLineFalse(50)
assert.Equal(t, false, isPass)
}
依赖
测试过程中强依赖于 File、DB 或者 Cache 数据。单元测试应满足下面的特性:
幂等性:重复运行一个测试的 Case 时,每次的结果都是相同的
稳定性:单元测试是相互隔离的,可以在任何时间、任何函数独立运行
// firstline.go
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func ReadFirstLine() string {
open, err := os.Open("log")
if err != nil {
return ""
}
defer open.Close()
scanner := bufio.NewScanner(open)
for scanner.Scan() {
// 返回scanner 读取到第一行数据
return scanner.Text()
}
return ""
}
func ProcessFirstLine() string {
line := ReadFirstLine()
destLine := strings.ReplaceAll(line, "11", "00")
return destLine
}
// firstline_test.go
func TestFirstLine(t *testing.T) {
firstLine := ProcessFirstLine()
assert.Equal(t, "line00", firstLine)
}
Mock 机制
如果上述的 log 文件被人篡改或者删除,那么单元测试就会失败,为了单元测试的稳定性,引入了 Mock 机制。
monkey: github.com/bouk/monkey
快速 Mock 函数
- 为一个函数打桩
常用函数 Patch & Unpatch
// Patch replaces a function with another
/*
type PatchGuard struct {
target interface{} // 原函数
replacement interface{} // 打桩函数
}
*/
// 为函数打桩
func Patch(target, replacement interface{}) *PatchGuard {
t := reflect.ValueOf(target)
r := reflect.ValueOf(replacement)
patchValue(t, r)
return &PatchGuard{t, r}
}
// 测试结束后,卸载桩
// Unpatch removes any monkey patches on target
// returns whether target was patched in the first place
func Unpatch(target interface{}) bool {
return unpatchValue(reflect.ValueOf(target))
}
代码实例: 对 ReadFirstLine 函数进行打桩测试,不再依赖本地文件
func TestProcessFirstLineWithMock(t *testing.T) {
/* Patch(target, replacement)
target = ReadFirstLine (函数签名为 func() string)
因此构造的匿名函数与 ReadFirstLine 相同,这个匿名函数返回 line110
ReadFirstLine 成功执行返回的也是 line110,就相当于使用构造的匿名函数替代了
原函数(ReadFirstLine),这样的话,即使 log 文件被篡改或者被删除,也不会影响单元测试的结果 */
monkey.Patch(ReadFirstLine, func() string {
return "line110"
})
defer monkey.Unpatch(ReadFirstLine)
line := ProcessFirstLine()
assert.Equal(t, "line000", line)
}
- 为一个方法打桩
打桩:使用 B 函数替换 A 函数,A 就是原函数,B 就是打桩函数
基准测试
bench.go
package main
import (
"math/rand"
"time"
)
var ServerIndex [10]int
func InitServerIndex() {
for i := 0; i < 10; i++ {
ServerIndex[i] = i + 100
}
}
// 随机选择执行服务器
func Select() int {
// 加上随机数种子
rand.Seed(time.Now().UnixNano())
return ServerIndex[rand.Intn(10)]
}
benckmark_test.go
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()
}
})
}
go test -bench=.
加随机种子
不加随机种子
引入 fastrand
package main
import "github.com/bytedance/gopkg/lang/fastrand"
var ServerIndex [10]int
func InitServerIndex() {
for i := 0; i < 10; i++ {
ServerIndex[i] = i + 100
}
}
func FastSelect() int {
return ServerIndex[fastrand.Intn(10)]
}
为什么并发的测试速度还要慢一点?
由于需要保证多线程的并发安全,所以需要使用并发安全锁,那么 goroutine 间就存在锁的竞争,没抢到锁的 goroutine 只能等待锁的释放,这就浪费了一些时间。
项目实战
需求描述
社区话题页面
- 展示话题(标题,文字描述)和回帖列表
- 暂不考虑前端页面实现,仅仅实现一个本地 web 服务
- 话题和回帖数据用文件存储
需求用例
可抽象出两个实体(Topic、Post)
ER 图-Entity Relationship Diagram
话题和帖子属于一对多的关系
分层结构
- 数据层:数据 Model,外部数据的增删改查
- 逻辑层:业务 Entity,处理核心业务逻辑输出
- 视图层:视图 view,处理和外部的交互逻辑
分层可实现不同领域分工,提高代码可读性
组件工具
-
Gin 高性能 go web 框架
-
Go Mod
go mod init
go get gopkg.in/gin-gonic/gin.v1@v1.3.0
Repository
Topic
{
"id": 1,
"title": "青训营",
"content": "快到碗里来~",
"create_time": 1650437625
}
QueryTopicById
Post
{
"id": 1,
"parent_id": 1,
"content": "快来!",
"create_time": 1650437616
}
QueryPostsByParentId
Repository-index
全扫描遍历的方式进行查询:一个个对比,看是否和我们想要的相符
这当然也能实现查询功能,但是效率太低,因此引入了索引(类似书的目录)
将数据行映射成内存的 Map,来实现数据索引功能,通过索引就可以很快定位到内存中的数据。
type Topic struct {
Id int64 `json: "id"`
Title string `json: "title"`
Content string `json: "content"`
CreateTime int64 `json: "create_time"`
}
type Post struct {
Id int64 `json: "id"`
ParentId int64 `json: "parent_id"`
Content string `json: "content"`
CreateTime int64 `json: "create_time"`
}
var (
topicIndexMap map[int64]*Topic
postIndexMap map[int64][]*Post
)
代码:初始化话题数据索引
func initTopicIndexMap(filePath string) error {
open, err := os.Open(filePath + "topic")
if err != nil {
return err
}
scanner := bufio.NewScanner(open)
topicTmpMap := make(map[int64]*Topic)
for scanner.Scan() {
text := scanner.Text()
var topic Topic
if err := json.Unmarshal([]byte(text), &topic); err != nil {
return err
}
// 将 topic 地址存储到 Topic_Id 对应的 Map 值中
// 以后需要查询这个 topic 时,只需要查询键 id 对应的值即可
topicIndexMap[topic.Id] = &topic
}
topicIndexMap = topicTmpMap
return nil
}
代码:初始化帖子数据索引
func initPostIndexMap(filePath string) error {
open, err := os.Open(filePath + "post")
if err != nil {
return err
}
scanner := bufio.NewScanner(open)
postTmpMap := make(map[int64]*Post)
for scannner.Scan() {
text := scannner.Text()
var post Post
if err := json.Unmarshal([]byte(text), &post); err != nil {
return err
}
postTmpMap[post.ParentId] = &post
}
postIndexMap = postTmpMap
return nil
}
Repository 查询
QueryTopicById
type Topic struct {
Id int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreateTime int64 `json:create_time"`
}
type TopicDao struct {
}
var (
topicDao *TopicDao
topicOnce sync.Once
)
// 仅实例化一次
func NewTopicDaoInstance() *TopicDao {
topicOnce.Do(
func() {
topicDao = &TopicDao{}
})
return topicDao
}
func (*TopicDao) QueryTopicById(id int64) *Topic {
return topicIndexMap[id]
}
QueryPostByParentId
type Post struct {
Id int64 `json:"id"`
ParentId int64 `json:"parent_id"`
Content string `json:"content"`
CreateTime int64 `json:"create_time"`
}
type PostDao struct {}
var (
postDao *PostDao
postOnce sync.Once
)
func NewPostDaoInstance() *PostDao {
postOnce.Do(
func() {
postDao = &PostDao{}
})
return postDao
}
func (*PostDao) QueryPostByParentId(parentId int64) *Post {
return postIndexMap[parentId]
}
Service
实体
// 页面信息,包含两个实体(话题、帖子列表)
type PageInfo struct {
Topic *repository.Topic
PostList []*repository.Post
}
流程
参数校验 -> 准备数据 -> 组装实体
- 参数校验:对传入的 topic_id 做合法性校验
- 准备数据:从 Repository 层拿数据
- 组装实体:根据读取到的数据组装(填充)实体内容
代码流程编排
func (f *QueryPageInfoFlow) Do() (*PageInfo, error) {
// 参数校验
if err := f.checkParam(); err != nil {
return nil, err
}
// 通过 repository 层获取 topic 和 postList 数据
if err := f.prepareInfo(); err != nil {
return nil, err
}
if err := f.packPageInfo(); err != nil {
return nil, err
}
return f.pageInfo, nil
}
// topic 信息和 postList 信息同时依赖于 topicid,且相互隔离,因此可以使用并行处理
func (f *QueryPageInfoFlow) prepareInfo() error {
var wg sync.WaitGroup
// 开启两个 goroutine,分别获取 topic 和 post 数据
wg.Add(2)
// 获取 topic 信息
go func() {}()
// 获取 post 信息
go func() {}()
wg.Wait() // 等待子 goroutine
return nil
}
Controller 层
- 构建 View 对象
- 业务错误码
type PageData struct {
Code int64 `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data"`
}
func QueryPageInfo(topicIdStr string) *PageData {
topicId, err := strconv.ParseInt(topicIdStr, 10, 64)
if err != nil {
return &PageData{} // 非 0 状态码
}
pageInfo, err := service.QueryPageInfo(topicId)
if err != nil {
return &PageData{Data: pageInfo} // 成功状态码
}
return &PageData{} //未查询到的状态码
}
Router
- 初始化数据索引
- 初始化引擎配置
- 构建路由
- 启动服务
func main() {
//初始化数据索引
if err := Init("./data/"); err != nil {
os.Exit(-1)
}
// 初始化引擎配置
r := gin.Default()
// 构建路由
r.GET("/community/page/get/:id", func(c *gin.Context) {
topicId := c.Param("id")
data := controller.QueryPageInfo(topicId)
c.JSON(200, data)
})
// 启动服务
err := r.Run()
if err != nil {
return
}
}
运行测试
-
go run server.go
-
curl --location --request GET 'http://0.0.0.0:8080/community/page/get/2' | json
总结
- 项目拆解
- 代码设计
- 测试运行
课后实践
- 支持发布帖子
- 本地 Id 生成需要保证不重复、唯一性
- Append 文件,更新索引,需要注意 Map 的并发安全问题