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

jopen 10年前發布 | 24K 次閱讀 Go語言 Google Go/Golang開發

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

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

框架

和其他web app一樣,我們的網站有三層:

  • web前端用以服務HTML;

    </li>

  • HTTP API用以返回JSON; 

    </li>

  • 數據存儲,運行對數據庫的SQL查詢,返回Go結構體或切片。

    </li> </ul>

    當一個用戶請求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.)

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

    測試 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的每一層。

      </li>

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

      </li>

    • 快速: 測試需要運行的非常快,意味著不再進行數據庫互動。

      </li>

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

      </li>

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

      </li> </ul>

      我們如何重建測試

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

      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) }</pre>

      以前,我們的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) }</pre>

      統一這些接口把我們的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) }</pre>

       而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) }</pre>

      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{}} }</pre>

      下面是測試中的使用。我們模仿了數據倉庫的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") } }</pre>

      高級測試目標回顧

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

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

        </li>

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

        </li>

      • 快速: 測試運行得很快。

        </li>

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

        </li>

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

        </li> </ul>

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

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!