Skip to main content

testing

Current state​

7 test files, ~35 tests total, ~760 lines of test code. All are unit tests for pure-logic functions (manifest generation, registry CRUD, type marshaling, plugin resolution). No integration tests, no HTTP handler tests, no frontend tests, no tests that touch a real cluster or QEMU.

PackageTestsWhat they coverWhat's missing
kubevirt/14manifest generation, VM-list parsing, helpersVM CRUD (create/start/stop/delete), vol/snapshot/clone ops, cloud-init injection, SSH
registry/6file-based CRUDconcurrent access, corruption recovery
qemu/4basic opsall QEMU lifecycle (start/stop/SSH/logs), service template generation
types/2JSON round-tripvalidation, edge cases
plugin/2resolve/installmarketplace fetch, actual download, remove
web/2server startupall 35+ HTTP handlers, WS bridges, bootc task tracking, error paths
cmd/5CLI parsingactual command execution, flag interactions, TUI
catalog/0β€”Find(), catalog integrity
config/0β€”YAML parsing, env-var fallback, auth-key masking
doctor/0β€”each diagnostic check, Fix()

Testing strategy: three layers​

Layer 1 β€” Unit tests (fast, offline, always run)​

Goal: catch logic bugs before they reach the cluster. Run in CI on every push. No external dependencies.

Approach: inject a CommandRunner interface everywhere code shells out to kubectl, virtctl, or systemctl. Today those calls are raw exec.Command scattered through the codebase. By introducing:

type Runner interface {
Run(name string, args ...string) ([]byte, error)
RunStream(name string, args ...string) error // for SSH, VNC, logs
}

…and a RealRunner (default) + FakeRunner (test), unit tests can simulate any cluster state without a real cluster.

What this unblocks:

Handler/functionFakeRunner scenarioTest count
ListVMskubectl returns 2 VMs, 1 running4 (empty, error, single, multi)
CreateVMkubectl apply succeeds3 (success, name-collision, namespace-creation)
StartVMvirtctl start succeeds2 (success, already-running)
ScaleVM is live-migratable β†’ hotplug; not β†’ offline3 (live-hotplug, offline, error)
Snapshotfeature gate present/absent2 (create, missing-gate)
handleImagescatalog.Find hits/misses2
handleImportDataVolumeCDI present/missing2
handleCreateVMall 6 source types, bootc task, error paths~12
handleExportVM running (reject) / stopped (stream)2
Doctor checkseach diagnostic~10
Plugin installdownload succeeds/fails2

Implementation order:

  1. Introduce Runner interface + RealRunner in a new pkg/shell/ package
  2. Thread Runner through kubevirt.Client, qemu, and web.Server
  3. Add FakeRunner with scriptable responses
  4. Write handler tests for the 5 highest-value handlers
  5. Backfill for remaining handlers

Cost: moderate refactor (~200 lines of plumbing). High payoff β€” unlocks ~80 unit tests that today can't exist.

Layer 2 β€” Integration tests (needs cluster, opt-in)​

Goal: verify that real kubectl/virtctl commands produce real VMs that actually boot, accept SSH, and respond to lifecycle operations.

Approach: Go build tag //go:build integration. Run only when a KubeVirt cluster is available (CI on a schedule or manual trigger, never on every push). Tests create real VMs in an isolated namespace and clean up.

//go:build integration

func TestIntegration_CreateBootDelete(t *testing.T) {
ns := "corral-test-" + randomSuffix()
client := kubevirt.NewClient(ns)
client.EnsureNamespace(ns)
t.Cleanup(func() { client.DeleteNamespace(ns) })

// Create from catalog
opts := types.CreateOpts{
Name: "test-vm", Namespace: ns,
ContainerDisk: "quay.io/containerdisks/fedora:42",
Mem: "2G", CPU: 1, Disk: "5Gi",
}
require.NoError(t, client.CreateVM(opts))
require.NoError(t, client.StartVM("test-vm"))

// Wait for ready
require.Eventually(t, func() bool {
vms, _ := client.ListVMs()
for _, v := range vms {
if v.Name == "test-vm" && v.Ready { return true }
}
return false
}, 3*time.Minute, 5*time.Second)

// SSH in and run a command
out, err := client.SSH("test-vm", "fedora", "", "hostname", "22", "")
require.NoError(t, err)
require.Contains(t, out, "test-vm")

client.DeleteVM("test-vm")
}

What to test at this layer:

