golang及beego框架单元测试小结

  单元测试是保证开发质量的一个重要手段,提及golang开发,要保证开发质量,则难以回避单元测试的使用。golang开发语言有原生提供单元测试相关代码及工具,不足之处是代码实现层面不是太友好,写单元测试不大便利;有第三方,依据大家习惯使用的断言方式,给出了开源解决方案testify,为大家写单元测试提供了便利;具体到beego框架,鉴于其实现机制,实现单元测试,也需要进行适当的调整,下面将依次进行说明。

一、golang原生单元测试

  golang原生支持单元测试,使用上非常简单,测试代码只需要放到以 _test.go 结尾的文件中即可,golang单元测试的测试用例以 Test 开头,下面举例说明。

1 代码样例

  文件列表如下:

file.go
file_test.go
go.mod

file.go文件内容如下:

package file

import (
	"os"
	"path"
	"strings"
)

// Exists reports whether the named file or directory exists.
func Exists(name string) bool {
	if _, err := os.Stat(name); err != nil {
		if os.IsNotExist(err) {
			return false
		}
	}

	return true
}

func PureName(name string) string {
	return strings.TrimSuffix(name, path.Ext(name))
}

file_test.go文件内容如下:

package file

import (
	"fmt"
	"io/ioutil"
	"os"
	"testing"
)

func TestExists(t *testing.T) {
	// ioutil.TempDir 不指定第一个参数,则默认使用os.TempDir()目录
	tmepDir := os.TempDir()
	fmt.Println(tmepDir)

	tempDir, err := ioutil.TempDir("", "fileTest") // 在DIR目录下创建tmp为目录名前缀的目录,DIR必须存在,否则创建不成功
	if err != nil {
		t.Errorf("create temp directory failed with %v.", err)
		return
	}
	// fmt.Println(tempDir) // 生成的目录名为tmpXXXXX,XXXXX为一个随机数

	defer os.RemoveAll(tempDir)

	testFilePath := tempDir + string(os.PathSeparator) + "existsTest.txt"
	r := Exists(testFilePath)
	if r {
		t.Errorf("Exists(%s) failed. Got %t, expected false.", testFilePath, r)
		return
	}

	file, error := os.Create(testFilePath)
	if error != nil {
		fmt.Println("文件创建失败")
		return
	}
	file.WriteString("insert into file") //利用file指针的WriteString()写入内容
	file.Close()

	r = Exists(testFilePath)
	if !r {
		t.Errorf("Exists(%s) failed. Got %t, expected true.", testFilePath, r)
	}
}

func TestPureName(t *testing.T) {
	name := "what.bat"
	expect := "what"
	pure := PureName(name)
	r := pure == expect
	if !r {
		t.Errorf("PureName(%s) failed. Got %s, expected %s.", name, pure, expect)
	}

	name = "name"
	expect = "name"
	pure = PureName(name)
	r = pure == expect
	if !r {
		t.Errorf("PureName(%s) failed. Got %s, expected %s.", name, pure, expect)
	}
}

go.mod文件内容如下:

module example.com/file

go 1.18

2 创建golang模块说明

  创建go语言模块,可以考虑使用go命令行工具的mod子命令创建模块:

go mod init example.com/file

然后再使用如下命令下载更新依赖:

go mod tidy

  关于golang模块创建,详情可参考官方说明https://golang.google.cn/doc/tutorial/create-module。golang workspaces使用,详情可参考官方说明https://golang.google.cn/doc/tutorial/workspaces。

3 运行测试用例

  下面以Visual Studio Code作为开发工具进行演示说明,将依次说明如何运行单个测试用例、运行全部测试用例以及运行部分测试用例。

(1) 运行单个测试用例

  在每个测试用例左上角有“run test|debug test”两个按钮,点击“run test”按钮会直接运行该测试用例,点击“debug test”按钮则会以debug模式运行该测试用例,实际效果可参见下图:
