最近在做项目设计的时候,考虑到实际项目与传统意义上的测试有些现实的差距。目前CI部分并不是对每次的测试环境都有一个新环境的准备,在实际的自动化测试中会糅合一部分QA人工的操作,在此背景下需要在自动化测试代码层面中控制,需要注意的是,e2e测试最好的设计还是能够有个干净的单独环境用于随时拉起与清除资源,以避免不要的脏数据保证与实际功能迭代相匹配

鉴于以上的上下文,顺便设计了个简单的可扩展的测试框架,便于项目集成。

功能拆分

[package]core 设计
关键元素拆分:

  • group:以功能组为最上层测试用例注册维度
1
2
3
4
5
6
7
8
9
10
11
type BaseGroup struct {
// group name
Name string
// group description
Desc string
// Reqs list of features interfaces
Reqs []req.Req
// rest api client
Client *restclient.Client
}

group 抽象接口的设计:

1
2
3
4
5
6
7
8
9
10
type Interface interface {
// client init
InitC()
// group init
Init()
// start group test
Start() error
// clean up resources
Cleanup() error
}
  • req:实际功能

这里又要提一句的是,架构在设计 struct 的时候,尽量考虑复用。这里是因为在实际项目过程中,私有代码仓库中的 module 无法被引用,主要是因为历史原因在做整个大工程项目的时候没有做好很好的切分与独立,所以只能退而求其次来解决问题,考虑搞 sync 项目中的部分代码独立出一个库,但是这个高级功能在 gitlab 中需要付费版才支持,所以折中就出现如下架构设计。

1
2
3
4
5
6
7
8
9
10
11
12
13
type BaseReq struct {
// req name
Name string
// req uri
Uri string
// req method
Method string
// req body
Body interface{}
// req query
Query map[string]string
Runtime runtime.GroupRuntime
}
1
2
3
4
5
6
7
8
9
10
11
12
// Req for test request
type Req interface {
Init()
Do(c *restclient.Client) error
Prepare() error
}

// PreReq for prepare request
type PreReq interface {
Init()
PreCheck() interface{}
}

这里抽象两个 req,主要是为了便于处理资源准备接口的区分,在实际的资源准备中,对于请求之后的状态码其实不应该在设计的结构体中进行描述,为了更好的表意语义。

  • restclient:http 请求库构造,有了如上的测试元素的实例,还需要存在 http 客户端进行具体请求的执行,此次设计使用的 clientresty,虽然最近不维护了,但是看了下 star 和源码实现都是可控的。切记在选型时,要根据实际情况选择,如果社区活跃且支持较好,复杂点的库也可使用,如果社区活跃度一般,但是评估之后可控也可以适当尝试,具体看实际需求。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
type Client struct {
Rclient *resty.Client
config.Config
}

func NewClient() *Client {
conf := config.NewConfig()
c := &Client{}
c.Rclient = resty.New()
c.Server = conf.Server
c.Port = conf.Port
c.User = conf.User
c.Passwd = conf.Passwd
c.Restries = conf.Restries
return c
}

func (c *Client) Request(method, uri string, body interface{}, query map[string]string) (interface{}, error) {
r := c.Rclient.R()
r.SetHeader("Content-Type", "application/json")

token, err := c.getToken(r)
if err != nil {
return nil, err
}

t, err := util.GetValueFromJson(token, "token")
if err != nil {
return nil, err
}
r.SetAuthToken(t)

url := fmt.Sprintf("http://%s:%s%s", c.Server, c.Port, uri)

switch method {
case "GET":
resp, err := get(r, url, query)
if err != nil {
return nil, err
}
return resp, nil
case "POST":
// TODO implement client
panic("implement me")
case "PUT":
// TODO implement client
panic("implement me")
case "DELETE":
// TODO implement client
panic("implement me")
default:
return nil, fmt.Errorf("unsupported method: %s", method)
}
}

func (c *Client) getToken(r *resty.Request) (string, error) {
var token string
path := fmt.Sprintf("http://%s:%s/login", c.Server, c.Port)

resp, err := r.SetBody(map[string]string{"userid": "admin", "password": "Password"}).
Post(path)
if err != nil {
return "", fmt.Errorf("get token failed with gui web : %v", err)
}
token = resp.String()
return token, nil
}

