NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

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:

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. Each cluster name has its own store; ad-hoc clusters share the default cluster’s.

Set-up

Set-upEffect
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').
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:

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-variantExamplePasses 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.
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.

$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:

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().

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.
  • 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:

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:

$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:

composer test

# Opt-in live suite against a local OrbStack cluster
K8S_INTEGRATION=1 composer test-integration
VariableDefaultPurpose
K8S_INTEGRATIONunsetSet to 1 (or true) to enable the suite; without it every integration test skips.
K8S_INTEGRATION_CONTEXTorbstackThe kube context the suite takes its credentials from. It must be orbstack.
K8S_INTEGRATION_NAMESPACErandom 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

By donating, you agree to our donation terms.

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.