Kubernetes Fake Client doesn't handle GenerateName in ObjectMeta

8/15/2021

When using the Kubernetes Fake Client to write unit tests, I noticed that it fails to create two identical objects which have their ObjectMeta.GenerateName field set to some string. A real cluster accepts this specification and generates a unique name for each object.

Running the following test code:

package main

import (
	"context"
	"testing"

	"github.com/stretchr/testify/assert"
	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/client-go/kubernetes/fake"
)

func TestFake(t *testing.T) {
	ctx := context.Background()
	client := fake.NewSimpleClientset()

	_, err := client.CoreV1().Secrets("default").Create(ctx, &corev1.Secret{
		ObjectMeta: metav1.ObjectMeta{
			GenerateName: "generated",
		},
		StringData: map[string]string{"foo": "bar"},
	}, metav1.CreateOptions{})
	assert.NoError(t, err)

	_, err = client.CoreV1().Secrets("default").Create(ctx, &corev1.Secret{
		ObjectMeta: metav1.ObjectMeta{
			GenerateName: "generated",
		},
		StringData: map[string]string{"foo": "bar"},
	}, metav1.CreateOptions{})
	assert.NoError(t, err)
}

fails with

--- FAIL: TestFake (0.00s)
    /Users/mihaitodor/Projects/kubernetes/main_test.go:44: 
        	Error Trace:	main_test.go:44
        	Error:      	Received unexpected error:
        	            	secrets "" already exists
        	Test:       	TestFake
FAIL
FAIL	kubernetes	0.401s
FAIL
-- Mihai Todor
client-go
go
kubernetes
unit-testing

1 Answer

8/15/2021

According to this GitHub issue comment:

the fake clientset doesn't attempt to duplicate server-side behavior like validation, name generation, uid assignment, etc. if you want to test things like that, you can add reactors to mock that behavior.

To add the required reactor, we can insert the following code before creating the corev1.Secret objects:

client.PrependReactor(
	"create", "*",
	func(action k8sTesting.Action) (handled bool, ret runtime.Object, err error) {
		ret = action.(k8sTesting.CreateAction).GetObject()
		meta, ok := ret.(metav1.Object)
		if !ok {
			return
		}

		if meta.GetName() == "" && meta.GetGenerateName() != "" {
			meta.SetName(names.SimpleNameGenerator.GenerateName(meta.GetGenerateName()))
		}

		return
	},
)

There are a few gotchas in there:

  • The Clientset contains an embedded Fake structure which has the PrependReactor method we need to call for this use case (there are a few others). This code here is invoked when creating such objects.
  • The PrependReactor method has 3 parameters: verb, resource and reaction. For verb, resource, I couldn't find any named constants, so, in this case, "create" and "secrets" (strange that it's not "secret") seem to be the correct values for them if we want to be super-specific, but setting resource to "*" should be acceptable in this case.
  • The reaction parameter is of type ReactionFunc, which takes an Action as a parameter and returns handled, ret and err. After some digging, I noticed that the action parameter will be cast to CreateAction, which has the GetObject() method that returns a runtime.Object instance, which can be cast to metav1.Object. This interface allows us to get and set the various metadata fields of the underlying object. After setting the object Name field as needed, we have to return handled = false, ret = mutatedObject and err = nil to instruct the calling code to execute the remaining reactors.
  • Digging through the apiserver code, I noticed that the ObjectMeta.Name field is generated from the ObjectMeta.GenerateName field using the names.SimpleNameGenerator.GenerateName utility.
-- Mihai Todor
Source: StackOverflow