溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點擊 登錄注冊 即表示同意《億速云用戶服務(wù)條款》

如何構(gòu)建一個可測試的Go Web應(yīng)用

發(fā)布時間:2021-11-17 13:37:24 來源:億速云 閱讀:141 作者:柒染 欄目:web開發(fā)

如何構(gòu)建一個可測試的Go Web應(yīng)用,針對這個問題,這篇文章詳細(xì)介紹了相對應(yīng)的分析和解答,希望可以幫助更多想解決這個問題的小伙伴找到更簡單易行的方法。

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

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

框架

和其他web app一樣,我們的網(wǎng)站有三層:

  • web前端用以服務(wù)HTML;

  • HTTP API用以返回JSON;

  • 數(shù)據(jù)存儲,運行對數(shù)據(jù)庫的SQL查詢,返回Go結(jié)構(gòu)體或切片。

當(dāng)一個用戶請求Sourcegraph的頁面,前端收到HTTP頁面請求,并對API服務(wù)器發(fā)起一系列HTTP請求。 然后API服務(wù)器開始查詢數(shù)據(jù)存儲, 數(shù)據(jù)存儲將數(shù)據(jù)返回給API服務(wù)器,然后編碼成 JSON格式,返回給web前端服務(wù)器,前端使用Go html/template包將數(shù)據(jù)顯示并格式化成HTML。

框架圖如下:(更多細(xì)節(jié),查看 recap of our Google I/O talk about building a large-scale code search engine in Go.)

如何構(gòu)建一個可測試的Go Web應(yīng)用

測試 v0

當(dāng)我們***次開始構(gòu)建Sourcegraph,我們以最容易跑起來的方式寫了測試。每一個測試都將進(jìn)入數(shù)據(jù)庫對測試API端點發(fā)起HTTP GET請求。測試會解析HTTP返回內(nèi)容并和預(yù)期數(shù)據(jù)進(jìn)行對比。一個典型的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進(jìn)化會變得痛苦。 隨著時間推移,我們加入了新特性。更多的特性導(dǎo)致更多的測試,更長的運行時間,延長了我們的dev周期。更多的特性也需要改變和添加新的URL路徑(現(xiàn)在大概有75個),大都相當(dāng)復(fù)雜。 Sourcegraph的每一層內(nèi)部也變得更加復(fù)雜,所以我們想獨立于其他層做測試。

我們在測試當(dāng)中遇到了一些問題:

1.測試慢,因為他們要和實際的數(shù)據(jù)庫互動——插入測試用例,發(fā)起查詢,回滾每一次測試事務(wù)。每一次測試大約運行100毫秒,隨著我們添加更多的測試?yán)奂印?/p>

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

3.有大量的散亂脆弱的樣本代碼。安裝每一個測試要求確保數(shù)據(jù)庫運行正常并擁有正確的數(shù)據(jù)。這樣的代碼在多個案例中重復(fù)使用,但是差異的足以在安裝代碼中引入bug。我們發(fā)現(xiàn)自己花大量的時間調(diào)試我們的測試而非實際的app代碼。

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

***,我們提出了開發(fā)一個公開發(fā)行的API客戶端的需求。我們想讓API容易被模仿,以便于我們的API用戶也可以寫出好測的代碼。

高級測試目標(biāo):

隨著我們的app演進(jìn),我們意識到需要能滿足這些高要求的測試:

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

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

  • 快速: 測試需要運行的非常快,意味著不再進(jìn)行數(shù)據(jù)庫互動。

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

  • 易模仿: API外部用戶應(yīng)當(dāng)也可以使用我們的內(nèi)部測試模式。以我們的API為基礎(chǔ)構(gòu)建的工程,應(yīng)當(dāng)可以容易地寫出良好的測試。 畢竟,我們的web前端不是獨特的——它只是另一個API用戶。

我們?nèi)绾沃亟y試

寫良好的、可維護(hù)的測試和良好的、可維護(hù)的應(yīng)用代碼是密不可分的。重構(gòu)應(yīng)用代碼使我們可以極大地改進(jìn)我們的測試代碼,這是我們改進(jìn)測試的步驟。

1. 構(gòu)建一個Go HTTP API 客戶端

