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.
| Package | Tests | What they cover | What's missing |
|---|---|---|---|
kubevirt/ | 14 | manifest generation, VM-list parsing, helpers | VM CRUD (create/start/stop/delete), vol/snapshot/clone ops, cloud-init injection, SSH |
registry/ | 6 | file-based CRUD | concurrent access, corruption recovery |
qemu/ | 4 | basic ops | all QEMU lifecycle (start/stop/SSH/logs), service template generation |
types/ | 2 | JSON round-trip | validation, edge cases |
plugin/ | 2 | resolve/install | marketplace fetch, actual download, remove |
web/ | 2 | server startup | all 35+ HTTP handlers, WS bridges, bootc task tracking, error paths |
cmd/ | 5 | CLI parsing | actual 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/function | FakeRunner scenario | Test count |
|---|---|---|
ListVMs | kubectl returns 2 VMs, 1 running | 4 (empty, error, single, multi) |
CreateVM | kubectl apply succeeds | 3 (success, name-collision, namespace-creation) |
StartVM | virtctl start succeeds | 2 (success, already-running) |
Scale | VM is live-migratable β hotplug; not β offline | 3 (live-hotplug, offline, error) |
Snapshot | feature gate present/absent | 2 (create, missing-gate) |
handleImages | catalog.Find hits/misses | 2 |
handleImportDataVolume | CDI present/missing | 2 |
handleCreateVM | all 6 source types, bootc task, error paths | ~12 |
handleExport | VM running (reject) / stopped (stream) | 2 |
| Doctor checks | each diagnostic | ~10 |
| Plugin install | download succeeds/fails | 2 |
Implementation order:
- Introduce
Runnerinterface +RealRunnerin a newpkg/shell/package - Thread
Runnerthroughkubevirt.Client,qemu, andweb.Server - Add
FakeRunnerwith scriptable responses - Write handler tests for the 5 highest-value handlers
- 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:
| Scenario | Time | Risk of flake |
|---|---|---|
| Catalog VM: create β start β SSH β stop β delete | ~5 min | Low (containerdisk is reliable) |
| ISO import: create β CDI import β start β verify boot | ~10 min | Medium (CDI download) |
| Snapshot: create VM β snapshot β restore β verify state | ~8 min | Low |
| Scale: create β hotplug CPU β verify guest sees new CPUs | ~5 min | Medium (needs guest agent) |
| QEMU: create β start β SSH β stop β delete | ~3 min | Low (local, no network) |
| Web UI E2E: Playwright against corral web | ~3 min | Medium (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/component | Checks |
|---|---|
| Datacenter view | VM table renders, node cards show counts |
| Create dialog | Source-type toggle shows/hides fields, catalog populates, submit sends correct body |
| Image library | Import dialog flow, delete confirmation |
| VM detail β Summary | Status, CPU, memory, IP, actions available |
| VM detail β Hardware | Scale form, add-volume form, expand dialog |
| VM detail β Snapshots | List, create, delete |
| Tree sidebar | Node β 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)β
- Introduce
pkg/shell/βRunnerinterface +RealRunner(wrapsos/exec) +FakeRunner(map of command β canned output). - Thread
Runnerthrough constructors βkubevirt.NewClient(ns, runner),web.Serve(addr, runner),qemu.NewManager(runner). Default toRealRunnerso existing code paths don't change. - 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)β
- Tests for
handleListVMs,handleCreateVM,handleVMAction,handleDeleteVM(the hot path). - Tests for
handleImages,handleListDataVolumes,handleImportDataVolume,handleDeleteDataVolume. - Tests for
handleScale,handleAddVolume,handleExpand,handleSnapshot*,handleClone. - 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)β
catalog/catalog_test.goβ Find, edge casesconfig/config_test.goβ YAML parse, env fallback, missing filedoctor/doctor_test.goβ each check with FakeRunner
Phase 4 β Integration tests (2 sessions)β
- Write the integration test harness (namespace lifecycle, cleanup, skip logic for missing capabilities).
- Port
client_test.gomanifest-generation tests into integration (they already exist as unit tests β add real apply + verify). - Catalog create β SSH β delete smoke test.
- Snapshot + restore test.
- QEMU create β SSH β delete test (local, needs KVM).
Phase 5 β Frontend tests (1-2 sessions)β
- Install Playwright, write a
test/e2e/directory with config. - Write the test server (starts Go server with FakeRunner on random port).
- Write ~10 Playwright scenarios covering the critical UI paths.
- Wire into
just test-e2eornpm testin 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/httprouting β 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β
| Metric | Target |
|---|---|
| Unit test count | 100+ (up from ~35) |
| Handler coverage | 100% of routes have β₯1 test |
| Integration smoke test | create β SSH β delete passes reliably |
| CI gate | go test ./... runs in under 30s on PR |
| Frontend regression | critical paths covered by Playwright |
| Flake rate | under 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.
| Tier | What | Where 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-only | console 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-import | manually 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;mainpushes stay fully validated. - Real external tools run in CI. The fast gate installs
qemu-utils+rclonesoTestConvertRawToQcow2_Real(qcow2 export) and the backup plugin'srclone copytoround-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 apage.routemock β no Tailscale headers needed). @live-onlyadds live migration between nodes and the bootc rebuild SSH-survives---wiperound-trip.