ScenarioTimeRisk of flake
Catalog VM: create β†’ start β†’ SSH β†’ stop β†’ delete~5 minLow (containerdisk is reliable)
ISO import: create β†’ CDI import β†’ start β†’ verify boot~10 minMedium (CDI download)
Snapshot: create VM β†’ snapshot β†’ restore β†’ verify state~8 minLow
Scale: create β†’ hotplug CPU β†’ verify guest sees new CPUs~5 minMedium (needs guest agent)
QEMU: create β†’ start β†’ SSH β†’ stop β†’ delete~3 minLow (local, no network)
Web UI E2E: Playwright against corral web~3 minMedium (browser timing)

Cluster requirements: longhorn RWX, VolumeSnapshotClass, HotplugVolumes feature gate. The test suite probes capabilities and skips what isn't available.

Layer 3 β€” Frontend tests (browser, opt-in)​

Goal: catch JS regressions in the SPA (broken forms, missing DOM elements, API wiring errors).

Approach: Playwright with a single scenario file. The Go server starts on a random port, Playwright opens Chrome and exercises the UI.

// test/e2e/corral.spec.js
test('create dialog shows catalog images', async ({ page }) => {
await page.goto('http://localhost:PORT');
await page.click('#btn-create');
await page.selectOption('[name=sourceType]', 'catalog');
// Catalog dropdown should be populated
const options = await page.$$eval('[name=catalogImage] option', els =>
els.map(e => e.textContent));
expect(options.length).toBeGreaterThan(0);
expect(options.some(o => o.includes('fedora'))).toBe(true);
});

test('image library loads datavolumes', async ({ page }) => {
await page.goto('http://localhost:PORT');
await page.waitForSelector('#dc-images');
// Should show datavolumes or the "no images" placeholder
const text = await page.textContent('#dc-images');
expect(text).toBeTruthy();
});

What to test:

Page/componentChecks
Datacenter viewVM table renders, node cards show counts
Create dialogSource-type toggle shows/hides fields, catalog populates, submit sends correct body
Image libraryImport dialog flow, delete confirmation
VM detail β€” SummaryStatus, CPU, memory, IP, actions available
VM detail β€” HardwareScale form, add-volume form, expand dialog
VM detail β€” SnapshotsList, create, delete
Tree sidebarNode β†’ VM navigation, drawer toggle (mobile)

Prerequisites: the Playwright test server needs a FakeRunner backing the API, or a static cluster snapshot. Using the fake runner (Layer 1) avoids needing a real cluster for frontend tests.


Implementation roadmap​

Phase 1 β€” Foundation (2-3 sessions)​

  1. Introduce pkg/shell/ β€” Runner interface + RealRunner (wraps os/exec) + FakeRunner (map of command β†’ canned output).
  2. Thread Runner through constructors β€” kubevirt.NewClient(ns, runner), web.Serve(addr, runner), qemu.NewManager(runner). Default to RealRunner so existing code paths don't change.
  3. Write FakeRunner β€” supports exact-match, prefix-match, and regex command matching. Records calls for assertion. Preload with realistic kubectl JSON output.

Phase 2 β€” Handler unit tests (2-3 sessions)​

  1. Tests for handleListVMs, handleCreateVM, handleVMAction, handleDeleteVM (the hot path).
  2. Tests for handleImages, handleListDataVolumes, handleImportDataVolume, handleDeleteDataVolume.
  3. Tests for handleScale, handleAddVolume, handleExpand, handleSnapshot*, handleClone.
  4. Tests for handleDoctor, handlePlugins, error paths (404, 503, 500).

Target: 60-80 handler tests, covering every HTTP route with at least one happy-path and one error-path case.

Phase 3 β€” Pure-logic unit tests (1 session)​

  1. catalog/catalog_test.go β€” Find, edge cases
  2. config/config_test.go β€” YAML parse, env fallback, missing file
  3. doctor/doctor_test.go β€” each check with FakeRunner

Phase 4 β€” Integration tests (2 sessions)​

  1. Write the integration test harness (namespace lifecycle, cleanup, skip logic for missing capabilities).
  2. Port client_test.go manifest-generation tests into integration (they already exist as unit tests β€” add real apply + verify).
  3. Catalog create β†’ SSH β†’ delete smoke test.
  4. Snapshot + restore test.
  5. QEMU create β†’ SSH β†’ delete test (local, needs KVM).

Phase 5 β€” Frontend tests (1-2 sessions)​

  1. Install Playwright, write a test/e2e/ directory with config.
  2. Write the test server (starts Go server with FakeRunner on random port).
  3. Write ~10 Playwright scenarios covering the critical UI paths.
  4. Wire into just test-e2e or npm test in the project root.

