几乎每个程序员都同意测试很重要,但是测试在很多方面阻碍了测试编写者。它们可能运行缓慢,可能使用重复代码,并且可能同时进行太多测试,从而难以定位测试失败的根源。在本文中,我们将讨论如何设计Sourcegraph的单元测试,以便它们易于编写、易于维护、运行速度快并且可供其他人使用。我们希望这里提到的一些模式可以帮助其他人编写Goweb应用程序,并欢迎对我们的测试方法提出建议。在我们开始测试之前,请看一下我们的框架概述。框架就像任何其他网络应用程序一样,我们的网站具有三层:提供HTML的网络前端;返回JSON的HTTPAPI;以及对数据库运行SQL查询并返回Go结构或切片的数据存储。当用户请求Sourcegraph页面时,前端接收到HTTP页面请求,并向API服务器发起一系列HTTP请求。然后API服务器开始查询数据存储,数据存储将数据返回给API服务器,然后编码成JSON格式,返回给Web前端服务器。前端使用Gohtml/template包来显示数据并将其格式化为HTML。框架图如下:(更多细节请看我们GoogleI/Otalkaboutbuildingalarge-scalecodesearchengineinGo的recap。)测试v0当我们刚开始构建Sourcegraph时,我们从最简单的开始run方式编写测试。每个测试都将转到数据库并向测试API端点发出HTTPGET请求。该测试解析HTTP响应并将其与预期数据进行比较。典型的v0测试如下:funcTestListRepositories(t*testing.T){tests:=[]struct{urlstring;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:=rangetests{func(){req,_:=http.NewRequest("GET",test.url,nil)tx,_:=db.DB.DbMap.Begin()defertx.Rollback()tx.Insert(test.data...)rw:=httptest.NewRecorder()rw.Body=new(bytes.Buffer)s.ServeHTTP(rw,req)vargot[]*Repojson.NewDecoder(rw.Body).Decode(&got)if!reflect.DeepEqual(got,want){t.Errorf("%s:got%v,want%v",test.url,got,test.want)}}()}}以这种方式编写测试一开始很容易,但随着应用程序的发展会变得很痛苦。随着时间的推移,我们添加了新功能。更多功能导致更多测试、更长运行时间、延长我们的开发周期。更多功能还需要更改和添加新的URL路径(现在大约有75个),其中大部分都相当复杂。Sourcegraph的每一层在内部也变得更加复杂,因此我们希望独立于其他层进行测试。我们在测试中遇到了一些问题:1.测试速度很慢,因为它们必须与实际的数据库-插入测试用例交互,发起查询,并回滚每个测试事务。每个测试运行大约100毫秒,随着我们添加更多测试而增加。2.测试很难重构。测试将HTTP路径和查询参数硬编码为字符串,这意味着如果我们想要更改URL路径或一组查询参数,我们必须在测试中手动更新URL。随着URL路由的复杂性和数量的增加,这种痛苦会加剧。3.大量零散脆弱的示例代码。安装确保数据库正常运行并具有正确数据所需的每个测试。此类代码在多种情况下被重用,但差异足以在安装代码中引入错误。我们发现自己花了很多时间调试我们的测试,而不是实际的应用程序代码。4.测试失败难以诊断。随着应用程序变得越来越复杂,测试失败的根源可能难以诊断,因为每个测试都会访问三个应用程序层。我们的测试更像是集成测试而不是单元测试。***,我们提出需要开发一个公开发布的API客户端。我们希望API易于模拟,以便我们API的用户也可以编写可测试的代码。高级测试目标:随着我们的应用程序的发展,我们意识到我们需要能够满足这些高要求的测试:明确的目标:我们需要单独测试应用程序的每一层。全面:我们应用程序的所有三层都经过测试。快速:测试需要运行得非常快,这意味着没有数据库交互。DRY:尽管我们应用程序的每一层都不同,但它们共享许多通用的数据结构。测试需要利用这一点来消除重复的样板代码。易于模仿:API的外部用户也应该能够使用我们的内部测试模式。在我们的API之上构建的项目应该可以轻松编写好的测试。毕竟,我们的网络前端并不是唯一的——它只是另一个API消费者。我们如何重建测试编写好的、可维护的测试和好的、可维护的应用程序代码是密不可分的。重构应用程序代码使我们能够极大地改进我们的测试代码,这是我们改进测试所采取的步骤。1.构建一个GoHTTPAPI客户端简化测试的第一步是用Go为我们的API编写一个高质量的客户端。以前,我们的网站是AngularJS应用程序,但由于我们主要提供静态内容,因此我们决定将前端HTML生成移至服务器。通过这样做,我们的新前端可以使用Go的API客户端与API服务器进行通信。我们的客户端go-sourcegraph是开源的,go-github库对它影响巨大。客户端代码(尤其是获取仓库数据的端点代码)如下:funcNewClient()*Client{c:=&Client{BaseURL:DefaultBaseURL}c.Repositories=&repoService{c}return}typerepoServicestruct{c*Client}func(c*repoService)Get(namestring)(*Repo,error){resp,err:=http.Get(fmt.Sprintf("%s/api/repos/%s",c.BaseURL,name))iferr!=nil{returnnil,err}deferresp.Body.Close()varrepoReporeturn&repo,json.NewDecoder(resp.Body).Decode(&repo)}之前我们的v0API测试结合了大量的URL路径,构造HTTP请求硬编码以一种特别的方式,现在他们可以使用这个API客户端来构造和发起请求。2.统一HTTPAPI客户端和数据仓库的接口接下来,我们统一HTTPAPI和数据仓库的接口。以前我们的APIhttp.Handlers直接发起SQL查询。现在我们的APIhttp.Handlers只需要解析http.Request然后调用我们的数据仓库即可。数据仓库和HTTPAPI客户端实现相同的接口。利用上面的HTTPAPI客户端(*repoService).Get方法,我们现在有(*repoStore).Get:funcNewDatastore(dbhmodl.SqlExecutor)*Datastore{s:=&Datastore{dbh:dbh}s.Repositories=&repoStore{s}返回}typerepoStorestruct{*Datastore}func(s*repoStore)Get(namestring)(*Repo,error){varrepo*Reporeturnrepo,s.db.Select(&repo,"SELECT*FROMrepoWHEREname=$1",name)}统一这些接口将我们的网络应用程序行为的描述放在一个地方,使其更容易理解和推理。我们可以在API客户端和数据仓库中重用相同的数据类型和参数结构。3.在集中URL路径定义之前,我们必须在应用程序的多个层重新定义URL路径。在API客户端中,我们的代码是这样的resp,err:=http.Get(fmt.Sprintf("%s/api/repos/%s",c.BaseURL,name))这种方式很容易出错,因为我们有超过75个路径定义,还有更多是复杂的。集中URL路径定义意味着在独立于API服务器的新包中重构路径。路径定义在路径包中声明。constRepoGetRoute="repo"funcNewAPIRouter()*mux.Router{m:=mux.NewRouter()//定义路由m.Path("/api/repos/{Name:.*}").Name(RepoGetRoute)returnm}whilehttp.Handlers实际上挂载在APIserver包中:funcinit(){m:=NewAPIRouter()//mounthandlersm.Get(RepoGetRoute).HandlerFunc(handleRepoGet)http.Handle("/api/",m)}andhttp.Handlers实际上是挂载在API服务器包Mountin:funcinit(){m:=NewAPIRouter()//mounthandlersm.Get(RepoGetRoute).HandlerFunc(handleRepoGet)http.Handle("/api/",m)}现在我们可以在API客户端中使用path包生成URL而不是硬编码。(*repoService).Get方法现在如下:varapiRouter=NewAPIRouter()func(s*repoService)Get(namestring)(*Repo,error){url,_:=apiRouter.Get(RepoGetRoute).URL("name",name)resp,err:=http.Get(s.baseURL+url.String())iferr!=nil{returnnil,err}deferresp.Body.Close()varrepo[]Reporeturnrepo,json.NewDecoder(resp.Body).Decode(&repo)}4.创建非统一接口的模拟。我们的v0测试同时测试了路由、HTTP处理、SQL生成和数据库查询。故障很难诊断,测试也很慢。现在,我们对每一层进行独立测试,并模拟相邻层的功能。因为应用程序的每一层都实现相同的接口,所以我们可以在所有三层中使用相同的模拟接口。模仿实现是一个简单的模拟函数结构,可以在每次测试中指定:{ifs.Get_==nil{returnnil,nil}returns.Get_(name)}funcNewMockClient()*Client{return&Client{&MockRepoService{}}}下面是测试中的使用。我们模仿数据仓库的RepoService,使用HTTPAPI客户端来测试APIhttp.Handler。(此代码使用上述所有方法。)funcTestRepoGet(t*testing.T){setup()deferteardown()varfetchedRepoboolmockDatastore.Repo.(*MockRepoService).Get_=func(namestring)(*Repo,error){ifname!="foo"{t.Errorf("wantGet%q,got%q","foo",repo.URI)}fetchedRepo=truereturn&Repo{name},nil}repo,err:=mockAPIClient.Repositories.Get("foo")iferr!=nil{t.Fatal(err)}if!fetchedRepo{t.Errorf("!fetchedRepo")}}高级测试目标回顾使用上述模式,我们实现了测试目标。我们的代码是:明确目标:一次测试一层。全面:所有三个应用层都经过测试。快速:测试运行迅速。DRY:我们合并了三个应用层的公共接口,在应用代码和测试中复用。易于模拟:模拟实现可用于所有三个应用程序层,以及想要测试基于Sourcegraph构建的库的外部API用户。关于如何重组和改进Sourcegraph测试的故事到此结束。这些模式和示例在我们的环境中运行良好,我们希望这些模式和示例对Go社区中的其他人有所帮助,显然它们并非在所有情况下都是正确的,我们确信存在改进空间。我们一直在努力改进我们做事的方式,所以我们很乐意听取您的建议和反馈-告诉我们您在Go中编写测试的经验!本文来自:http://www.oschina.net/translate/building-a-testable-webapp
