Agonesの挙動をGoでテストする

Agones を使って開発をしている最中、Agonesの細かな挙動についてテストしたくなる時があった。

Agonesはクイックスタート的な小さなサーバーでも動かすのに結構な手間が必要で、パラメータを少しずつ変えながら意図した挙動になっているかチェックする…という作業も繰り返していると面倒になってくる。

そこでこの手作業でやっている検証を自動化できないか調べたところ、Agones本体の中にGo用のテストライブラリ (test/e2e/framework) が含まれていることに気づいた。

agones/test/e2e at ff6ec62569bda023176f0c7f2f4dab2430e11f8c · googleforgames/agones · GitHub

この framework パッケージでGoのコードから次のような処理ができる!

  • Kuberenetes namespace の作成と削除
  • Agones Fleet/GameServer の作成と削除
  • Agones Fleet/GameServer が特定の状態になるまで待機

Minikubeの準備

Minikubeを使うとお手軽にローカル環境にAgonesの環境を構築できるので、まずはこのドキュメントに従って環境を準備する。

Minikube | Agones

このようになったら準備完了。

$ kubectl get pods --namespace agones-system
NAME                                 READY   STATUS    RESTARTS   AGE
agones-allocator-7cd58db9dc-gdcdm    1/1     Running   0          28s
agones-allocator-7cd58db9dc-qjqdd    1/1     Running   0          28s
agones-allocator-7cd58db9dc-s28tc    1/1     Running   0          28s
agones-controller-6f4458776f-cr9fk   1/1     Running   0          28s
agones-ping-59d46d7fb5-85xzc         1/1     Running   0          28s
agones-ping-59d46d7fb5-b8q4v         1/1     Running   0          27s

Goでコードを書く

Agonesの環境が整ったので、実際にGoのテストで

まずはAgonesのライブラリを導入して、

go get agones.dev/agones

次のように e2eframwork.Framework という構造体をグローバルに保持しておき、この構造体を使ってAgonesのさまざまな機能にアクセスできる。 TestMain でこのframeworkを初期化しておくとすべてのテストで自動的に初期化できて便利。

import (
    "fmt"
    "log"
    "os"
    "testing"

    e2eframework "agones.dev/agones/test/e2e/framework"
)

var (
    framework *e2eframework.Framework
)

func TestMain(m *testing.M) {
    fw, err := e2eframework.NewFromFlags()
    if err != nil {
        log.Fatalf("failed to init e2e e2eframework: %+v", err)
    }
    framework = fw

    var exitCode int
    defer func() {
        os.Exit(exitCode)
    }()
    exitCode = m.Run()
}

Namespaceの作成と削除

テストごとに独立したKubernetes Namespaceを切って、テスト同士が影響しないように分離するとよい。

そのために、ランダムな名前のNamespaceを作って、テスト終了後に削除するという処理を用意しておく。 ランダムな値を作るにはUUID(v4)などを使うと便利。

テスト終了後に削除するのは defer を使ってもよいが、Go 1.14 で追加された t.Cleanup を使うとより便利

import (
    "github.com/google/uuid"
)

func newRandomNamespace(t *testing.T) string {
    namespace := fmt.Sprintf("testns-%s", uuid.Must(uuid.NewRandom()))
    if err := framework.CreateNamespace(namespace); err != nil {
        panic(err)
    }
    t.Cleanup(func() {
        _ = framework.DeleteNamespace(namespace)
    })
    return namespace
}

GameServer と Fleetの作成

実際にGameServerやFleetを立ち上げるには、Fleet構造体を作り framework.AgonesClient.AgonesV1().Fleets(namespace).Create(...) に渡す。

構造体のパラメータがやたら多く複雑に見えるが、これ実は手動でAgonesを使うときに定義するYAMLとまったく同じ構造である。 よってわからない場合はKubernetesのYAML定義の説明を見ればよい。

GameServerの中身は簡単のため、Agones公式が用意してくれてるtcp-server を使った。 ここを自分で作ったImageに差し替えると自作GameServerでもテストが可能。

import (
    "k8s.io/apimachinery/pkg/api/resource"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "log"
    "testing"
    "time"

    agonesv1 "agones.dev/agones/pkg/apis/agones/v1"
    corev1 "k8s.io/api/core/v1"
)

