千家信息网

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

发表于:2025-01-20 作者:千家信息网编辑
千家信息网最后更新 2025年01月20日,如何构建一个可测试的Go Web应用,针对这个问题,这篇文章详细介绍了相对应的分析和解答,希望可以帮助更多想解决这个问题的小伙伴找到更简单易行的方法。几乎每一个程序员都赞同测试是重要的,但测试以多种方
千家信息网最后更新 2025年01月20日如何构建一个可测试的Go Web应用

如何构建一个可测试的Go Web应用,针对这个问题,这篇文章详细介绍了相对应的分析和解答,希望可以帮助更多想解决这个问题的小伙伴找到更简单易行的方法。

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

这篇文章中,我们将讨论如何设计 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测试如下:

func TestListRepositories(t *testing.T) {    tests := []struct { url string; insert []interface{}; want []*Repo }{      {"/repos", []*Repo{{Name: "foo"}}, []*Repo{{Name: "foo"}}},      {"/repos?lang=Go", []*Repo{{Lang: "Python"}}, nil},      {"/repos?lang=Go", []*Repo{{Lang: "Go"}}, []*Repo{{Lang: "Go"}}},    }    db.Connect()    s := http.NewServeMux()    s.Handle("/", router)    for _, test := range tests {      func() {        req, _ := http.NewRequest("GET", test.url, nil)        tx, _ := db.DB.DbMap.Begin()        defer tx.Rollback()        tx.Insert(test.data...)        rw := httptest.NewRecorder()        rw.Body = new(bytes.Buffer)        s.ServeHTTP(rw, req)        var got []*Repo        json.NewDecoder(rw.Body).Decode(&got)        if !reflect.DeepEqual(got, want) {          t.Errorf("%s: got %v, want %v", test.url, got, test.want)        }      }()    }  }

一开始这么写测试简单易行,但随着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)的端点代码)如下:

func NewClient() *Client {    c := &Client{BaseURL:DefaultBaseURL}    c.Repositories = &repoService{c}    return c  }     type repoService struct{ c *Client }     func (c *repoService) Get(name string) (*Repo, error) {      resp, err := http.Get(fmt.Sprintf("%s/api/repos/%s", c.BaseURL, name))      if err != nil {          return nil, err      }      defer resp.Body.Close()      var repo Repo      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:

func NewDatastore(dbh modl.SqlExecutor) *Datastore {    s := &Datastore{dbh: dbh}    s.Repositories = &repoStore{s}    return s  }     type repoStore struct{ *Datastore }     func (s *repoStore) Get(name string) (*Repo, error) {      var repo *Repo      return repo, s.db.Select(&repo, "SELECT * FROM repo WHERE name=$1", name)  }

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

3. 集中URL路径定义

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

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

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

const RepoGetRoute = "repo"    func NewAPIRouter() *mux.Router {      m := mux.NewRouter()      // define the routes      m.Path("/api/repos/{Name:.*}").Name(RepoGetRoute)      return m  }     while the http.Handlers were actually mounted in the API server package:     func init() {      m := NewAPIRouter()      // mount handlers      m.Get(RepoGetRoute).HandlerFunc(handleRepoGet)      http.Handle("/api/", m)  }

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

func init() {      m := NewAPIRouter()      // mount handlers      m.Get(RepoGetRoute).HandlerFunc(handleRepoGet)      http.Handle("/api/", m)  }

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

var apiRouter = NewAPIRouter()     func (s *repoService) Get(name string) (*Repo, error) {      url, _ := apiRouter.Get(RepoGetRoute).URL("name", name)      resp, err := http.Get(s.baseURL + url.String())      if err != nil {          return nil, err      }      defer resp.Body.Close()         var repo []Repo      return repo, json.NewDecoder(resp.Body).Decode(&repo)  }

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

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

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

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

type MockRepoService struct {      Get_ func(name string) (*Repo, error)  }     var _ RepoInterface = MockRepoService{}     func (s MockRepoService) Get(name string) (*Repo, error) {      if s.Get_ == nil {          return nil, nil      }      return s.Get_(name)  }     func NewMockClient() *Client { return &Client{&MockRepoService{}} }

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

func TestRepoGet(t *testing.T) {     setup()     defer teardown()        var fetchedRepo bool     mockDatastore.Repo.(*MockRepoService).Get_ = func(name string) (*Repo, error) {         if name != "foo" {             t.Errorf("want Get %q, got %q", "foo", repo.URI)         }         fetchedRepo = true        return &Repo{name}, nil     }        repo, err := mockAPIClient.Repositories.Get("foo")     if err != nil { t.Fatal(err) }        if !fetchedRepo { t.Errorf("!fetchedRepo") }  }

高级测试目标回顾

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

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

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

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

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

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

关于如何重新构建并改进Sourcegraph的测试的故事就讲完了。这些模式和例子在我们的环境中运行良好,我们希望这些模式和例子也能帮助到Go社区的其他人,显而易见的是它们并不是在每一个场景下都是正确的,我们确信还有改进的空间。

关于如何构建一个可测试的Go Web应用问题的解答就分享到这里了,希望以上内容可以对大家有一定的帮助,如果你还有很多疑惑没有解开,可以关注行业资讯频道了解更多相关知识。

测试 数据 代码 客户 客户端 路径 应用 服务 运行 接口 服务器 仓库 前端 更多 查询 复杂 数据库 方法 模式 用户 数据库的安全要保护哪些东西 数据库安全各自的含义是什么 生产安全数据库录入 数据库的安全性及管理 数据库安全策略包含哪些 海淀数据库安全审计系统 建立农村房屋安全信息数据库 易用的数据库客户端支持安全管理 连接数据库失败ssl安全错误 数据库的锁怎样保障安全 辽宁新一代软件开发服务品质保障 国企加强公司网络安全 南通工程机械外包软件开发平台 计算机网络安全面临的威胁等级 如何抓取服务器所有网页 软件开发方向哪个好 我的世界18服务器空岛咋刷东西 银行软件开发工资上限 中国最好的网络安全大学 戴尔机架式服务器配置 漂浮悬浮时f服务器 1363协议使用什么软件开发 红米手机关闭小米云服务器 c#导入oracle数据库 软件开发去那个网站 深圳软件开发公司类似贝店 服务器文件管理器怎么关闭 洛阳西美网络技术服务有限公司 高中可以从事软件开发吗 boat进mod服务器 宝山区手机软件开发系统 网络安全趣味抢答题库 武汉商途软件开发 租传奇服务器多少钱 宁波应用软件开发价格是多少 个人发现网络安全 服务器节点对应的域名在哪里找 网络安全制胜法宝视频 吃鸡日本服务器 网络安全宣讲会海报
0