Skip to main content

Manual placement and Pod expectations

Without kube-scheduler, a Pod stays Pending until the test places it. The test can use that moment as an assertion, the way a mock checks a call: it states which Pods your code should create, and decides where each one runs.

The samples run on a dedicated plane with Nodes.

One Pod at a time​

cp.ExpectPod(t, matchers...) waits for a matching Pod that is Pending and unscheduled, and returns it. It never returns the same Pod twice. cp.Bind(t, pod, node) places the Pod with the same pods/binding call kube-scheduler makes.

bind_test.go
package placement_test

import (
"testing"

corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/yyewolf/petri"
)

func TestPlaceAPodByHand(t *testing.T) {
t.Parallel()

cp := dedicatedPlane(t)
ns := cp.Namespace(t)
pods := cp.Client().CoreV1().Pods(ns)
node := cp.AddNode(t, petri.NodeOptions{Name: "worker"})

// Your code under test would create this Pod.
_, err := pods.Create(t.Context(), &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "job-runner", Labels: map[string]string{"app": "runner"}},
Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "run", Image: "runner"}}},
}, metav1.CreateOptions{})
if err != nil {
t.Fatal(err)
}

// ExpectPod waits for a matching Pending Pod and hands it over; Bind
// places it with the pods/binding call kube-scheduler makes.
pod := cp.ExpectPod(t, petri.InNamespace(ns), petri.Labeled("app", "runner"))
cp.Bind(t, pod, node)

petri.WaitFor(t, pods, pod.Name, func(p *corev1.Pod) (bool, error) {
return p != nil && p.Status.Phase == corev1.PodRunning && p.Status.PodIP != "", nil
})
}

Declaring expectations​

cp.ExpectPods(t, matchers...) registers an expectation. It claims matching Pods as they are created, before anything else sees them:

expectations_test.go
package placement_test

import (
"testing"

appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/yyewolf/petri"
)

func TestDeploymentRunsOnTheNodesWeChoose(t *testing.T) {
t.Parallel()

cp := dedicatedPlane(t)
ns := cp.Namespace(t)
deployments := cp.Client().AppsV1().Deployments(ns)
node := cp.AddNode(t, petri.NodeOptions{})
cp.EnableControllers(t, petri.DeploymentController, petri.ReplicaSetController)

// Like gomock: declare what must happen before it happens. Each matching
// Pod is claimed as it arrives and bound to the node. If fewer than three
// arrive, the test fails when it ends; StrictPods also fails it for any
// Pod nobody expected.
web := cp.ExpectPods(t, petri.InNamespace(ns), petri.Labeled("app", "web")).Times(3).BindTo(node)
cp.StrictPods(t)

labels := map[string]string{"app": "web"}
_, err := deployments.Create(t.Context(), &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "web"},
Spec: appsv1.DeploymentSpec{
Replicas: new(int32(3)),
Selector: &metav1.LabelSelector{MatchLabels: labels},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels},
Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "web", Image: "nginx"}}},
},
},
}, metav1.CreateOptions{})
if err != nil {
t.Fatal(err)
}

web.Wait(t) // all three claimed and bound
petri.WaitFor(t, deployments, "web",
petri.StatusCondition[*appsv1.Deployment]("Available", metav1.ConditionTrue))
}
MethodMeaning
Times(n)Claim n Pods; the default is one. Later matching Pods are left unclaimed.
AnyTimes()Any number, zero included.
BindTo(node)Bind each claimed Pod to node.
Do(func(ctx, pod) error)Run your own action on each claimed Pod.
Wait(t)Wait until the expected number of Pods were claimed and acted on.
Pods()The Pods claimed so far.

When the test ends, petri checks every expectation. One that claimed fewer Pods than expected fails the test. An expectation never claims more than its count, so extra Pods go unnoticed unless you call cp.StrictPods(t), which fails the test for each Pod no expectation claimed.

Expectations claim Pods in the order they were registered, and before ExpectPod does. A Pod goes to the first expectation that matches it.

Matchers​

MatcherMatches Pods
petri.InNamespace(ns)in a namespace
petri.Labeled(key, value)with a label
petri.OwnedBy(obj)owned by an object, by UID
petri.PodMatching(description, func(*corev1.Pod) bool)for anything else; the description shows in failure messages

A Pod must match every matcher given.

Finishing Pods​

No container runs, so the test decides how a Pod ends. This is how a Job completes:

job_test.go
package placement_test

import (
"testing"

batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/yyewolf/petri"
)

func TestJobCompletesWhenItsPodSucceeds(t *testing.T) {
t.Parallel()

cp := dedicatedPlane(t)
ns := cp.Namespace(t)
jobs := cp.Client().BatchV1().Jobs(ns)
node := cp.AddNode(t, petri.NodeOptions{})
cp.EnableControllers(t, petri.JobController)

job, err := jobs.Create(t.Context(), &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{Name: "migrate"},
Spec: batchv1.JobSpec{
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
Containers: []corev1.Container{{Name: "migrate", Image: "migrate"}},
},
},
},
}, metav1.CreateOptions{})
if err != nil {
t.Fatal(err)
}

pod := cp.ExpectPod(t, petri.OwnedBy(job))
cp.Bind(t, pod, node)

// No container runs, so the test decides how the Pod ends: CompletePod
// succeeds it, FailPod fails it with an exit code.
petri.WaitFor(t, cp.Client().CoreV1().Pods(ns), pod.Name, func(p *corev1.Pod) (bool, error) {
return p != nil && p.Status.Phase == corev1.PodRunning, nil
})
cp.CompletePod(t, pod)

petri.WaitFor(t, jobs, "migrate",
petri.StatusCondition[*batchv1.Job]("Complete", metav1.ConditionTrue))
}
  • cp.CompletePod(t, pod) marks the Pod Succeeded, with its containers terminated with exit code 0.
  • cp.FailPod(t, pod, exitCode) marks it Failed.
  • cp.SetPodReady(t, pod, ready) changes its readiness, to test how your code handles a Pod that stops being ready.
  • cp.Evict(t, pod) evicts it through the Eviction API, so PodDisruptionBudgets apply.

The simulated kubelet never overrides what the test set.

Scheduler or by hand​

A plane places Pods in one way only. Bind, ExpectPod and ExpectPods fail the test on a plane running SchedulerController. Enabling the scheduler on a plane where the test placed Pods or registered expectations fails too. The shared plane has no Nodes, so it refuses all of these.