Phase 6 β€” CI (1 session)​

Update .github/workflows/ci.yml:

jobs:
unit:
runs-on: ubuntu-latest
steps:
- run: go test ./... -count=1 -short

integration:
runs-on: self-hosted # machine with kubectl context to the cluster
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
steps:
- run: go test -tags integration ./... -count=1 -timeout 30m

e2e:
runs-on: ubuntu-latest
steps:
- run: go build -o corral .
- run: npx playwright test

What NOT to test​

  • TUI rendering β€” Bubble Tea's rendering is a framework concern. Test the action dispatch logic, not terminal output.
  • kubectl/virtctl correctness β€” those are upstream tools. Test that we pass the right flags, not that they work.
  • Go net/http routing β€” the stdlib is tested. Test the handler logic, not the mux.
  • 100% coverage β€” the web UI has 25+ handlers; 80% coverage of the hot paths (create, list, action, delete, images, scale, snapshots) is the target. Doctor, plugins, NADs, NICs are lower priority.
  • Bootc plugin binary β€” test bootc through the kubevirt package with a real cluster in integration; the separate binary is a thin Cobra wrapper.

Test utilities to build​

pkg/shell/fake.go​

type FakeRunner struct {
Responses map[string]FakeResponse // command β†’ canned output
Calls []Call // record of every invocation
}

type FakeResponse struct {
Stdout string
Stderr string
Err error
}

pkg/web/testutil.go​

// NewTestServer returns a running server with a FakeRunner for handler tests.
func NewTestServer(t *testing.T) (*httptest.Server, *FakeRunner) { ... }

// Fixture helpers
func fakeVMList() []byte // realistic kubectl get vms -A -o json
func fakeVMIList() []byte // running VMIs with IPs
func fakeNodeList() []byte // 2 nodes, 1 ready

test/e2e/helpers.js​

// Start the test server, navigate, provide common selectors
async function setup(page) { ... }
async function createVM(page, name, sourceType, source) { ... }
async function deleteVM(page, name) { ... }

Success criteria​

MetricTarget
Unit test count100+ (up from ~35)
Handler coverage100% of routes have β‰₯1 test
Integration smoke testcreate β†’ SSH β†’ delete passes reliably
CI gatego test ./... runs in under 30s on PR
Frontend regressioncritical paths covered by Playwright
Flake rateunder 5% for integration tests (skip on capability mismatch)

Browser e2e: two tiers (2026-06-12)​

The Playwright suite (e2e/corral.spec.js) drives the real web UI against a real cluster, with multi-angle verification: every "the UI says it worked" is cross-checked from the cluster side (VMI phase, virt-launcher pod state, qemu container logs, guest serial output over the TTY websocket).

Safety: every resource is e2e--prefixed, deleted in afterEach (verified gone), and the cleanup guard refuses to touch unprefixed names β€” safe to run against production.

TierWhatWhere it runs
CI (default)wizard flows, catalog content, create for every source type, CDI import to Succeeded (PVC flow), scale on a stopped VM, one full boot lifecycle (cirros).github/workflows/e2e.yml: kind + KubeVirt useEmulation + CDI on every push/PR β€” npx playwright test --grep-invert "@live-only"
@live-onlyconsole websockets (VNC RFB handshake, serial output) + fullscreen/scaling controls, SSH login with an injected key, RDP probe, snapshots (longhorn), bootc build β†’ boot β†’ serial check, boot-from-importmanually against the real cluster: corral web + cd e2e && npx playwright test

Knobs: CORRAL_URL (default http://localhost:8006), CORRAL_NS (default tailvm), E2E_CONTAINERDISK (lifecycle boot image; CI uses the cirros demo containerdisk because fedora won't boot in sensible time under TCG).

Update (2026-06-21)​

  • Path-targeted CI. Both workflows now run only when the diff touches their area: the fast Go gate on **.go/go.mod; the e2e suite on Go / pkg/web/static / e2e / deploy. Docs/marketplace-only PRs skip both; main pushes stay fully validated.
  • Real external tools run in CI. The fast gate installs qemu-utils + rclone so TestConvertRawToQcow2_Real (qcow2 export) and the backup plugin's rclone copyto round-trip run for real instead of skipping.
  • Non-live e2e widened to the new SPA flows: multi-select bulk bar, the export format picker, tag chips + filter, /api/whoami, and read-only gating (via a page.route mock β€” no Tailscale headers needed).
  • @live-only adds live migration between nodes and the bootc rebuild SSH-survives---wipe round-trip.