いろいろ備忘録日記

主に .NET とか Go とか Flutter とか Python絡みのメモを公開しています。

Goメモ-117 (外部テストパッケージについて)(External Test Package)

概要

ずっと謎だったことがやっと理解できたので、忘れないうちにメモメモ。

Goの「外部テストパッケージ」という仕様についての話です。

知ってらっしゃる方も多いのですかね・・・自分は知りませんでした・・。

すみません、事の経緯を忘れないように書いているので、ちょっと長いです m( )m

Example作るときに謎だったこと

Goではテストファイルに関数名がExampleで始まる関数を定義すると、godoc とかで見たときに特定の関数や型に対しての例という形で表示することが出来る仕様があります。

golang.org

上記のような感じのやつですね。Exampleってところをクリックすると使い方の例が表示できたりします。

んで、かつ、このExample関数もテストの一部になっています。

例えば、以下のような処理があったとして

package hello

import "fmt"

// Say makes the greeting message.
//
// if parameter is empty, return empty.
func Say(message string) string {
    if message == "" {
        return ""
    }

    return makeMessage(message)
}

func makeMessage(message string) string {
    return fmt.Sprintf("hello %s", message)
}

この型に対してのテストファイル (hello_test.go)で

package hello

import (
    "fmt"
)

func ExampleSay() {
    fmt.Println(Say("golang"))

    // Output:
    // hello golang
}

って書くという感じ。関数の下にある

   // Output:
    // hello golang

って部分が特殊で、ここに出力される値をこのようにして書いておくと、テスト時に検証されます。

これを go test で実行すると

$ go test -v ./pkg/hello/ -run ^ExampleSay$
=== RUN   ExampleSay
--- PASS: ExampleSay (0.00s)
PASS

普通のテストのように処理されます。標準出力に出力されたりする処理の確認とかにも便利。

でも、以下の点がずっと謎でした。

例の中で使っている関数の呼び出し方が、使用例として考えると変じゃない??

上のExample関数の以下の部分です。

   fmt.Println(Say("golang"))

hello.Say じゃなくて、Say になってしまっています。

それは当たり前で、このExample関数が書かれているテストファイルのパッケージはhello.Say関数と同じパッケージ

つまり、helloパッケージの中にいます。同じパッケージの中にいるから、当然 hello.Say って書けない。

でも、これ使用例ですから、使う人は当然このパッケージをimportして利用します。

なので、使い方としては、hello.Say で書かないと変になります。でも、Goでは一つのディレクトリの中には一つのパッケージしか存在することが出来ません。

んで、さっき上でリンクを貼り付けた

golang.org

のExampleを見ると、fmt.Errorf ってちゃんとなってる。。なんで!?って思っていました。

外部テストパッケージという仕組み

書籍「プログラミング言語Go」の第11章 テストの中に、「外部テストパッケージ」という記載が出てきました。

なんと、Goでは「外部テストパッケージ」という仕組みを使うと、以下のようなことが出来るとのこと。

  • 外部テストパッケージの仕組みを使うと、一つのディレクトリの中に xxx_test (xxxはパッケージ名) というパッケージを特別に存在させることが出来る
  • この xxx_test パッケージ(外部テストパッケージ)は、別のパッケージ名になるけれども、xxx パッケージのテストとして扱われる。つまり、go test したときに普通にテスト対象に含まれる。
  • xxxとxxx_testパッケージは別のパッケージ扱いになるので、xxxの中の関数の呼び出しとかは当然 xxx.関数 って形で書かないといけない。
  • xxx_test パッケージは、外部テストパッケージとしてのみ利用できるので、通常のインポートパスでは使えない。
  • 外部テストパッケージを使うと、通常のパッケージテストだと循環参照してしまいテストが書けないものもテストが出来る(別のパッケージだから)

この部分読んだときにやっとfmtパッケージのExampleがあのような形で書けている理由がやっとわかりましたw

外部テストパッケージを使っていたんですね。んで、実際のソースコードをよく見てみると

golang.org

案の定、外部テストパッケージになっていました。すっきり。

先のExample関数を外部テストパッケージにする