func get(r *resty.Request, url string, query map[string]string) (interface{}, error) {
var resp *resty.Response
var err error
if query != nil {
resp, err = r.SetQueryParams(query).
Get(url)
if err != nil {
return nil, fmt.Errorf("get request failed with gui web : %v", err)
}
} else {
resp, err = r.Get(url)
if err != nil {
return nil, fmt.Errorf("get request failed with gui web : %v", err)
}

}
return resp, nil
}
  • config: web server 配置定义
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
var c *Config

type Config struct {
Server string `yaml:"server"`
Port string `yaml:"port"`
User string `yaml:"user"`
Passwd string `yaml:"password"`
Restries int `yaml:"restries"`
}

func NewConfig() *Config {
dir, _ := os.Getwd()
filePath := path.Join(dir, "config.yaml")
config, err := ioutil.ReadFile(filePath)
if err != nil {
panic(err)
}
err = yaml.Unmarshal(config, &c)
if err != nil {
panic(err)
}
if c.Restries <= 0 {
c.Restries = 3
}
return c
}
  • 扩展设计与支持: 由于忙于工作其他项目的解决方案的设计,对于测试中间日志的显示和最终结果的汇总暂时还没做,这也是一个需要注意的点,先跳过此设计。

流程实现

既然具体功能元素已经拆分结束,下面就是需要设计入口来实现具体测试用例的注册与调用了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
type GuiApiTest struct {
Groups []group.Interface
}

func NewGuiApiTest() *GuiApiTest {
return &GuiApiTest{}
}

// 对需要进行测试 group 注册
func (g *GuiApiTest) Init() {
g.Groups = []group.Interface{
groups.NewFilesystemGroup(),
}
}

// 实际测试元素的处理
func (g *GuiApiTest) Start() error {
for i := range g.Groups {
g.Groups[i].Init()
if err := g.Groups[i].Start(); err != nil {
return err
}
}
return nil
}

// group 维度资源清除
func (g *GuiApiTest) Cleanup() error {
for i := range g.Groups {
if err := g.Groups[i].Cleanup(); err != nil {
return err
}
}
return nil
}

继续往下看,看看 FilesystemGroup 中有啥?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
type GetFileSystemList struct {
req.BaseReq
// 姑且是一个冗余设计
prepares []req.PreReq
// 这里可以看到,这是一个非资源准备 req,是实际的需要测试的功能接口,所以需要对 assert 做一个期望描述
StatusCode int
}

func (g *GetFileSystemList) Init() {
g.Name = "Get_FileSystemList_Success"
g.Url = "/v1/storage/filesystem/N9000"
g.Method = "GET"
// g.ContentType = "application/json"
// g.Authenticated = true
g.StatusCode = 200
g.prepares = nil
}

func (g *GetFileSystemList) Prepare() error {
return nil
}

func (g *GetFileSystemList) Do(c *restclient.Client) error {
// 实际 http request 请求,对业务接口进行测试,为避免网络抖动,可根据之前 conf配置的 重试次数进行多次请求验证, resty 库原生支持,这里暂且没实现。
resp, err := c.Request(g.Method, g.Url, nil, nil)
if err != nil {
return fmt.Errorf("[%s] test not passed", g.Name)
}
statusCode := resp.(*resty.Response).StatusCode()

if statusCode != g.StatusCode {
return fmt.Errorf("[%s] test not passed. want status code: %d, got status code: %d", g.Name, g.StatusCode, statusCode)
}
return nil
}

如上 c.Request(g.Method, g.Url,nil,nil) 具体逻辑可以看 Client 的设计。

最后剩下的就是对整个入口的启动调用了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func main() {

tests := pkg.NewGuiApiTest()
tests.Init()
err := tests.Start()
if err != nil {
os.Exit(1)
}

err = tests.Cleanup()
if err != nil {
os.Exit(1)
}

os.Exit(0)
}

自此一个雏形设计就出来了,可在此基础上继续扩展实现实际的项目需求了。^_^