Help us understand the problem. What is going on with this article?

Goをテストする時に使えるTips

Goのテストを楽に書くためのTipsです。
読みやすさもアップするのでウチのGoプロジェクトでは頻繁に使っています。

1. テスト対象は外部パッケージとして扱う

テストコードがそのまま簡易ドキュメントになったら素敵ですよね。
外部パッケージとしてテストを書くことで実現できます。

例えばutil.goにこんな実装があったとします。

package util

import (
    "fmt"
)

const prefix = "Hello"

// 名前を受け取って挨拶を返却。
func Hello(name string) string {
    return fmt.Sprintf("%s %s", prefix, name)
}

これをテストするには同じディレクトリにutil_test.goというファイルを作ると思います。
通常はこうです。

package util

import (
    "testing"
)

func TestHello(t *testing.T) {
    exp := "Hello Yamada-san"

        // ここで関数をコール
    got := Hello("Yamada-san")

    if exp != got {
        t.Errorf("expected %s, got %s", exp, got)
    }
}

これは一見すると何の問題も無いんですが、このパッケージを外部から利用する時にHello("Yamada-san")をコピペすると動きません。util.Hello("Yamada-san")ならOKです。エクスポートしている関数が僅かなら大した手間では無いのですが数が増えるのに比例して手間も増えます。

ということで、パッケージの末尾に_testを付けてみましょう。
util_testになりますね、外部パッケージとしてテストが書けます。

package util_test // コレで外部パッケージになる

import (
    "testing"
    "github.com/tomodian/whatever/util"
)

func TestHello(t *testing.T) {
    exp := "Hello Yamada-san"
    got := util.Hello("Yamada-san") // 外部パッケージとしてコール

    if exp != got {
        t.Errorf("expected %s, got %s", exp, got)
    }
}

コピペ性能がグッと高まりましたね!
しかし、テストコードが外部パッケージになったので、エクスポートされていない実装が見えなくなります。

こういう場合は内部パッケージのテストだということを明示してutil_internal_test.goというファイルを作ってみましょう。

package util // 同一の名前空間

import (
    "testing"
)

func TestInternalPrefix(t *testing.T) {
    // 内部の定数をチェック
    if prefix != "Hello" {
        t.Errorf("wrong prefix")
    }
}

ファイル構成はこんな感じになりました。

  • util.go → 実体
  • util_test.go → 外部パッケージとしてテスト
  • util_internal_test.go → 内部テスト

2. パターンテストは配列で

これはGoに限らず、どんなプログラミング言語にも使えるテクニックです。
関数に色々な値を渡して出力結果をチェックしたいときは、パターンを配列にすると楽です。

例えばstringutilというパッケージがあり、文字列に含まれる数値を拾ってintを返却する関数を作る場合はこんな実装になると思います。

package stringutil

import (
    "regexp"
    "strconv"
)

// 文字列から数値を抜き出してint型で返却。小数点は考慮しない。
// エラーの場合はゼロ (Default Value)にフォールバック。
func Numeric(in string) int {
    re := regexp.MustCompile(`[^0-9]+`)

    got, err := strconv.Atoi(re.ReplaceAllString(in, ""))

    if err != nil {
        return 0
    }

    return got
}

んで、これに色々な値を渡すときに配列を使います。

package stringutil_test

import (
    "testing"

    "api/app/utils/stringutil"
)

func TestNumeric(t *testing.T) {

    patterns := []struct {
        exp   int     // 期待値 (expectation)
        given string  // 入力値 (given input)
    }{
        // パターンを把握して追加・削除を行うのが楽になる
        {10, "010"},
        {100, "100"},
        {100, "Hello100"},
        {100, "Hello100🍎"},
        {0, "Hello"},
    }

    for idx, p := range patterns {
        got := stringutil.Numeric(p.given)

        if p.exp != got {
            t.Errorf("Case(%d) expected %d, got %d", idx, p.exp, got)
        }
    }
}

3. Success / Failケースをスッキリと書く

エラーを返却して欲しいようなテストケースを書くとき。
例えばさっきの関数は問題が起きたらゼロにフォールバックするようになっていましたが、これだと本当にゼロなのかエラーが握り潰されたのかが分からないので素直にエラーを返却するようにリファクタリングしてみます。

