LoginSignup
35
27

More than 5 years have passed since last update.

Goで不確定要素に依存する場合のテストを書く

Posted at

プログラムを書いていると、不確定要素に依存する関数を書くことは多々あると思います。

不確定要素というのは、以下のようなものです。

  • 乱数
  • 現在時刻
  • 外部I/O (通信・ファイル等)

この記事では、例として乱数に依存する関数を書いてみます。

作ってみるもの

  • 0~9の目がある10面ダイスを使う
  • 10面ダイスを2つ振った結果で攻撃の判定が行われる
  • 攻撃の判定結果は文字列で返す
  • 10面ダイスの目が二つとも0 = 00 の場合はクリティカル
  • 10面ダイスの目が二つとも9 = 99 の場合はファンブル

乱数依存の実装 その1

math/rand パッケージで毎回異なる結果を得るための手軽な方法として、初期Seedにtime.Now().UnixNano() を設定します。

RoleD10 で0~9の目が出る10面ダイスを表現しています。
これはもろに乱数に依存しているため、実行のたびに結果が変わります。

package main

import (
    "fmt"
    "math/rand"
    "time"
)

// RNG random number generater
var RNG *rand.Rand = rand.New(rand.NewSource(time.Now().UnixNano()))

// RoleD10 returns 0-9
func RoleD10() int {
    return RNG.Intn(10)
}

// Attack 攻撃結果は10面ダイスを2つ振ってパーセンテージ判定する
func Attack() (result string) {
    p := RoleD10()*10 + RoleD10()
    fmt.Println("2D10 Result:", p)

    if p == 0 {
        return "CRITICAL!!"
    }

    if p == 99 {
        return "FUMBLE..."
    }

    return "HIT!"
}

func main() {
    fmt.Println("Attack Result:", Attack())
}

その1のテスト

このプログラムで実際にレアケースのCRITICAL!!が表示されるのか確かめようとすると、300回試行しても95%の確率でしかCRITICAL!!を見ることは出来ないので、テストするのが大変です。
ましてやもっと低確率な事象をプログラムした場合は人力ではテスト出来ないでしょう。

rand の動きをテストのときだけ任意の値が出るように制御しなければなりません。

乱数依存の実装 その2 (randをインターフェース化)

不確定要素に依存する場合は、その依存部分をインターフェースにするのが常套手段です。
動きを制御したいのは RNG.Intn(10) の部分なので、これを素直にインターフェースにしてみましょう。

整数値nを渡すと、0 <= x < nを返すので、以下のようなインターフェースを定義します。

type RandomNumberGenerator interface {
    Intn(n int) int
}

実装は次のように変わります。
具体的な変更ポイントとしては、RNG の型が *rand.Rand から、新しく定義したRandomNumberGenerator インターフェースに変わっています。

その2の実装

rand.New() が返す*rand.RandIntn(n int) int を実装しているので、 RandomNumberGenerator インターフェースも当然満たします。

package main

import (
    "fmt"
    "math/rand"
    "time"
)

// RandomNumberGenerator returns pseudo-random number
type RandomNumberGenerator interface {
    Intn(n int) int
}

// RNG random number generator
var RNG RandomNumberGenerator = rand.New(rand.NewSource(time.Now().UnixNano()))

// RoleD10 returns 0-9
func RoleD10() int {
    return RNG.Intn(10)
}

// Attack 攻撃結果は10面ダイスを2つ振ってパーセンテージ判定する
func Attack() (result string) {
    p := RoleD10()*10 + RoleD10()
    fmt.Println("2D10 Result:", p)

    if p == 0 {
        return "CRITICAL!!"
    }

    if p == 99 {
        return "FUMBLE..."
    }

    return "HIT!"
}

func main() {
    fmt.Println("Attack Result:", Attack())
}

乱数という不確定要素の実装がインターフェース依存になったことで、テストが書けるようになりました。

その2のテスト

テストコードの雛形は gotests サクッと生成してしまいます。
テストのときはパッケージ変数 RNG にモックを入れて、動作を思い通りに制御します。

package main

import (
    "testing"
)

type RandomNumberGeneratorMock struct {
    IntnResults []int
}

func (rng *RandomNumberGeneratorMock) Intn(n int) int {
    result := rng.IntnResults[0]
    rng.IntnResults = rng.IntnResults[1:]
    return result
}

// 乱数生成をモックにすることで確実に望みの乱数に対する挙動をテストできる
func TestAttack_RNGのMockで確実に成功(t *testing.T) {
    // 1 が出るように仕込む
    RNG = &RandomNumberGeneratorMock{IntnResults: []int{0, 1}}
    got := Attack()
    want := "HIT!"

    if got != want {
        t.Errorf("Attack() = %v, want %v", got, want)
    }
}