在这里插入图片描述
运行后的测试结果会在下面的OUTPUT栏下面展示。

(2) 运行全部测试用例

  运行全部测试用例,比较简单,在控制台上,进入代码所在目录,直接运行下面命令即可:

go test

相应效果截图如下:
在这里插入图片描述

(3) 运行部分测试用例

  运行部分测试用例,有两种比较典型的方式,一是直接在go test命令后带上要跑的测试代码所在文件以及依赖文件,样例如下:

go test .\file.go .\file_test.go

相应效果截图如下:
在这里插入图片描述
二是直接运行整个模块的测试用例,样例如下:

go test cfh008.com/file

相应效果截图如下:
在这里插入图片描述
  想了解更详细的执行测试用例方式,可通过执行下面命令查看官方说明文档:

go help test

二、使用单元测试框架

  前面讲过,直接使用原生的单元测试方式,写测试用例不大方便,这里推荐一个第三方开源组件testify(https://github.com/stretchr/testify),可简化测试用例的编写,提高效率。下面我们使用testify重新实现一下上述file模块单元测试用例编写,新的file_test.go代码如下:

package file

import (
	"fmt"
	"io/ioutil"
	"os"

	//	"path/filepath"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestExists(t *testing.T) {
	assert := assert.New(t)

	// ioutil.TempDir 不指定第一个参数,则默认使用os.TempDir()目录
	tmepDir := os.TempDir()
	fmt.Println(tmepDir)

	tempDir, err := ioutil.TempDir("", "fileTest") // 在DIR目录下创建tmp为目录名前缀的目录,DIR必须存在,否则创建不成功
	assert.Nil(err)
	// fmt.Println(tempDir) // 生成的目录名为tmpXXXXX,XXXXX为一个随机数

	defer os.RemoveAll(tempDir)

	testFilePath := tempDir + string(os.PathSeparator) + "existsTest.txt"
	assert.False(Exists(testFilePath))

	file, error := os.Create(testFilePath)
	assert.Nil(error)
	file.WriteString("insert into file") //利用file指针的WriteString()写入内容
	file.Close()

	assert.True(Exists(testFilePath))
}

func TestPureName(t *testing.T) {
	assert := assert.New(t)

	name := "what.bat"
	expect := "what"
	pure := PureName(name)
	assert.Equal(pure, expect)

	name = "name"
	expect = "name"
	pure = PureName(name)
	assert.Equal(pure, expect)
}

对照查看,会发现使用testify,实现同样的效果,代码简洁很多。如果使用新的file_test.go替换了前面的旧文件,则需要在控制台运行下面的命令:

go mod tidy

确保依赖的第三库已经下载下来,然后再根据需要,使用前面运行单元测试用例的方式,执行测试用例。
  如果想对面向对象实现的代码进行单元测试,该框架也提供了便利(使用方式可参考https://pkg.go.dev/github.com/stretchr/testify/suite),下面直接提供代码fileSuite_test.go,想进一步研究的可自行下载实际验证。

package file

// Basic imports
import (
	"fmt"
	"io/ioutil"
	"os"
	"testing"

	"github.com/stretchr/testify/suite"
)

// Define the suite, and absorb the built-in basic suite
// functionality from testify - including assertion methods.
type FileTestSuite struct {
	suite.Suite
	ModleName string
}

// Make sure that ModleName is set to file
// before each test
func (suite *FileTestSuite) SetupSuite() {
	fmt.Println("begin to execute")
	suite.ModleName = "file"
}

// before each test
func (suite *FileTestSuite) SetupTest() {
	fmt.Println("begin to execute test case")
}

// after each test
func (suite *FileTestSuite) TearDownTest() {
	// suite.Equal(suite.ModleName, "")
	fmt.Println("to the end of test case")
}

// after all test
func (suite *FileTestSuite) TearDownSuite() {
	fmt.Println("to the end")
}

// All methods that begin with "Test" are run as tests within a
// suite.
func (suite *FileTestSuite) TestExists() {
	suite.Equal(suite.ModleName, "file")

	// ioutil.TempDir 不指定第一个参数,则默认使用os.TempDir()目录
	tmepDir := os.TempDir()
	fmt.Println(tmepDir)

	tempDir, err := ioutil.TempDir("", "fileTest") // 在DIR目录下创建tmp为目录名前缀的目录,DIR必须存在,否则创建不成功
	suite.Nil(err)
	// fmt.Println(tempDir) // 生成的目录名为tmpXXXXX,XXXXX为一个随机数

	defer os.RemoveAll(tempDir)

	testFilePath := tempDir + string(os.PathSeparator) + "existsTest.txt"
	suite.False(Exists(testFilePath))

	file, error := os.Create(testFilePath)
	suite.Nil(error)
	file.WriteString("insert into file") //利用file指针的WriteString()写入内容
	file.Close()

	suite.True(Exists(testFilePath))
}

// All methods that begin with "Test" are run as tests within a
// suite.
func (suite *FileTestSuite) TestPureName() {
	suite.Equal(suite.ModleName, "file")

	name := "what.bat"
	expect := "what"
	pure := PureName(name)
	suite.Equal(pure, expect)

	name = "name"
	expect = "name"
	pure = PureName(name)
	suite.Equal(pure, expect)
}

// In order for 'go test' to run this suite, we need to create
// a normal test function and pass our suite to suite.Run
func TestFileTestSuite(t *testing.T) {
	suite.Run(t, new(FileTestSuite))
}

三、beego单元测试注意点

  提到beego的单元测试(以我使用的“github.com/beego/beego/v2 v2.0.5”版本为例),这里重点需要注意的主要是两点,第一点,因为单元测试用例执行时自成体系,正常执行程序时所做的配置信息加载以及数据库存储之类的初始化操作,是不会执行的,所以我们需要自行处理这方面的内容;第二点,涉及到网络请求的,特别是涉及登录session之类的操作的,需要考虑全部环节。下面将分别进行具体说明。

1 单元测试依赖初始化

  单元测试的依赖主要是全局配置的加载以及数据库初始化,下面我们直接给出方案,代码样例如下:

package models

import (
	"path/filepath"
	"runtime"

	beego "github.com/beego/beego/v2/server/web"
)
func InitForTest() {
	_, file, _, _ := runtime.Caller(0)
	apppath, _ := filepath.Abs(filepath.Dir(filepath.Join(file, ".."+string(filepath.Separator))))
	// 加载全局配置
	beego.TestBeegoInit(apppath)
	// logs.Info("%v call beego.TestBeegoInit(%v)", runutils.RunFuncName(), apppath)
	Init(apppath)
}

其中数据库初始化相关代码封装在了*func Init(workPath string)*方法中,可根据实际情况,实现具体业务逻辑。

2 外部请求全环节实现

  实现外部请求全环节,关键是登录完成后,需要将登录后获取到的cookie带入到后面的请求中,从而实现模拟已登录用户请求操作,达到单元测试目的。在讲解具体实现前,先给大家查看一下整体目录结构,使大家对背景有一个大体了解,对应截图信息如下:
在这里插入图片描述
上图中,有用红色方框标记的conf目录,这里之所以特意标注,是因为使用beego 2.0.5版本时,很多全局性的变量是在运行时(使用模块的init()方法,优先级很高),直接从工作目录下或可执行文件同级目录下的conf目录中读取配置直接进行的初始化,而且后面调用相应方法重新加载配置也无效,不得已情况下,只好把全局的配置拷贝一份放到tests目录下,确保可找到配置,正常完成初始化。
  具体实现全环节请求时,直接上代码:

package test

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"manager/controllers"
	"net/http"
	"net/http/httptest"
	"strconv"
	"testing"
	"time"

	"gitee.com/cfh008/runutils"
	"github.com/beego/beego/v2/core/logs"
	beego "github.com/beego/beego/v2/server/web"
	"github.com/stretchr/testify/assert"
)

func loginForCookies(username string, password string) (cookies []*http.Cookie, err error) {
	params := make(map[string]interface{})
	currentTime := time.Duration(time.Now().UnixNano()).Milliseconds()
	timestamp := strconv.FormatInt(currentTime, 10)
	params["sn"] = timestamp
	data := make(map[string]interface{})
	params["data"] = data
	data["username"] = username
	data["password"] = password
	reqBody, doErr := json.Marshal(params)
	if doErr != nil {
		errorInfo := fmt.Sprintf("%v json.Marshal(%v) failed with %v", runutils.RunFuncName(), params, doErr)
		err = errors.New(errorInfo)
		logs.Error(errorInfo)
		return
	}

	r, _ := http.NewRequest("POST", "/v1/user/login", bytes.NewReader(reqBody))
	w := httptest.NewRecorder()
	beego.BeeApp.Handlers.ServeHTTP(w, r)
	var result map[string]interface{}
	doErr = json.Unmarshal(w.Body.Bytes(), &result)
	if doErr != nil {
		errorInfo := fmt.Sprintf("%v json.Unmarshal(%v) failed with %v", runutils.RunFuncName(), string(w.Body.Bytes()), doErr)
		err = errors.New(errorInfo)
		logs.Error(errorInfo)
		return
	}
	cookies = w.Result().Cookies()

	return
}

func TestUserStatus(t *testing.T) {
	asserter := assert.New(t)

	// 未登录直接请求
	params := make(map[string]interface{})
	currentTime := time.Duration(time.Now().UnixNano()).Milliseconds()
	timestamp := strconv.FormatInt(currentTime, 10)
	params["sn"] = timestamp
	data := make(map[string]interface{})
	params["data"] = data
	reqBody, reqErr := json.Marshal(params)
	asserter.Nil(reqErr)

	r, _ := http.NewRequest("POST", "/v1/user/status", bytes.NewReader(reqBody))
	w := httptest.NewRecorder()
	beego.BeeApp.Handlers.ServeHTTP(w, r)
	var result map[string]interface{}
	doErr := json.Unmarshal(w.Body.Bytes(), &result)
	asserter.Nil(doErr)
	asserter.Equal(200, w.Code)
	asserter.Greater(w.Body.Len(), 0)
	resultCode := result["code"].(string)
	asserter.Equal(strconv.Itoa(controllers.ERROR_USER_NOT_LOGIN), resultCode)

	// 先登录
	cookies, doErr := loginForCookies("yourName", "yourPassword")
	asserter.Nil(doErr)
	asserter.True(len(cookies) > 0)

	// 正常登录后请求
	params = make(map[string]interface{})
	currentTime = time.Duration(time.Now().UnixNano()).Milliseconds()
	timestamp = strconv.FormatInt(currentTime, 10)
	params["sn"] = timestamp
	data = make(map[string]interface{})
	params["data"] = data
	reqBody, reqErr = json.Marshal(params)
	asserter.Nil(reqErr)

	r, _ = http.NewRequest("POST", "/v1/user/status", bytes.NewReader(reqBody))
	for _, item := range cookies {
		r.AddCookie(item)
	}
	w = httptest.NewRecorder()
	beego.BeeApp.Handlers.ServeHTTP(w, r)
	doErr = json.Unmarshal(w.Body.Bytes(), &result)
	asserter.Nil(doErr)
	asserter.Equal(200, w.Code)
	asserter.Greater(w.Body.Len(), 0)
	resultCode = result["code"].(string)
	asserter.Equal(strconv.Itoa(0), resultCode)
}

上面的代码,是和对应接口实现相匹配的代码,大家在实现逻辑时,需要根据实际情况进行必要的调整。
  在实际工作中,大家可结合前面提到的testify框架,把一些公共的代码抽离出来,减少冗余代码。至此,总结告一段落。