簡化測試的***步是用Go為我們的API寫一個高質(zhì)量的客戶端。之前,我們的網(wǎng)站是AngularJS app,但是因為我們主要服務(wù)靜態(tài)內(nèi)容,我們決定將前端HTML生成移動到服務(wù)器。這么做以后,我們的新前端就可以使用Go的API客戶端和API服務(wù)器通信。我們的客戶端go-sourcegraph是開源的,go-github庫對它的影響巨大??蛻舳舜a(特別是獲取倉庫數(shù)據(jù)(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路徑和構(gòu)建好的HTTP請求用ad-hoc的方式寫死,現(xiàn)在它們可以使用這個API客戶端構(gòu)建和發(fā)起請求了。

2. 統(tǒng)一HTTP API客戶端和數(shù)據(jù)倉庫的接口

接下來,我們統(tǒng)一HTTP API和數(shù)據(jù)倉庫的接口。以前我們的API http.Handlers直接發(fā)起SQL查詢?,F(xiàn)在我們的API http.Handlers只需要解析http.Request再調(diào)用我們的數(shù)據(jù)倉庫,數(shù)據(jù)倉庫和HTTP API客戶端實現(xiàn)了一樣的接口。

借鑒上面的HTTP API客戶端(*repoService).Get的方法,我們現(xiàn)在也有了(*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)  }

統(tǒng)一這些接口把我們的web app的行為描述放在一個地方,使得它更易理解和推理。而且我們可以在API客戶端和數(shù)據(jù)倉庫中重用相同的數(shù)據(jù)類型和參數(shù)結(jié)構(gòu)。

3. 集中URL路徑定義

之前,我們不得不在應(yīng)用的多個層重新定義URL路徑。在API客戶端中,我們的代碼是這樣的

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

這種方式很容易引發(fā)錯誤,因為我們有超過75個路徑定義,還有很多是復(fù)雜的。集中URL路徑定義意味著從API服務(wù)器獨立出來在一個新包中重構(gòu)路徑。路徑包中聲明了路徑的定義。

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服務(wù)器包中掛載:

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

現(xiàn)在我們可以在API客戶端中使用路徑包生成URL,而不是把它們寫死。(*repoService).Get方法現(xiàn)在如下:

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. 創(chuàng)建未統(tǒng)一接口的仿制

我們的v0測試同時測試了路徑、HTTP處理、SQL生成和DB查詢。失敗難以診斷,測試也很慢。

現(xiàn)在,我們擁有每一層的獨立測試并且我們模仿了毗鄰層的功能。因為應(yīng)用的每一層實現(xiàn)了相同的接口,所以我們可以在所有的三層中使用同樣的仿制接口。

仿制的實現(xiàn)是簡單的模擬函數(shù)結(jié)構(gòu),可以在每一個測試中指明:

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

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

高級測試目標(biāo)回顧

使用上述模式,我們實現(xiàn)了測試目標(biāo)。我們的代碼是:

  • 目標(biāo)明確: 一次測試一層。

  • 全面: 三個應(yīng)用層均被測試。

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

  • DRY: 我們合并了三個應(yīng)用層的通用接口, 在應(yīng)用代碼和測試中進(jìn)行了重用。

  • 易模仿: 一個仿制實現(xiàn)在三個應(yīng)用層中都可以使用,想測試以Sourcegraph為基礎(chǔ)構(gòu)建的庫的外部API用戶也可以使用。

關(guān)于如何重新構(gòu)建并改進(jìn)Sourcegraph的測試的故事就講完了。這些模式和例子在我們的環(huán)境中運行良好,我們希望這些模式和例子也能幫助到Go社區(qū)的其他人,顯而易見的是它們并不是在每一個場景下都是正確的,我們確信還有改進(jìn)的空間。

關(guān)于如何構(gòu)建一個可測試的Go Web應(yīng)用問題的解答就分享到這里了,希望以上內(nèi)容可以對大家有一定的幫助,如果你還有很多疑惑沒有解開,可以關(guān)注億速云行業(yè)資訊頻道了解更多相關(guān)知識。

向AI問一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進(jìn)行舉報,并提供相關(guān)證據(jù),一經(jīng)查實,將立刻刪除涉嫌侵權(quán)內(nèi)容。

AI