// 滅多に起きない確率のケースもテスト可能
func TestAttack_ケース網羅(t *testing.T) {
    type fakes struct {
        FakeIntnResults []int
    }
    tests := []struct {
        name       string
        fakes      fakes
        wantResult string
    }{
        {"0", fakes{FakeIntnResults: []int{0, 0}}, "CRITICAL!!"},
        {"55", fakes{FakeIntnResults: []int{5, 5}}, "HIT!"},
        {"99", fakes{FakeIntnResults: []int{9, 9}}, "FUMBLE..."},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // fakes で指定したRNGの結果をここで仕込む
            RNG = &RandomNumberGeneratorMock{IntnResults: tt.fakes.FakeIntnResults}

            if gotResult := Attack(); gotResult != tt.wantResult {
                t.Errorf("Attack() = %v, want %v", gotResult, tt.wantResult)
            }
        })
    }
}

乱数生成器は値を取得するたびに乱数を1つ返すので、その返す乱数が望みのものになるようにintのスライスで仕込んでおけるようにして、そこから1つずつ取り出すようにします。
Table Driven Testsパターンでは依存する引数をargsで表すことが多いですが、モックに仕込み通りの動作をさせるための偽物という意味でfakesと名付けています。

その2の実装の問題

これで乱数という不確定要素に依存する関数のテストを書くという目的は達成できました。
しかし、 math/rand には Intn(n int) int 以外にも多数のメソッドが定義されているため、この部分をインターフェース化すると、他のメソッドを使いたいときに不便です。

また、Go標準の math/randは乱数生成器のアルゴリズムを簡単に入れ替えることができるように、 rand.Newrand.Source インターフェースに依存するようになっています。
このおかけで、乱数生成器をメルセンヌ・ツイスタxorshift に簡単に入れ替えることが出来ます。

したがってインターフェース化するのは10面ダイスを振る部分 RoleD10() int にするべきでしょう。

乱数依存の実装 その3 (Diceをインターフェース化)

func Attack() (result string)D10Roller インターフェースに依存するように、 Attacker struct を新たに定義しています。
依存するものはstructのフィールドに取り込んでしまう方が書きやすくなります。

その3の実装

package main

import (
    "fmt"
    "math/rand"
    "time"
)

// D10Roller は 10面ダイスのインターフェース
type D10Roller interface {
    RoleD10() int
}

// Dice implements dice role
type Dice struct {
    rng *rand.Rand
}

// RoleD10 returns 0-9
func (d *Dice) RoleD10() int {
    return d.rng.Intn(10)
}

// Attacker は攻撃役
type Attacker struct {
    d10 D10Roller
}

// Attack 攻撃結果は10面ダイスを2つ振ってパーセンテージ判定する
func (a *Attacker) Attack() (result string) {
    p := a.d10.RoleD10()*10 + a.d10.RoleD10()
    fmt.Println("2D10 Result:", p)

    if p == 0 {
        return "CRITICAL!!"
    }

    if p == 99 {
        return "FUMBLE..."
    }

    return "HIT!"
}

func main() {
    a := &Attacker{
        d10: &Dice{
            rng: rand.New(rand.NewSource(time.Now().UnixNano())),
        },
    }
    fmt.Println("Attack Result:", a.Attack())
}

その3のテスト

直接動作を制御したいDiceをモックにしたことで、テストコードはスッキリしています。

package main

import (
    "testing"
)

type DiceMock struct {
    Results []int
}

func (rng *DiceMock) RoleD10() int {
    result := rng.Results[0]
    rng.Results = rng.Results[1:]
    return result
}

// Diceをモックにすることで確実に望みの結果に対する挙動をテストできる
func TestAttack_DiceのMockで確実に成功(t *testing.T) {
    // 1 が出るように仕込む
    dice := &DiceMock{Results: []int{0, 1}}
    a := &Attacker{d10: dice}
    got := a.Attack()
    want := "HIT!"

    if got != want {
        t.Errorf("Attack() = %v, want %v", got, want)
    }
}

func TestAttack_ケース網羅(t *testing.T) {
    type fakes struct {
        FakeResults []int
    }
    tests := []struct {
        name       string
        fakes      fakes
        wantResult string
    }{
        {"0", fakes{FakeResults: []int{0, 0}}, "CRITICAL!!"},
        {"55", fakes{FakeResults: []int{5, 5}}, "HIT!"},
        {"99", fakes{FakeResults: []int{9, 9}}, "FUMBLE..."},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // fakes で指定したDiceの結果をここで仕込む
            dice := &DiceMock{Results: tt.fakes.FakeResults}
            a := &Attacker{d10: dice}
            if gotResult := a.Attack(); gotResult != tt.wantResult {
                t.Errorf("Attack() = %v, want %v", gotResult, tt.wantResult)
            }
        })
    }
}

まとめ

  • 不確定要素に依存する関数、メソッドをインターフェースにすることでテスト可能になる
  • 外部ライブラリから提供される関数をそのままインターフェースにするより、使いやすい形でラップする方がテストしやすくなる

参考記事

35
27
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
35
27