func TestCreateFleet(t *testing.T) {
    // ランダムなnamespaceをつくる
    namespace := newRandomNamespace(t)

    // GameServerの定義をつくる
    gsSpec := agonesv1.GameServerSpec{
        Ports: []agonesv1.GameServerPort{
            {
                ContainerPort: 7654,
                Name:          "tcp",
                PortPolicy:    agonesv1.Dynamic,
                Protocol:      corev1.ProtocolTCP,
            }},
        Template: corev1.PodTemplateSpec{
            Spec: corev1.PodSpec{
                Containers: []corev1.Container{{
                    Name:            "tcp-server",
                    Image:           "gcr.io/agones-images/tcp-server:0.4",
                    ImagePullPolicy: corev1.PullIfNotPresent,
                    Resources: corev1.ResourceRequirements{
                        Requests: corev1.ResourceList{
                            corev1.ResourceCPU:    resource.MustParse("30m"),
                            corev1.ResourceMemory: resource.MustParse("32Mi"),
                        },
                        Limits: corev1.ResourceList{
                            corev1.ResourceCPU:    resource.MustParse("30m"),
                            corev1.ResourceMemory: resource.MustParse("32Mi"),
                        },
                    },
                }},
            },
        },
    }

    // Fleetを作る
    fleetDef := &agonesv1.Fleet{
        ObjectMeta: metav1.ObjectMeta{Name: "fleet-name", Namespace: namespace},
        Spec: agonesv1.FleetSpec{
            Replicas: 2,
            Template: agonesv1.GameServerTemplateSpec{
                Spec: gsSpec,
            },
        },
    }
    flt, err := framework.AgonesClient.AgonesV1().Fleets(namespace).Create(fleetDef)
    if err != nil {
        t.Fatal(err)
    }
    log.Printf("fleet created! (name: %s)", flt.ObjectMeta.Name)
}

このテストを実行すると、いくつかのログが出てFleetの作成に成功!

=== RUN   TestCreateFleet
{"message":"Namespace gameserver-test-1aacbb5a-1e32-42c3-a48e-0761c15d044f is created","severity":"info","time":"2020-07-27T23:09:48.764999+09:00"}
{"message":"ServiceAccount gameserver-test-1aacbb5a-1e32-42c3-a48e-0761c15d044f/agones-sdk is created","severity":"info","time":"2020-07-27T23:09:48.770368+09:00"}
{"message":"RoleBinding gameserver-test-1aacbb5a-1e32-42c3-a48e-0761c15d044f/agones-sdk-access is created","severity":"info","time":"2020-07-27T23:09:48.775277+09:00"}
23:09:48 fleet created! (name: fleet-name)
{"message":"Namespace gameserver-test-1aacbb5a-1e32-42c3-a48e-0761c15d044f is deleted","severity":"info","time":"2020-07-27T23:09:48.821892+09:00"}
--- PASS: TestCreateFleet (0.07s)
PASS

これでNamespace作成→Fleet作成までがコード上で実現可能になったので、さまざまなテストを自動化できる。すごい!

Kubernetesの特性を考慮した便利な関数たち

さらに、frameworkパッケージには便利関数がいくつか用意されているので、少しだけ紹介。

AgonesというかKubernetesは、命令したら即座にその状態になるのではなく、内部でループを回して理想の状態にだんだん近づける といった動きをする。

よって、次のように「〜〜の状態になるまで待機する」系の関数が役立つ。これはAgones本体のテストでも多用されていてテストコードを読んでみるだけでも結構面白い。

// AssertFleetCondition: Fleetが指定した状態になることを確認するassertion
// 例)Status: Readyなゲームサーバーが2つ揃うことを検証する
framework.AssertFleetCondition(t, flt, func(flt *agonesv1.Fleet) bool {
    return flt.Status.ReadyReplicas == 2
})

// WaitFor* 系関数: Agonesのさまざまな要素が一定の状態になるまで待機する
// Status: Readyなゲームサーバーが2つになるまで待機する
framework.WaitForFleetCondition(t, flt, func(flt *agonesv1.Fleet) bool {
    return flt.Status.ReadyReplicas == 2
})