构建一个可测试的 Go Web 应用

开发 前端
这篇文章中,我们将讨论如何设计 Sourcegraph的单元测试,使其简单易写,容易维护,运行快速并可以被其他人使用。我们希望这里提到的一些模式有助于其他写Go web app的人,同时欢迎对于我们测试方法的建议。在开始测试之前,先来看看我们的框架概览。

几乎每一个程序员都赞同测试是重要的,但测试以多种方式让写测试的人员打退堂鼓。它们可能运行慢,可能使用重复的代码,可能一次测试得太多导致难以定位测试失败的根源。

这篇文章中,我们将讨论如何设计 Sourcegraph的单元测试,使其简单易写,容易维护,运行快速并可以被其他人使用。我们希望这里提到的一些模式有助于其他写Go web app的人,同时欢迎对于我们测试方法的建议。在开始测试之前,先来看看我们的框架概览。 

框架

和其他web app一样,我们的网站有三层:

  • web前端用以服务HTML;

  • HTTP API用以返回JSON; 

  • 数据存储,运行对数据库的SQL查询,返回Go结构体或切片。

当一个用户请求Sourcegraph的页面,前端收到HTTP页面请求,并对API服务器发起一系列HTTP请求。 然后API服务器开始查询数据存储, 数据存储将数据返回给API服务器,然后编码成 JSON格式,返回给web前端服务器,前端使用Go html/template包将数据显示并格式化成HTML。

框架图如下:(更多细节,查看 recap of our Google I/O talk about building a large-scale code search engine in Go.)

 

测试 v0