てことで、先のExampleテストを外部テストパッケージにすると

package hello_test

import (
    "fmt"

    "go-external-test-package-example/pkg/hello"
)

func ExampleSay() {
    fmt.Println(hello.Say("golang"))

    // Output:
    // hello golang
}

って書くことが出来るようになりました。これでユーザが使う際に適切な使用例になりました。

外部テストパッケージは、文字通り「そのパッケージを外部からテストする場合のテストパッケージ」って意味ですね。

外部から利用する場合のテストなので、当然非公開なスコープにはアクセスできない状態となります。

でも、それはそれで不便なときがあって、、、テストする時って、いつも綺麗に正しく外からアクセスしてテストできるものばかりじゃない時が往々にしてあります。テストするときだけ、内部の情報が欲しかったり、すこし動作を別のものにすり替えたりしてテストしたりすることも多いです。

同じパッケージに属しているテストの場合は、非公開な定義にもそのままアクセス可能ですが、外部テストパッケージはパッケージとして別なので、もちろん非公開なスコープになっているものにはアクセスできません。

テストファイルを2つに分けて、片方は同じパッケージ、片方は外部テストパッケージにしてもいいと思いますが、ちょっとめんどい。。。

export_test.go というトリック

書籍「プログラミング言語Go」の第11章を読みすすめると、その点についても記載があります。

上記のような、外部テストパッケージからテスト対象のパッケージの非公開な情報にテスト時のみアクセスしたい場合は以下のようなトリックを使うと良いとのこと。

  • 外部テストパッケージじゃなくて、正規のパッケージで ファイルを作る。慣習的にこのファイルの名前は export_test.goにすることが多い。
  • この export_test.go は、テスト対象と同じパッケージに属しているので当然非公開なフィールドなどにアクセスできる
  • export_test.go にはテスト関数を書かずに、外部テストパッケージと連携するための情報のみを記載して、橋渡しするようにする。つまり「裏口」を作る役割。
  • 外部テストパッケージが実行される際、テスト対象パッケージをimportするので、そのときに export_test.goの内容もインポートされる。なので、そこから外部テストパッケージ経由でアクセスできるようになる。
  • export_test.go はテストファイルなので、当然通常のビルド時及び実行時には含まれることがない。テスト時のみ有効となるファイル。なので、この「裏口」はテスト時のみ有効となる。

ということみたいです。なるほどーって思いました。

んで、何回も貼り付けていますが、fmtパッケージのソースツリーみると

golang.org

ちゃんと、export_test.go がいます。他の標準パッケージにも export_test.go は結構存在しています。

fmtパッケージの export_test.go は以下のようになっていました。

package fmt

var IsSpace = isSpace
var Parsenum = parsenum

上記の通り、テスト関数は存在しなくて、fmtパッケージ内部の非公開関数 isSpaceparsenum を外部テストパッケージ側で呼び出せるように橋渡ししていますね。

これで更にすっきりw

サンプル

ということで、メモ代わりのサンプルです。

テスト対象として以下のような処理があったとします。

package hello2

import "fmt"

// Say makes the greeting message.
//
// if parameter is empty, return empty.
func Say(message string) string {
    if message == "" {
        return ""
    }

    return makeMessage(message)
}

func makeMessage(message string) string {
    return fmt.Sprintf("hello %s", message)
}

で、Exampleとかも書きたいので、この処理のテストファイルは外部テストパッケージとします。

さらに、内部関数のmakeMessageもテストしたいので、以下のように export_test.go を定義。

package hello2

