---
title: "Testing — Kubernetes API for Laravel | Roundly"
description: "Kubernetes::fake() swaps in an in-memory apiserver — seed objects, stub logs and exec, then assert creates, updates, scales, restarts and deletes."
url: https://roundly-consulting.com/open-source/docs/kubernetes-api-for-laravel/testing
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Kubernetes API for Laravel](https://roundly-consulting.com/open-source/docs/kubernetes-api-for-laravel.md)

# Testing

Kubernetes::fake() swaps the manager — behind the facade and in the container, so injected KubernetesManagers get it too — for an in-memory apiserver, and returns the KubernetesFake handle. Nothing reaches the network. Every cluster the manager hands out (named, default, ad-hoc, namespace-scoped) and every resource bound to one talks to the fake, which records each request:

```php
use RoundlyConsulting\KubernetesApi\Facades\Kubernetes;
use RoundlyConsulting\KubernetesApi\Resources\Deployment;

it('scales checkout for the sale', function () {
    $fake = Kubernetes::fake()->seed(Deployment::class, [
        ['metadata' => ['name' => 'checkout', 'namespace' => 'shop'], 'spec' => ['replicas' => 2]],
    ]);

    app(PrepareForSale::class)();   // your code: Kubernetes::namespace('shop')->deployments()->setName('checkout')->scale(10)

    $fake->assertScaled(Deployment::class, 'checkout', 10);
    $fake->assertNothingDeleted();
});
```

The fake behaves like a small apiserver: seeded and created objects can be listed (label and field selectors, limit and continue, all namespaces), found, updated, patched, scaled and deleted. A missing object is a real 404, a duplicate create or a stale resourceVersion a real 409, and dryRun() requests validate without persisting. As on the apiserver, a create with metadata.generateName and no name gets <generateName> plus five random characters, update(), updateOrCreate() and patch() keep the stored status (a status in the body is ignored), and a limit of 0 or less means no limit. Each cluster name has its own store; ad-hoc clusters share the default cluster’s.

## Set-up

| Set-up | Effect |
| --- | --- |
| `seed(string $resource, array $items, ?string $cluster = null)` | Put objects — manifests or resource objects — on a cluster. $resource is a class or a registered name ('pods'). A namespaced item without metadata.namespace lands in the target cluster’s default namespace, where create() through that cluster would put it; an unknown cluster name throws ClusterNotFoundException. |
| `seedLogs(string $pod, string $logs, string $namespace = 'default', ?string $cluster = null)` | What logs() and streamLogs() return; tailLines is honoured. |
| `stubExec(ExecResult\|Closure $result)` | What exec() returns — a fixed result, or a closure given the RecordedRequest. Default: empty output, exit code 0. |
| `stubVersion(VersionInfo\|string $version)` | What version() reports; default v1.34.0. |
| `unreachable(bool $unreachable = true)` | Every request throws ConnectionException; ping() returns false. |
| `recorded(?Closure $filter = null)` | Every RecordedRequest the fake received, optionally filtered. |

Logs, exec and watches work against the fake too — a watch streams one ADDED event per matching object:

```php
use RoundlyConsulting\KubernetesApi\DataTransferObjects\ExecResult;
use RoundlyConsulting\KubernetesApi\DataTransferObjects\PodLogOptions;
use RoundlyConsulting\KubernetesApi\DataTransferObjects\WatchEvent;
use RoundlyConsulting\KubernetesApi\Facades\Kubernetes;

it('serves seeded pods, logs and exec', function (): void {
    $fake = Kubernetes::fake()
        ->seed('pods', [
            ['metadata' => ['name' => 'api', 'namespace' => 'shop', 'labels' => ['app' => 'web']]],
        ])
        ->seedLogs('api', "one\ntwo\nthree\n", namespace: 'shop')
        ->stubExec(new ExecResult("hi\n", '', 0));

    $pod = Kubernetes::namespace('shop')->pods()->setName('api');

    expect($pod->logs(new PodLogOptions(tailLines: 2)))->toBe("two\nthree")
        ->and(iterator_to_array($pod->streamLogs(), false))->toBe(['one', 'two', 'three'])
        ->and($pod->exec(['sh', '-c', 'echo hi'])->stdout)->toBe("hi\n");

    // A watch streams one ADDED event per matching object
    $events = [];
    Kubernetes::namespace('shop')->pods()->whereLabel('app', 'web')
        ->watch(function (WatchEvent $event) use (&$events): void {
            $events[] = $event;
        });

    expect($events)->toHaveCount(1)
        ->and($events[0]->isAdded())->toBeTrue();

    $fake->assertExecuted('api', ['sh', '-c', 'echo hi']);
});
```

## Assertions

| Assertion · nothing-variant | Example | Passes when |
| --- | --- | --- |
| `assertSent(Closure) · assertNothingSent()` | `$fake->assertSent(fn (RecordedRequest $r): bool => $r->cluster === 'production')` | Any request matches — or no request was sent at all. |
| `assertCreated($resource, $constraint = null) · assertNothingCreated()` | `$fake->assertCreated('configMaps', 'settings')` | A create of that resource — optionally that name, or matching the closure. A service-account token request counts as a create. |
| `assertUpdated($resource, $constraint = null) · assertNothingUpdated()` | `$fake->assertUpdated(Deployment::class, 'checkout')` | A full update — update(), or updateOrCreate() on an existing object. |
| `assertPatched($resource, $constraint = null) · assertNothingPatched()` | `$fake->assertPatched('deployments', 'checkout')` | Any PATCH — patch(), scale() and rolloutRestart() included. |
| `assertScaled($resource, $name, ?int $replicas = null) · assertNothingScaled()` | `$fake->assertScaled(Deployment::class, 'checkout', 10)` | A scale() — to that many replicas when given. |
| `assertRestarted($resource, $name) · assertNothingRestarted()` | `$fake->assertRestarted(Deployment::class, 'checkout')` | A rolloutRestart(). |
| `assertDeleted($resource, $constraint = null) · assertNothingDeleted()` | `$fake->assertDeleted('pods', 'api')` | A delete. |
| `assertExecuted(string $pod, ?array $command = null) · assertNothingExecuted()` | `$fake->assertExecuted('api', ['sh', '-c', 'echo hi'])` | An exec() in that pod — with exactly that command when given. |
| `assertTokenRequested(string $serviceAccount, ?string $namespace = null, ?int $expirationSeconds = null) · assertNoTokenRequested()` | `$fake->assertTokenRequested('deployer', 'ci', 900)` | A requestToken() for that service account — in that namespace and for that lifetime when given. |

$resource is a resource class or a registered name ('pods'); the constraint is an object name or a closure that receives the RecordedRequest. Dry-run requests are recorded but never satisfy the mutation assertions:

```php
use RoundlyConsulting\KubernetesApi\Facades\Kubernetes;
use RoundlyConsulting\KubernetesApi\Resources\Deployment;
use RoundlyConsulting\KubernetesApi\Testing\RecordedRequest;

$fake->assertCreated('configMaps', fn (RecordedRequest $request): bool => $request->namespace === 'shop'
    && $request->input('data.FEATURE_X') === 'on');

// Everything the fake received, optionally filtered
$dryRuns = $fake->recorded(fn (RecordedRequest $request): bool => $request->isDryRun());

// The assertions are also callable on the facade once it is faked
Kubernetes::assertScaled(Deployment::class, 'checkout', 10);
```

A RecordedRequest carries cluster (null for ad-hoc clusters), method, path, verb, apiVersion, plural, namespace, name, subresource, query, the decoded body, contentType, and command and container for exec — plus isDryRun(), input('spec.replicas') and isRolloutRestart().

## Service-account tokens

A token request is recorded as a create on the token subresource, with name set to the service account — so assertCreated('serviceAccounts', 'deployer') sees it and assertNothingCreated() fails on it. The fake answers with a 404 unless the service account was seeded in that cluster and namespace; otherwise with a deterministic token, fake-token-{namespace}.{name}.{n} (n counts the fake’s token requests from 1), an expiry of now plus expirationSeconds (default 3600 — freeze time with Carbon::setTestNow()), the requested audiences (default https://kubernetes.default.svc.cluster.local) and any binding echoed back:

```php
use Illuminate\Support\Carbon;
use RoundlyConsulting\KubernetesApi\Facades\Kubernetes;

Carbon::setTestNow('2026-10-10T12:00:00Z');

$fake = Kubernetes::fake()->seed('serviceAccounts', [
    ['metadata' => ['name' => 'deployer', 'namespace' => 'ci']],
]);

$token = Kubernetes::namespace('ci')->serviceAccounts()->setName('deployer')->requestToken(900);

$token->token;                              // 'fake-token-ci.deployer.1'
$token->expiresAt->toIso8601ZuluString();   // '2026-10-10T12:15:00Z'

$fake->assertTokenRequested('deployer', 'ci', 900);
Kubernetes::assertTokenRequested('deployer');   // through the facade
$fake->assertCreated('serviceAccounts', 'deployer');   // a token request is a create too
```

## What the fake does not do

- Clusters registered before fake() carry over, and their definitions still run; under the fake, fromKubeConfig(), inCluster() and the kubeconfig and in-cluster config sources never read credentials.
- Like the apiserver, the fake answers a server-side apply without fieldManager, or force on any other patch type, with a 422.
- Strategic-merge and server-side-apply patches are applied as JSON merge patches, and logs and exec answer for any pod.
- Deletes are immediate. A real delete() is a Foreground deletion: the apiserver keeps the object, marked with a deletionTimestamp, until its dependents are gone (a pod also waits out its grace period), so a GET still answers and a re-create gets a 409. The fake removes the object at once — test delete-then-recreate flows against a real cluster.
- status is kept on every kind, including custom resources without a status subresource, where a real apiserver would store what you send.
- Clusters built without the manager — new Cluster, Cluster::make() — bypass the fake.

## Wire-level control with Http::fake()

Http::fake() still works — it intercepts the real HTTP transport, for tests that want to assert exact URLs and headers. Disable client-side rate limiting there so a large suite never waits on the in-memory budget:

```php
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use RoundlyConsulting\KubernetesApi\Cluster;
use RoundlyConsulting\KubernetesApi\Facades\Kubernetes;

beforeEach(function (): void {
    config(['kubernetes.rate_limits.enabled' => false]);

    Kubernetes::registerCluster('testing', fn (Cluster $cluster): Cluster => $cluster
        ->url('https://k8s.example.com')
        ->withToken('fake-token')
        ->withManagerName('shop-tests'));

    Http::preventStrayRequests();
});

it('lists the deployments in a namespace', function (): void {
    Http::fake([
        '*' => Http::response([
            'items' => [
                ['metadata' => ['name' => 'checkout', 'namespace' => 'shop']],
            ],
        ]),
    ]);

    $deployments = Kubernetes::cluster('testing')->deployments()->setNamespace('shop')->get();

    expect($deployments)->toHaveCount(1)
        ->and($deployments->first()->getName())->toBe('checkout');

    Http::assertSent(fn (Request $request): bool => $request->method() === 'GET'
        && $request->url() === 'https://k8s.example.com/apis/apps/v1/namespaces/shop/deployments?pretty=1'
        && $request->hasHeader('Authorization', 'Bearer fake-token'));
});
```

exec() opens its own WebSocket instead of using the HTTP client, so Http::fake() doesn’t intercept it — stub it with Kubernetes::fake(), or assert the exact subresource path and query getExecPath() builds for a command:

```php
$path = Kubernetes::cluster('testing')->pods()
    ->setNamespace('shop')
    ->withName('checkout-5f7c9d8b6-x2kqj')
    ->getExecPath(['php', 'artisan', 'cache:clear'], container: 'app');

expect($path)
    ->toContain('/api/v1/namespaces/shop/pods/checkout-5f7c9d8b6-x2kqj/exec?')
    ->toContain('container=app')
    ->toContain('command=cache%3Aclear');
```

## The package’s own suites

The package’s default suite is fully faked and needs no cluster. A separate, opt-in suite runs the real CRUD, exec and listing matrix against a local OrbStack cluster; it is guarded so it only runs when K8S\_INTEGRATION=1, the context is exactly orbstack and the apiserver host is loopback or \*.orb.local, and each run uses a throwaway namespace that is deleted afterwards:

```bash
composer test

# Opt-in live suite against a local OrbStack cluster
K8S_INTEGRATION=1 composer test-integration
```

| Variable | Default | Purpose |
| --- | --- | --- |
| `K8S_INTEGRATION` | unset | Set to 1 (or true) to enable the suite; without it every integration test skips. |
| `K8S_INTEGRATION_CONTEXT` | orbstack | The kube context the suite takes its credentials from. It must be orbstack. |
| `K8S_INTEGRATION_NAMESPACE` | random k8s-it-… | Overrides the throwaway namespace the suite creates and cleans up. |

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/kubernetes-api-for-laravel.md)