package stringutil

import (
    "regexp"
    "strconv"
)

func Numeric(in string) (int, error) { // 返却内容が増えた
    re := regexp.MustCompile(`[^0-9]+`)

    got, err := strconv.Atoi(re.ReplaceAllString(in, ""))

    if err != nil {
        return 0, err // エラーが起きたら素直に返却
    }

    return got, nil
}

さて、こうなるとSuccessケースとFailケースを考慮したテストが要りますね。
先ほどのTestNumericTestNumericSucceedsTestNumericFailsに分割してみましょうか。

package stringutil_test

import (
    "testing"

    "api/app/utils/stringutil"
)

func TestNumericSucceeds(t *testing.T) {

    patterns := []struct {
        exp   int     // 期待値 (expectation)
        given string  // 入力値 (given input)
    }{
        {100, "Hello100🍎"},
    }

    for idx, p := range patterns {
        got, err := stringutil.Numeric(p.given)

        if err != nil {
            t.Errorf("Case(%d) failed, error %s", idx, err)
            continue
        }

        if p.exp != got {
            t.Errorf("Case(%d) expected %d, got %d", idx, p.exp, got)
        }
    }
}

func TestNumericFails(t *testing.T) {

    patterns := []struct {
        given string // 入力値 (given input)
    }{
        {"エラーを期待💣"},
    }

    for idx, p := range patterns {
        got, err := stringutil.Numeric(p.given)

        if err == nil || got != 0 {
            t.Errorf("Case(%d) should fail", idx)
        }
    }
}

うーむ、テストコードが冗長になってしまいました。

まずテスト対象がstringutil.Numericなのに*Succeeds*Failsという2つのテストケースに分割されています。一見問題ないように見えるんですが、patterns構造体が2つあるので、テスト対象が増えるとメンテも増えそうです。

そもそもstringutil.Numericをテストしたいだけなのにpatterns構造体を考慮すること自体が無駄な気がします。

こんな時はケースごとに無名関数で括るとスッキリ書けます。

package stringutil_test

import (
    "testing"

    "api/app/utils/stringutil"
)

func TestNumeric(t *testing.T) { // Numeric関数のテストはここに全て詰め込む

    // 構造体は1つ
    type pattern struct {
        exp   int     // 期待値 (expectation)
        given string  // 入力値 (given input)
    }

    // Successケース
    func() {
        patterns := []pattern{
            {10, "010"},
            {100, "100"},
            {100, "Hello100"},
            {100, "Hello100🍎"},
        }

        for idx, p := range patterns {
            got, err := stringutil.Numeric(p.given)

            if err != nil {
                t.Errorf("Case(%d) failed, error %s", idx, err)
                continue
            }

            if p.exp != got {
                t.Errorf("Case(%d) expected %d, got %d", idx, p.exp, got)
            }
        }
    }()

    // Failケース
    func() {
        patterns := []pattern{
            {0, "エラーを期待"},
            {0, "これもNG💀"},
        }

        for idx, p := range patterns {
            // Failケースはerrorの返却有無だけをチェックすればOK
            if _, err := stringutil.Numeric(p.given); err == nil {
                t.Errorf("Case(%d) must fail")
            }
        }
    }()
}

だいぶ楽になりましたね! 1つのpattern構造体でどちらのケースもカバーし、無名関数で括って変数のスコープを閉じたことでほぼ同じコードでテストが書けるようになりました。

4. テストに絵文字を入れる

上記のテストには🍎💀が入ってますが、テストコードに日頃から絵文字を入れておくと思わぬバグを検出することができます。
絵文字はUTF-8で28バイトなので、文字数を厳密にカウントしなければならないとき、例えばRDBのカラム長が絡むときはテスト時にエラーを検出できます。

まとめと宣伝

Goは素晴らしい言語ですが、コードが冗長になりがちなのでなるべくリファクタリング工数が増えないように工夫しています。
今月出たばかりの弊社のSaaS「チームで使える在庫管理アプリ Tana」のサーバサイドは全てGoで動いていて、テストコードには今回ご紹介したテクニックが頻繁に使われています。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away