// MakeMessage は、本来は 非公開スコープ として定義している hello2.makeMessage 関数を
// テストするために、用意しているフィールドである。
//
// 外部テストパッケージ (external test package) を利用したテストの場合
// 処理本体が存在しているパッケージとは異なるパッケージにテストを定義するため
// スコープがpublicではない関数などが見えなくなってしまう。
//
// 外部利用される観点からのテストではそれで良いのであるが、時にはテストのために
// 非公開となっている内部の情報を操作してテストすることも必要となる場合がある。
//
// そのような場合、Goではテスト時のみ発動する裏口(Backdoor)を用意してテストを実行するやり方がある。
// 以下のトリックとなる。
//
//   - 非公開となっている状態や操作をテスト時のみ利用できるようにする関数や変数を定義する
//     - 慣習として、このファイルは export_test.go と命名される
//   - export_test.go が所属するパッケージは xxx_test パッケージではなく通常の xxx パッケージとする
//     - これにより、この export_test.go 内では非公開の定義にアクセスすることができる
//   - export_test.go にはテストメソッドを作らない。あくまで裏口となる変数や関数を定義するのみ。
// 
// go test 時、元のパッケージに所属しているテストと外部テストパッケージに所属しているテストがコンパイル対象となるため
// テスト時のみ export_test.go に定義した裏口が有効となるというトリック。
//
// このトリックは、Go本体の標準ライブラリ内でもあちこちで利用されている。
// 例: https://golang.org/src/fmt/
// 例: https://golang.org/src/strings/
//
// 上記の例のどちらも、本来のパッケージの所属しているテストは export_test.go だけとなっている。
// それ以外のテストは、全て外部テストパッケージとなっている。
var MakeMessage = makeMessage

で、最後に外部テストパッケージのテストを用意します。

package hello2_test

import (
    "fmt"
    "testing"

    "github.com/devlights/go-external-test-package-example/pkg/hello2"
)

func ExampleSay() {
    fmt.Println(hello2.Say("golang"))

    // Output:
    // hello golang
}

func TestSay(t *testing.T) {
    cases := []struct {
        name string
        in   string
        out  string
    }{
        {"not empty", "golang", "hello golang"},
        {"empty", "", ""},
    }

    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            ans := hello2.Say(c.in)
            if c.out != ans {
                t.Errorf("[want] %s\t[got] %s", c.out, ans)
            }
        })
    }
}

func TestMakeMessage(t *testing.T) {
    cases := []struct {
        name string
        in   string
        out  string
    }{
        {"not empty", "golang", "hello golang"},
        {"empty", "", "hello "},
    }

    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            ans := hello2.MakeMessage(c.in)
            if c.out != ans {
                t.Errorf("[want] %s\t[got] %s", c.out, ans)
            }
        })
    }
}

テスト実行してみます。

$ go test -v -cover ./pkg/hello2/
=== RUN   TestSay
=== RUN   TestSay/not_empty
=== RUN   TestSay/empty
--- PASS: TestSay (0.00s)
    --- PASS: TestSay/not_empty (0.00s)
    --- PASS: TestSay/empty (0.00s)
=== RUN   TestMakeMessage
=== RUN   TestMakeMessage/not_empty
=== RUN   TestMakeMessage/empty
--- PASS: TestMakeMessage (0.00s)
    --- PASS: TestMakeMessage/not_empty (0.00s)
    --- PASS: TestMakeMessage/empty (0.00s)
=== RUN   ExampleSay
--- PASS: ExampleSay (0.00s)
PASS
coverage: 100.0% of statements
ok      github.com/devlights/go-external-test-package-example/pkg/hello2        0.005s  coverage: 100.0% of statements

ちゃんと外部テストパッケージが実行されていますね。

今回のサンプルリポジトリ

今回のサンプル、勿体ないのでGithub にアップしてあります。よろしければご参照ください。

github.com

参考情報

おすすめ書籍

自分が読んだGo関連の本で、いい本って感じたものです。

Go言語による並行処理

Go言語による並行処理

スターティングGo言語 (CodeZine BOOKS)

スターティングGo言語 (CodeZine BOOKS)

  • 作者:松尾 愛賀
  • 発売日: 2016/04/15
  • メディア: 単行本(ソフトカバー)

プログラミング言語Go (ADDISON-WESLEY PROFESSIONAL COMPUTING SERIES)

プログラミング言語Go (ADDISON-WESLEY PROFESSIONAL COMPUTING SERIES)


過去の記事については、以下のページからご参照下さい。

  • いろいろ備忘録日記まとめ

devlights.github.io

サンプルコードは、以下の場所で公開しています。

  • いろいろ備忘録日記サンプルソース置き場

github.com

github.com

github.com