構建一個可測試的 Go Web 應用程式

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

幾乎每一個程式員都贊同測試是重要的,但測試以多種方式讓寫測試的人員打退堂鼓。它們可能運行慢,可能使用重複的代碼,可能一次測試得太多導致難以定位測試失敗的根源。

這篇文章中,我們將討論如何設計 Sourcegraph的單元測試,使其簡單易寫,容易維護,運行快速並可以被其他人使用。我們希望這裡提到的一些模式有助於其他寫Go web app的人,同時歡迎對於我們測試方法的建議。在開始測試之前,先來看看我們的架構概覽。 

鑫鑫鑫
翻譯於 4 天 前

0人頂

頂 翻譯的不錯哦!

架構

和其他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.)

鑫鑫鑫
翻譯於 4 天 前

0人頂

頂 翻譯的不錯哦!

測試 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的每一層內部也變得更加複雜,所以我們想獨立於其他層做測試。

鑫鑫鑫
翻譯於 4 天 前

0人頂

頂 翻譯的不錯哦!

我們在測試當中遇到了一些問題:

1.測試慢,因為他們要和實際的資料庫互動——插入測試案例,發起查詢,復原每一次測試事務。每一次測試大約運行100毫秒,隨著我們添加更多的測試累加。

2.測試難以重構。測試用字串寫死了HTTP路徑和查詢的參數,這意味著如果我們想改變一個URL路徑或者查詢參數集,不得不手動更新測試中的URL。這種痛會隨著我們的URL路由複雜度和數量的增長而加劇。

3.有大量的散亂脆弱的樣本代碼。安裝每一個測試要求確保資料庫運行正常並擁有正確的資料。這樣的代碼在多個案例中重複使用,但是差異的足以在安裝代碼中引入bug。我們發現自己花大量的時間調試我們的測試而非實際的app代碼。

4.測試失敗難以診斷。隨著app變得更加複雜,因為每一個測試都訪問三個應用程式層,測試失敗的根源難以診斷。我們的測試比起單元測試更像是整合測試。

最後,我們提出了開發一個公開發行的API用戶端的需求。我們想讓API容易被模仿,以便於我們的API使用者也可以寫出好測的代碼。

鑫鑫鑫
翻譯於 4 天 前

0人頂

頂 翻譯的不錯哦!

進階測試目標:

隨著我們的app演化,我們意識到需要能滿足這些高要求的測試:

  • 目標明確:我們需要單獨測試app的每一層。

  • 全面: 我們app的全部三層都要被測試到。

  • 快速: 測試需要啟動並執行非常快,意味著不再進行資料庫互動。

  • DRY: 儘管我們的app每一層都不同,它們共用了許多通用的資料結構。測試需要利用這一點去消除重複的樣本代碼。

  • 易模仿: API外部使用者應當也可以使用我們的自我裝載模式。以我們的API為基礎構建的工程,應當可以容易地寫出良好的測試。 畢竟,我們的web前端不是獨特的——它只是另一個API使用者。

我們如何重建測試

寫良好的、可維護的測試和良好的、可維護的應用代碼是密不可分的。重構應用代碼使我們可以極大地改進我們的測試代碼,這是我們改進測試的步驟。

鑫鑫鑫
翻譯於 4 天 前

0人頂

頂 翻譯的不錯哦!

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用戶端構建和發起請求了。

鑫鑫鑫
翻譯於 3 天 前

0人頂

頂 翻譯的不錯哦!

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 天 前

0人頂

頂 翻譯的不錯哦!

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)}
鑫鑫鑫
翻譯於 3 天 前

0人頂

頂 翻譯的不錯哦!

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") }}
鑫鑫鑫
翻譯於 3 天 前

0人頂

頂 翻譯的不錯哦!

進階測試目標回顧

使用上述模式,我們實現了測試目標。我們的代碼是:

  • 目標明確: 一次測試一層。

  • 全面: 三個應用程式層均被測試。

  • 快速: 測試回合得很快。

  • DRY: 我們合并了三個應用程式層的通用介面, 在應用代碼和測試中進行了重用。

  • 易模仿: 一個仿製實現在三個應用程式層中都可以使用,想測試以Sourcegraph為基礎構建的庫的外部API使用者也可以使用。

關於如何重新構建並改進Sourcegraph的測試的故事就講完了。這些模式和例子在我們的環境中運行良好,我們希望這些模式和例子也能協助到Go社區的其他人,顯而易見的是它們並不是在每一個情境下都是正確的,我們確信還有改進的空間。我們在不斷的嘗試改進做事的方法,所以我們樂意聽到你的建議和反饋——說說你用Go寫測試的經曆吧!

鑫鑫鑫
翻譯於 3 天 前

0人頂

頂 翻譯的不錯哦!

本文中的所有譯文僅用於學習和交流目的,轉載請務必註明文章譯者、出處、和本文連結
我們的翻譯工作遵照 CC 協議,如果我們的工作有侵犯到您的權益,請及時聯絡我們
相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.