Go testing 裏的巧妙設計

網絡上寫testing的文章浩如雲煙,有初學者Hello World級別的玩具,也有大佬乾貨滿滿的實踐。

不跟那個風(其實是寫得太水了有失逼格,寫得太高深了力有不逮,團隊沒有測試的傳統)。

換個角度來學習一下testing領域巧妙的設計,開闊眼界是爲了舉一反三,所謂技多不壓身。

按照慣例,從一個問題開始:

問題

testingunsafe不一樣:unsafe所有的語義和實現都由編譯器負責解釋,本身只是作爲佔位符提供文檔;testing雖然是用來測試其它package的,但是它本身並沒有在編譯器那裏得到任何優待,只是普通的一個package,它自己也需要依賴其它package也需要測試。

testing支持的場景越複雜,引入的依賴就越多。譬如:bytes flag fmt io os reflect runtime sync等等還有很多。

那麼問題來了:以bytes爲例,既然testing依賴於bytes,那麼bytes在測試時依賴testing不就import cycle了嗎?

這個問題難度較小。testing裏有如下描述:

If the test file is in the same package, it may refer to unexported identifiers within the package.

If the file is in a separate "_test" package, the package being tested must be imported explicitly and only its exported identifiers may be used. This is known as "black box" testing.

也就是說bytes/xxx_test.go裏面既可以是package bytes也可以是package bytes_test,所謂的白盒測試和黑盒測試。既然前者不能import "testing"那麼全部採用後者不就解決了嗎?

完美!世界從此太平了

嗎?並沒有。因爲bytes_testbytes是有點關係,但所謂親兄弟明算賬,bytes的私有財產譬如indexBytePortablebytes_test是用不了的。

於是新的問題出現了:package bytes無法使用testingpackage bytes_test無法訪問indexBytePortable,那麼bytes的私有元素就不能測試了嗎?

有道是 “有困難要上,沒有困難製造困難也要上”,滄海橫流方顯英雄本色。請看官方團隊帶來的華麗表演,而且至少是 2 種(如有缺失歡迎補充)。

export_test.go

我們還是以bytes爲例。

備註:export_test.go這個名字和go:build ignore裏的ignore一樣都是約定俗成,並沒有強制要求。(蛋是,你不遵守就等着被懟)

  1. 通過export_test.go將私有元素給一個exported的代理

    // export_test.go
    package bytes
        
    // Export func for testing
    var IndexBytePortable = indexBytePortable
  2. package bytes_test使用該代理替代原私有元素

    // bytes_test.go
    package bytes_test
        
    import (
     . "bytes"
     ...
    )
        
    func TestIndexByte(t *testing.T) {
     for _, tt := range indexTests {
      ...
      a := []byte(tt.a)
      b := tt.b[0]
            // IndexByte是公開的,使用沒有限制。
       pos := IndexByte(a, b)
      if pos != tt.i {
       t.Errorf(`IndexByte(%q, '%c') = %v`, tt.a, b, pos)
      }
            // bytes_test中不能直接使用indexBytePortable,自然無法測試indexBytePortable。
            // 爲了達到“不可告人的目的”,把indexBytePortable改寫成了IndexBytePortable。
      posp := IndexBytePortable(a, b)
      if posp != tt.i {
       t.Errorf(`indexBytePortable(%q, '%c') = %v`, tt.a, b, posp)
      }
     }
    }

XTest

這次以context爲例。

