client-goのfake clientでKubernetesオブジェクトの作成をテストする

::: note info
これは [ZOZO Advent Calendar 2023](https://qiita.com/advent-calendar/2023/zozo) カレンダー Vol.5 の 4日目の記事です。
:::

本記事では、`client-go`の`kubernetes/fake`パッケージを使って、テスト関数を実装する例を紹介します。
テスト関数では、テスト対象の関数内の処理によりKubernetesオブジェクトが作成されているかどうかの確認を行います。

# fake clientとは
`kubernetes/fake`パッケージが提供する`Clientset`を利用することで、実際にKubernetes APIへリクエストを送ることなく実行マシンのメモリ上でclientsetによるオブジェクト取得や作成といった動きを模倣できます。 

`client-go`が提供する`kubernetes`パッケージの`Clientset`には様々なKubernetesのデフォルトリソースに関してKubernetes APIにリクエストするためのクライアントが含まれており、PodやDeploymentといったデフォルトリソースをGo言語で操作する際に利用されます。。

`kubernetes`パッケージの`Clientset`は、同じく`kubernetes`パッケージの`Interface`インターフェースを満たしています。  
一方で、`kubernetes/fake`パッケージが提供する`Clientset`も`kubernetes`パッケージの`Interface`インターフェースを満たしています。

そのため、テスト対象の関数の引数に`kubernetes`パッケージの`Interface`インターフェースでクライアントを受け取るように実装していれば、`kubernetes`パッケージの`Clientset`と`kubernetes/fake`パッケージの`Clientset`のどちらの型でも渡すことが可能です。

# fake clientを使ったテストの実装例
## テスト対象の関数
`kubernetes/fake`パッケージの`Clientset`を使ったテストの例として、次のプログラムのテストを作成します。
テスト対象の関数は`replaceNameAndCreateNewPod()`関数です。 

この関数では引数で渡されたPodオブジェクトを元に、`Name`フィールドの値を`podName`引数の値で上書きした新しいKubernetesオブジェクトを作成します。

`clientset`引数の型が`kubernetes.Interaface`になっている点がポイントです。

```create_pod.go
package main

import (
	"context"
	"flag"
	"fmt"
	"os"

	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/client-go/kubernetes"
	"k8s.io/client-go/tools/clientcmd"
)

var (
	kubeconfig string
	podName    string
)

func main() {
	flag.StringVar(&kubeconfig, "kubeconfig", "", "kubeconfig path")
	flag.StringVar(&podName, "name", "", "Pod Name")
	flag.Parse()
	config, _ := clientcmd.BuildConfigFromFlags("", kubeconfig)
	clientset, _ := kubernetes.NewForConfig(config)

	base := &corev1.Pod{
		ObjectMeta: metav1.ObjectMeta{
			Name: "<REPLACE_THIS_FIELD>",
		},
		Spec: corev1.PodSpec{
			Containers: []corev1.Container{
				{
					Name:    "sleep-container",
					Image:   "alpine",
					Command: []string{"sleep", "3600"},
				},
			},
		},
	}

	_, err := replaceNameAndCreateNewPod(clientset, base, podName)

	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
}

func replaceNameAndCreateNewPod(clientset kubernetes.Interface, base *corev1.Pod, podName string) (*corev1.Pod, error) {
	newPod := base.DeepCopy()
	newPod.ObjectMeta.Name = podName

	_, err := clientset.CoreV1().Pods("default").Create(context.TODO(), newPod, metav1.CreateOptions{})

	if err != nil {
		return nil, err
	}

	return newPod, nil
}

```

## テスト関数
`replaceNameAndCreateNewPod()`関数の振る舞いとしてテストしたい観点は次の3点になります。  

1. 実行時にエラーが発生しないこと
1. 新しいPodオブジェクトの`Name`フィールドの値が変更されていること
1. clientsetにより、新しいPodオブジェクトが作成されていること

テスト関数の実装は以下になります。

```create_pod_test.go
package main

import (
	"context"
	"testing"

	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/client-go/kubernetes/fake"

	"github.com/stretchr/testify/assert"
)

func TestReplaceNameAndCreateNewPod(t *testing.T) {
	clientset := fake.NewSimpleClientset()

	original := &corev1.Pod{
		ObjectMeta: metav1.ObjectMeta{
			Name: "<REPLACE_THIS_FIELD>",
		},
		Spec: corev1.PodSpec{
			Containers: []corev1.Container{
				{
					Name:    "sleep-container",
					Image:   "alpine",
					Command: []string{"sleep", "3600"},
				},
			},
		},
	}

	tests := []struct {
		name     string
		input    string
		expected string
	}{
		{
			name:     "check equal to expected name",
			input:    "sample-pod",
			expected: "sample-pod",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			newPod, err := replaceNameAndCreateNewPod(clientset, original, tt.input)
			assert.NoError(t, err)
			assert.Equal(t, tt.expected, newPod.ObjectMeta.Name)

			_, err = clientset.CoreV1().Pods("default").Get(context.TODO(), tt.expected, metav1.GetOptions{})
			assert.NoError(t, err)
		})
	}
}
```

`TestReplaceNameAndCreateNewPod()`関数の初めに`kubernetes/fake`パッケージの`NewSimpleClientset()`関数を呼び、クライアントを初期化しています。  
`replaceNameAndCreateNewPod()`を呼び出す際には、こちらで初期化したクライアントを渡していることがわかるかと思います。

テストではまず次の部分で、`replaceNameAndCreateNewPod()`の戻り値とエラーオブジェクトについて、想定した値が返されているか確認しています。
 ```go
   		newPod, err := replaceNameAndCreateNewPod(clientset, original, tt.input)
   		assert.NoError(t, err)
   		assert.Equal(t, tt.expected, newPod.ObjectMeta.Name)
 ```

次に以下の部分で、想定したPodオブジェクトが作成されているのかのチェックを行なっています。
`fake.Clientset`が活躍しているのはこちらの部分です。
```go
			_, err = clientset.CoreV1().Pods("default").Get(context.TODO(), tt.expected, metav1.GetOptions{})
			assert.NoError(t, err)
```
上記のコードでテストを実行するとテストは成功します。
```bash
go test .                                                                                                                                                      
ok      main/create-pod 0.638s
```

## fake clientの機能を実験
`kubernetes/fake`パッケージの`Clientset`の`Create()`メソッドが呼ばれた際に、本当にメモリ上でオブジェクトが作成されているのか実験してみます。

`create_pod.go`の`replaceNameAndCreateNewPod()`関数の一部実装をコメントアウトしてテストを実行します。（contextパッケージの参照もなくなるためimport文もコメントアウトしてください）

この変更により`clientset`の`Create()`メソッドは呼ばれなくなるため、上記の3つ目のテスト箇所は失敗する想定です。
```go
func replaceNameAndCreateNewPod(clientset kubernetes.Interface, base *corev1.Pod, podName string) (*corev1.Pod, error) {
	newPod := base.DeepCopy()
	newPod.ObjectMeta.Name = podName

	// _, err := clientset.CoreV1().Pods("default").Create(context.TODO(), newPod, metav1.CreateOptions{})

	// if err != nil {
	// 	return nil, err
	// }

	return newPod, nil
}
```
変更後にテストを実行すると以下の出力を得ます。  
出力から、3つ目の観点のチェック箇所で`not found`なエラーが返され、テストに失敗していることがわかります。
```bash
go test .                                                                                                                                                               19:22:34
--- FAIL: TestReplaceNameAndCreateNewPod (0.00s)
    --- FAIL: TestReplaceNameAndCreateNewPod/check_equal_to_expected_name (0.00s)
        create_pod_test.go:51: 
                Error Trace:    /Users/yutookamoto/work/create_k8s_custom_resource/create-pod/create_pod_test.go:51
                Error:          Received unexpected error:
                                pods "sample-pod" not found
                Test:           TestReplaceNameAndCreateNewPod/check_equal_to_expected_name
FAIL
FAIL    main/create-pod 0.549s
FAIL
```
このことから、`kubernetes/fake`パッケージの`Clientset`は`Create()`メソッドによるKubernetes APIへの操作をメモリ上で記録できており、テスト内で`kubernetes`パッケージの`Clientset`の動きを模倣できていることがわかりました。

# Custom Resourceを扱う場合のfake client
今回は例としてPodオブジェクトの操作を題材にしため、`kubernetes/fake`パッケージの`Clientset`を利用しました。
一方で、Custom Resourceのオブジェクトを扱う際には`client-go`が提供する`dynamic/fake`パッケージの`FakeDynamicClient`を利用可能です。  
こちらを利用することで`dynamic`パッケージの`DynamicClient`を模倣できます。

`DynamicClient`については、別記事の[client-goでCustom ResourceのオブジェクトをGETする](https://qiita.com/okmtz/items/ca1ff7eb74c22e7d89e1)で説明しているため、気になる方は参考にしてみてください。

# まとめ
本記事では、`client-go`の`kubernetes/fake`パッケージを使ってKubernetesオブジェクトの作成をテストする方法を紹介しました。

`kubernetes/fake`パッケージの`Clientset`オブジェクトを利用することで`kubernetes`パッケージの`Clientset`オブジェクトの動きを模倣してテストを行うことができました。

注意点として、`kubernetes/fake`パッケージの`Clientset`を利用した場合、`etcd`へのリソースの書き込みやAdmision Webhookでの`validation`・`mutation`は対象のリソースに対して機能しないという点があります。こちらの点には注意してください。

# 参考
- [Kubernetes Programming with Go](https://www.amazon.co.jp/Kubernetes-Programming-Go-Clients-Operators/dp/1484290259)