当我们***次开始构建Sourcegraph,我们以最容易跑起来的方式写了测试。每一个测试都将进入数据库对测试API端点发起HTTP GET请求。测试会解析HTTP返回内容并和预期数据进行对比。一个典型的v0测试如下:

  1. func TestListRepositories(t *testing.T) {  
  2.   tests := []struct { url string; insert []interface{}; want []*Repo }{  
  3.     {"/repos", []*Repo{{Name: "foo"}}, []*Repo{{Name: "foo"}}},  
  4.     {"/repos?lang=Go", []*Repo{{Lang: "Python"}}, nil},  
  5.     {"/repos?lang=Go", []*Repo{{Lang: "Go"}}, []*Repo{{Lang: "Go"}}},  
  6.   }  
  7.   db.Connect()  
  8.   s := http.NewServeMux()  
  9.   s.Handle("/", router)  
  10.   for _, test := range tests {  
  11.     func() {  
  12.       req, _ := http.NewRequest("GET", test.url, nil)  
  13.       tx, _ := db.DB.DbMap.Begin()  
  14.       defer tx.Rollback()  
  15.       tx.Insert(test.data...)  
  16.       rw := httptest.NewRecorder()  
  17.       rw.Body = new(bytes.Buffer)  
  18.       s.ServeHTTP(rw, req)  
  19.       var got []*Repo  
  20.       json.NewDecoder(rw.Body).Decode(&got)  
  21.       if !reflect.DeepEqual(got, want) {  
  22.         t.Errorf("%s: got %v, want %v", test.url, got, test.want)  
  23.       }  
  24.     }()  
  25.   }  

一开始这么写测试简单易行,但随着app进化会变得痛苦。 随着时间推移,我们加入了新特性。更多的特性导致更多的测试,更长的运行时间,延长了我们的dev周期。更多的特性也需要改变和添加新的URL路径(现在大概有75个),大都相当复杂。 Sourcegraph的每一层内部也变得更加复杂,所以我们想独立于其他层做测试。

我们在测试当中遇到了一些问题:

1.测试慢,因为他们要和实际的数据库互动——插入测试用例,发起查询,回滚每一次测试事务。每一次测试大约运行100毫秒,随着我们添加更多的测试累加。

2.测试难以重构。测试用字符串写死了HTTP路径和查询的参数,这意味着如果我们想改变一个URL路径或者查询参数集,不得不手动更新测试中的URL。这种痛会随着我们的URL路由复杂度和数量的增长而加剧。

3.有大量的散乱脆弱的样本代码。安装每一个测试要求确保数据库运行正常并拥有正确的数据。这样的代码在多个案例中重复使用,但是差异的足以在安装代码中引入bug。我们发现自己花大量的时间调试我们的测试而非实际的app代码。

4.测试失败难以诊断。随着app变得更加复杂,因为每一个测试都访问三个应用层,测试失败的根源难以诊断。我们的测试比起单元测试更像是整合测试。

***,我们提出了开发一个公开发行的API客户端的需求。我们想让API容易被模仿,以便于我们的API用户也可以写出好测的代码。

高级测试目标:

随着我们的app演进,我们意识到需要能满足这些高要求的测试:

  • 目标明确:我们需要单独测试app的每一层。

  • 全面: 我们app的全部三层都要被测试到。

  • 快速: 测试需要运行的非常快,意味着不再进行数据库互动。

  • DRY: 尽管我们的app每一层都不同,它们共享了许多通用的数据结构。测试需要利用这一点去消除重复的样本代码。

  • 易模仿: API外部用户应当也可以使用我们的内部测试模式。以我们的API为基础构建的工程,应当可以容易地写出良好的测试。 毕竟,我们的web前端不是独特的——它只是另一个API用户。

我们如何重建测试

写良好的、可维护的测试和良好的、可维护的应用代码是密不可分的。重构应用代码使我们可以极大地改进我们的测试代码,这是我们改进测试的步骤。

1. 构建一个Go HTTP API 客户端

简化测试的***步是用Go为我们的API写一个高质量的客户端。之前,我们的网站是AngularJS app,但是因为我们主要服务静态内容,我们决定将前端HTML生成移动到服务器。这么做以后,我们的新前端就可以使用Go的API客户端和API服务器通信。我们的客户端go-sourcegraph是开源的,go-github库对它的影响巨大。客户端代码(特别是获取仓库数据(repository data)的端点代码)如下:

  1. func NewClient() *Client {  
  2.   c := &Client{BaseURL:DefaultBaseURL}  
  3.   c.Repositories = &repoService{c}  
  4.   return c  
  5. }  
  6.    
  7. type repoService struct{ c *Client }  
  8.    
  9. func (c *repoService) Get(name string) (*Repo, error) {  
  10.     resp, err := http.Get(fmt.Sprintf("%s/api/repos/%s", c.BaseURL, name))  
  11.     if err != nil {  
  12.         return nil, err  
  13.     }  
  14.     defer resp.Body.Close()  
  15.     var repo Repo  
  16.     return &repo, json.NewDecoder(resp.Body).Decode(&repo)  

以前,我们的v0 API测试把大量的URL路径和构建好的HTTP请求用ad-hoc的方式写死,现在它们可以使用这个API客户端构建和发起请求了。

2. 统一HTTP API客户端和数据仓库的接口

接下来,我们统一HTTP API和数据仓库的接口。以前我们的API http.Handlers直接发起SQL查询。现在我们的API http.Handlers只需要解析http.Request再调用我们的数据仓库,数据仓库和HTTP API客户端实现了一样的接口。

借鉴上面的HTTP API客户端(*repoService).Get的方法,我们现在也有了(*repoStore).Get:

  1. func NewDatastore(dbh modl.SqlExecutor) *Datastore {  
  2.   s := &Datastore{dbh: dbh}  
  3.   s.Repositories = &repoStore{s}  
  4.   return s  
  5. }  
  6.    
  7. type repoStore struct{ *Datastore }  
  8.    
  9. func (s *repoStore) Get(name string) (*Repo, error) {  
  10.     var repo *Repo  
  11.     return repo, s.db.Select(&repo, "SELECT * FROM repo WHERE name=$1", name)  

统一这些接口把我们的web app的行为描述放在一个地方,使得它更易理解和推理。而且我们可以在API客户端和数据仓库中重用相同的数据类型和参数结构。

3. 集中URL路径定义

之前,我们不得不在应用的多个层重新定义URL路径。在API客户端中,我们的代码是这样的

  1. resp, err := http.Get(fmt.Sprintf("%s/api/repos/%s", c.BaseURL, name)) 

这种方式很容易引发错误,因为我们有超过75个路径定义,还有很多是复杂的。集中URL路径定义意味着从API服务器独立出来在一个新包中重构路径。路径包中声明了路径的定义。

  1. const RepoGetRoute = "repo" 
  2.    
  3. func NewAPIRouter() *mux.Router {  
  4.     m := mux.NewRouter()  
  5.     // define the routes  
  6.     m.Path("/api/repos/{Name:.*}").Name(RepoGetRoute)  
  7.     return m  
  8. }  
  9.    
  10. while the http.Handlers were actually mounted in the API server package:  
  11.    
  12. func init() {  
  13.     m := NewAPIRouter()  
  14.     // mount handlers  
  15.     m.Get(RepoGetRoute).HandlerFunc(handleRepoGet)  
  16.     http.Handle("/api/", m)  

 而http.Handlers 实际上在API服务器包中挂载:

  1. func init() {  
  2.     m := NewAPIRouter()  
  3.     // mount handlers  
  4.     m.Get(RepoGetRoute).HandlerFunc(handleRepoGet)  
  5.     http.Handle("/api/", m)  

现在我们可以在API客户端中使用路径包生成URL,而不是把它们写死。(*repoService).Get方法现在如下:

  1. var apiRouter = NewAPIRouter()  
  2.    
  3. func (s *repoService) Get(name string) (*Repo, error) {  
  4.     url, _ := apiRouter.Get(RepoGetRoute).URL("name", name)  
  5.     resp, err := http.Get(s.baseURL + url.String())  
  6.     if err != nil {  
  7.         return nil, err  
  8.     }  
  9.     defer resp.Body.Close()  
  10.    
  11.     var repo []Repo  
  12.     return repo, json.NewDecoder(resp.Body).Decode(&repo)  

4. 创建未统一接口的仿制

我们的v0测试同时测试了路径、HTTP处理、SQL生成和DB查询。失败难以诊断,测试也很慢。

现在,我们拥有每一层的独立测试并且我们模仿了毗邻层的功能。因为应用的每一层实现了相同的接口,所以我们可以在所有的三层中使用同样的仿制接口。

仿制的实现是简单的模拟函数结构,可以在每一个测试中指明:

  1. type MockRepoService struct {  
  2.     Get_ func(name string) (*Repo, error)  
  3. }  
  4.    
  5. var _ RepoInterface = MockRepoService{}  
  6.    
  7. func (s MockRepoService) Get(name string) (*Repo, error) {  
  8.     if s.Get_ == nil {  
  9.         return nil, nil  
  10.     }  
  11.     return s.Get_(name)  
  12. }  
  13.    
  14. func NewMockClient() *Client { return &Client{&MockRepoService{}} } 

下面是测试中的使用。我们模仿了数据仓库的RepoService,使用HTTP API客户端测试API http.Handler。(这段代码使用了上述所有方法。)

  1. func TestRepoGet(t *testing.T) {  
  2.    setup()  
  3.    defer teardown()  
  4.    
  5.    var fetchedRepo bool  
  6.    mockDatastore.Repo.(*MockRepoService).Get_ = func(name string) (*Repo, error) {  
  7.        if name != "foo" {  
  8.            t.Errorf("want Get %q, got %q""foo", repo.URI)  
  9.        }  
  10.        fetchedRepo = true 
  11.        return &Repo{name}, nil  
  12.    }  
  13.    
  14.    repo, err := mockAPIClient.Repositories.Get("foo")  
  15.    if err != nil { t.Fatal(err) }  
  16.    
  17.    if !fetchedRepo { t.Errorf("!fetchedRepo") }  

高级测试目标回顾

使用上述模式,我们实现了测试目标。我们的代码是:

  • 目标明确: 一次测试一层。

  • 全面: 三个应用层均被测试。

  • 快速: 测试运行得很快。

  • DRY: 我们合并了三个应用层的通用接口, 在应用代码和测试中进行了重用。

  • 易模仿: 一个仿制实现在三个应用层中都可以使用,想测试以Sourcegraph为基础构建的库的外部API用户也可以使用。

关于如何重新构建并改进Sourcegraph的测试的故事就讲完了。这些模式和例子在我们的环境中运行良好,我们希望这些模式和例子也能帮助到Go社区的其他人,显而易见的是它们并不是在每一个场景下都是正确的,我们确信还有改进的空间。我们在不断的尝试改进做事的方法,所以我们乐意听到你的建议和反馈——说说你用Go写测试的经历吧!

本文来自:http://www.oschina.net/translate/building-a-testable-webapp

责任编辑:林师授 来源: 开源中国社区 编译
相关推荐

2023-09-15 10:10:05

R 语言

2023-05-10 08:05:41

GoWeb应用

2024-01-02 00:18:56

Buffalo项目Go Web框架

2022-09-20 08:43:37

Go编程语言Web

2023-09-21 08:00:00

ChatGPT编程工具

2015-12-04 11:36:04

SaaS架构设计可持续

2022-04-12 14:00:05

元宇宙人工智能安全

2010-07-12 10:11:27

ibmdwWeb

2021-06-18 06:11:26

工具WebpackSnowpack

2014-02-26 10:14:51

OpenStack测试系统

2021-12-21 06:23:43

TIWAP安全工具渗透测试

2019-07-05 08:39:39

GoSQL解析器

2023-12-26 00:58:53

Web应用Go语言

2011-09-16 17:18:43

iPhone应用TimeSpan

2019-09-29 15:25:13

CockroachDBGoJavaScript

2022-02-10 07:03:32

流量应用架构数据交换

2019-05-08 14:37:49

Web服务器HTTP

2024-01-09 18:00:22

Rust后端slvelte

2019-10-28 20:12:40

OAuthGuard中间件编程语言

2020-10-09 12:45:19

创建消息即时消息编程语言
点赞
收藏

51CTO技术栈公众号