bytes稍有不同,testing並不直接依賴context,但是testing/internal/testdeps依賴context導致了間接依賴。

  1. 定義接口

    // context_test.go
    package context
        
    // 參照testing.T一比一設計
    type testingT interface {
     Deadline() (time.Time, bool)
     Error(args ...any)
     Errorf(format string, args ...any)
     Fail()
     FailNow()
     Failed() bool
     Fatal(args ...any)
     Fatalf(format string, args ...any)
     Helper()
     Log(args ...any)
     Logf(format string, args ...any)
     Name() string
     Parallel()
     Skip(args ...any)
     SkipNow()
     Skipf(format string, args ...any)
     Skipped() bool
    }
  2. 使用接口

    // context_test.go
    // 注意這裏不是context_test
    package context
        
    // 注意XTestXxx並不能被go test識別
    func XTestParentFinishesChild(t testingT) {
     ...
     parent, cancel := WithCancel(Background())
     cancelChild, stop := WithCancel(parent)
     defer stop()
     valueChild := WithValue(parent, "key""value")
     timerChild, stop := WithTimeout(valueChild, veryLongDuration)
     defer stop()
     afterStop := AfterFunc(parent, func() {})
     defer afterStop()
        
        // 由於testingT是參照testing.T設計的,因此testing.T怎麼使用,在此也可以怎麼使用
     select {
     case x := <-parent.Done():
      t.Errorf("<-parent.Done() == %v want nothing (it should block)", x)
     case x := <-cancelChild.Done():
      t.Errorf("<-cancelChild.Done() == %v want nothing (it should block)", x)
     case x := <-timerChild.Done():
      t.Errorf("<-timerChild.Done() == %v want nothing (it should block)", x)
     case x := <-valueChild.Done():
      t.Errorf("<-valueChild.Done() == %v want nothing (it should block)", x)
     default:
     }
        
     // 注意這裏用到了cancelCtx和timerCtx,它們都是私有的,只能放在package context文件裏。
     pc := parent.(*cancelCtx)
     cc := cancelChild.(*cancelCtx)
     tc := timerChild.(*timerCtx)
     ...
    }
  3. 測試轉發

    // x_test.go
    // 注意這裏是context_test
    package context_test
        
    import (
     . "context"
     "testing"
    )
        
    // context_test和testing並不構成循環依賴關係,因此可以使用標準的testing簽名
    func TestParentFinishesChild(t *testing.T) {
        // testingT是參照testing.T設計的,因此*testing.T天熱滿足testingT接口。
        // 在此通過接口就避免了循環依賴的問題。
     XTestParentFinishesChild(t) // uses unexported context types
    }

思考

兩種方案都介紹完了,但事情並沒有結束。

封裝

先解釋一個問題:indexBytePortable一番折騰成了IndexBytePortable,那麼indexBytePortable私有不私有還有什麼意義?

也許會有人說:簡單得不值一提嘛,它在_test.go文件裏,編譯的時候根本就忽略了。

怎麼說呢?沒有錯,但離正確也還遠。

既然go build的時候不讓用,那麼在go test foo測試的時候用一用總可以吧?

仍然不可以,因爲bytes/export_test.go只有go test bytes的時候才發光發熱。

(寫法可以有其它種,譬如go test ./bytesgo test .等,本質不變)

// 還是以bytes爲例,此處的p代表package bytes
func TestPackagesAndErrors(ctx context.Context, done func(), opts PackageOpts, p *Package, cover *TestCover) (pmain, ptest, pxtest *Package) {
    ...
    if len(p.TestGoFiles) > 0 || p.Name == "main" || cover != nil && cover.Local {
        /*
        .go文件從大的方向分三種:
         1. 普通編譯使用的文件(譬如bytes.go),細分爲以下幾種:
          + 使用了import "C" ==========> CgoFiles
          + 有語法錯誤的 ================> InvalidGoFiles
          + 因爲os/arch/tag等原因排除的 ==> IgnoredGoFiles
          + 通常意義理解的,其實是GoFiles
         2. 測試文件(譬如example_test.go),又分兩種:
          + 如果和GoFiles屬於同一個package === > TestGoFiles
          + 如果是package xxx_test ===========> XTestGoFiles
         3. 編譯器認爲應該忽略的文件,分爲以下幾種:
          + 以_和.開頭的文件
        */
  ptest = new(Package)
  *ptest = *p
  ptest.Error = ptestErr
  ptest.Incomplete = incomplete
  ptest.ForTest = p.ImportPath
  ptest.GoFiles = nil
  // 普通編譯使用的文件,編譯這些文件不值得大驚小怪。
  ptest.GoFiles = append(ptest.GoFiles, p.GoFiles...)
  // 測試文件中的第一類,也需要參與編譯。
        // 這是IndexBytePortable能夠被測試的保證,但是注意只是在測試場景下。
        // 這裏的p就是要測試的目標,那麼只有go test bytes的時候,bytes/export_test.go才參與編譯。
  ptest.GoFiles = append(ptest.GoFiles, p.TestGoFiles...)
  ptest.Target = ""
        ptest.Imports = str.StringList(p.TestImports, p.Imports)
  ptest.Internal.Imports = append(imports, p.Internal.Imports...)
  ptest.Internal.RawImports = str.StringList(rawTestImports, p.Internal.RawImports)
  ptest.Internal.ForceLibrary = true
  ptest.Internal.BuildInfo = ""
  ptest.Internal.Build = new(build.Package)
  *ptest.Internal.Build = *p.Internal.Build
        ...
    }
}

接口

說起接口的作用,人們首先想起的總是那些八股文。

但是在這裏,官方團隊給我們上了生動的一課:接口在解決循環依賴中的巨大作用。

而且這種手法並不侷限於context或者testing,它比第一種方案更具普適性(在測試這個特定場景下也更繁瑣),完全可以在真實的世界裏發揮巨大作用。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/wveQsvB81MEvG58vgwEgRQ