-
Notifications
You must be signed in to change notification settings - Fork 198
Write unit tests for horizontal scaling operator behaviors (THV-0047) #4220
Description
Description
Write unit tests covering all new operator behaviors introduced by TASK-004, TASK-005, TASK-006, and TASK-007: spec-driven replica setting (nil-passthrough and explicit values), stdio transport hard cap, session storage warning conditions, Redis env var injection via SecretKeyRef, backendReplicas passthrough in RunConfig, and terminationGracePeriodSeconds presence on generated Deployments. The existing mcpserver_replicas_test.go covers externally-driven (HPA) replica preservation; new test cases here cover the distinct spec-driven code paths added by the upstream tasks.
Context
Epic THV-0047 adds horizontal scaling support to the ToolHive operator by extending MCPServer and VirtualMCPServer CRDs with replica counts, session storage configuration, and Redis injection. The upstream tasks (TASK-004 through TASK-007) implement the operator-side changes but delegate formal test coverage to this task. Without these tests, the new code paths — nil-passthrough replicas, stdio cap enforcement, session storage warning conditions, Redis env var injection, and RunConfig population — have no automated verification. This task closes that gap.
Dependencies: #280 (TASK-004 — MCPServer reconciler/deployment builder), #281 (TASK-005 — MCPServer RunConfig Redis/BackendReplicas), #278 (TASK-006 — VirtualMCPServer deployment builder), #277 (TASK-007 — VirtualMCPServer vMCP ConfigMap Redis injection)
Blocks: None (this is the final task in the DAG)
Acceptance Criteria
-
mcpserver_replicas_test.gois extended with spec-driven replica test cases:spec.replicas == nilyieldsDeployment.Spec.Replicas == nil;spec.replicas = 1yields*Deployment.Spec.Replicas == 1;spec.replicas = 3with transportsseyields*Deployment.Spec.Replicas == 3 - A test for the stdio transport hard cap verifies that the reconciler produces a Deployment with
*Spec.Replicas == 1and sets aStdioReplicaCappedstatus condition whenspec.transport == "stdio"andspec.replicas = 3 - A test verifies the session storage warning condition (
SessionStorageWarning) is set with statusTruewhenspec.replicas = 2andspec.sessionStorageis nil - A test verifies the session storage warning condition is set with status
Truewhenspec.replicas = 2andspec.sessionStorage.provider == "memory" - A test verifies the session storage warning condition is set with status
Falsewhenspec.replicas = 2andspec.sessionStorage.provider == "redis"(condition cleared) - Tests for
createRunConfigFromMCPSerververify:backendReplicas = nilyields nilRunConfig.BackendReplicas;backendReplicas = 3yieldsRunConfig.BackendReplicaspointing toint32(3);sessionStorage.provider == "redis"populatesRunConfig.SessionRediswith correct address, db, and keyPrefix;sessionStorage.provider == "memory"yields nilRunConfig.SessionRedis;passwordRefis NOT present anywhere in the serialized RunConfig - Tests for
deploymentForMCPServer(proxyrunner pod) verify:THV_SESSION_REDIS_PASSWORDenv var is injected with correctValueFrom.SecretKeyReffields whensessionStorage.provider == "redis"andpasswordRefis set; noTHV_SESSION_REDIS_PASSWORDenv var appears whensessionStorageis nil - Tests for
deploymentForVirtualMCPSerververify:THV_SESSION_REDIS_PASSWORDenv var is injected correctly whensessionStorage.provider == "redis"andpasswordRefis set; noTHV_SESSION_REDIS_PASSWORDenv var whensessionStorageis nil orprovider != "redis"orpasswordRefis nil - Tests for
deploymentForMCPServeranddeploymentForVirtualMCPSerververify thatSpec.Template.Spec.TerminationGracePeriodSecondsis non-nil and set to the expected constant value (e.g., 30 seconds) in all cases - Tests for
deploymentForVirtualMCPSerververify the nil-passthrough replica behavior:spec.replicas == nilyieldsSpec.Replicas == nil;spec.replicas = 1yields*Spec.Replicas == 1;spec.replicas = 3yields*Spec.Replicas == 3 - Tests for
ensureVmcpConfigConfigMap(or the converter) verify: Redisaddress,db, andkeyPrefixappear in the marshaledconfig.yamlwhenprovider == "redis"; theconfig.yamlomitssessionStorageentirely whenprovider == "memory"orspec.sessionStorage == nil; no password field appears inconfig.yaml - All pre-existing tests in
mcpserver_replicas_test.gocontinue to pass — the new spec-driven tests do not interfere with the HPA-scenario tests -
go test ./cmd/thv-operator/... ./pkg/runner/... ./pkg/vmcp/config/...passes without failures - Code reviewed and approved
Technical Approach
Recommended Implementation
The new tests span four existing test files (and may introduce one or two new files if the scope warrants it). The primary organization is:
1. Extend mcpserver_replicas_test.go with spec-driven replica cases
The existing TestReplicaBehavior table-driven test covers externally-driven scaling (HPA scenario: the deployment already has N replicas, the reconciler runs). The new tests cover the complementary path: the MCPServer spec itself sets Replicas, and the deployment builder/reconciler must respect it. Since these are fundamentally different code paths (not just different table rows), add them as new top-level test functions rather than extending the existing table:
func TestSpecDrivenReplicasNil(t *testing.T) { ... }
func TestSpecDrivenReplicasExplicit(t *testing.T) { ... } // table with 1 and 3
func TestSpecDrivenStdioCapAt1(t *testing.T) { ... }
func TestSessionStorageWarningCondition(t *testing.T) { ... } // table: nil, memory, redis
func TestTerminationGracePeriodMCPServer(t *testing.T) { ... }The setup pattern mirrors TestDefaultCreationHasOneReplica: create an MCPServer with the desired spec (including new Replicas and SessionStorage fields), create a fake client with fake.NewClientBuilder(), instantiate newTestMCPServerReconciler, call reconciler.Reconcile, fetch the updated Deployment, and assert on Spec.Replicas, Spec.Template.Spec.TerminationGracePeriodSeconds, and the MCPServer's Status.Conditions.
For condition assertions, fetch the updated MCPServer after reconcile and use apimeta.FindStatusCondition to check the condition type, status, and reason.
2. Extend mcpserver_runconfig_test.go with BackendReplicas and Redis cases
Add new test cases to the existing TestCreateRunConfigFromMCPServer table (or add a new table function TestRunConfigScalingFields). The createTestMCPServerWithConfig helper at line 45 may need to be augmented with a new builder helper that accepts BackendReplicas *int32 and SessionStorage *mcpv1alpha1.SessionStorageConfig. Test serialization by calling json.Marshal on RunConfig and asserting the presence/absence of backend_replicas and session_redis keys.
3. Extend virtualmcpserver_deployment_test.go with replica, termination grace, and Redis env var cases
The existing TestDeploymentForVirtualMCPServer at line 37 already asserts *Deployment.Spec.Replicas == 1. After TASK-006, this assertion will need updating (nil-passthrough means nil when spec.replicas == nil). Add additional test cases covering:
spec.replicas = nil→Spec.Replicas == nilspec.replicas = 3→*Spec.Replicas == 3TerminationGracePeriodSecondsis set in all casesbuildRedisPasswordEnvVarreturns the correctcorev1.EnvVarwhenprovider == "redis"andpasswordRefis set, and returns empty/nil for all other combinations
For the buildRedisPasswordEnvVar helper tests, call the function directly (it is a method on *VirtualMCPServerReconciler) with a minimal VirtualMCPServer struct containing only the relevant SessionStorage field.
4. Extend virtualmcpserver_vmcpconfig_test.go with Redis session storage cases
Add test cases to TestCreateVmcpConfigFromVirtualMCPServer (or a new TestVmcpConfigRedisInjection table) that:
- Set
vmcp.Spec.SessionStoragewithprovider == "redis",address,db, andkeyPrefix - Marshal the resulting config to YAML and assert on the presence of
sessionStorage.address,sessionStorage.db,sessionStorage.keyPrefix - Confirm no password field appears under
sessionStorage - Set
provider == "memory"and confirmsessionStorageis absent from the YAML
The existing gopkg.in/yaml.v3 import in virtualmcpserver_vmcpconfig_test.go is already available for YAML assertions.
Patterns & Frameworks
- Table-driven tests with
t.Parallel(): All new tests must uset.Parallel()andt.Run(tt.name, func(...) { t.Parallel() ... })following the existingTestReplicaBehaviorpattern. Usegithub.com/stretchr/testify/assertandgithub.com/stretchr/testify/requirefor assertions. fake.NewClientBuilder()pattern: Construct a fake Kubernetes client withfake.NewClientBuilder().WithScheme(testScheme).WithObjects(...).WithStatusSubresource(...).Build(). The scheme must include bothcorev1andmcpv1alpha1.newTestMCPServerReconcilerhelper: Use this to construct a properly initializedMCPServerReconcilerwith a mock platform detector andImageValidationAlwaysAllow.apimeta.FindStatusCondition: Usek8s.io/apimachinery/pkg/api/meta.FindStatusCondition(&status.Conditions, conditionType)to retrieve and assert on status conditions after reconcile.- Direct function call for builder tests: For
deploymentForMCPServer,deploymentForVirtualMCPServer, andbuildRedisPasswordEnvVar, call the functions directly rather than going through a full reconcile loop when testing only the deployment shape. This reduces setup overhead and makes assertions more targeted. - JSON marshaling for RunConfig assertions: Use
json.Marshal(runConfig)andjson.Unmarshalinto amap[string]interface{}to assert on the presence or absence of fields without depending on exact struct shapes.
Code Pointers
cmd/thv-operator/controllers/mcpserver_replicas_test.go— Primary file to extend; containsTestReplicaBehavior,TestConfigUpdatePreservesReplicas, andTestDefaultCreationHasOneReplicaas reference patterns. Note:TestDefaultCreationHasOneReplicaat line 459 asserts*deployment.Spec.Replicas == 1for a server withoutspec.replicasset; after TASK-004, this may need updating tonil(nil-passthrough). Verify with the TASK-004 implementer.cmd/thv-operator/controllers/mcpserver_runconfig_test.go— ContainscreateTestMCPServerWithConfighelper at line 45 andTestCreateRunConfigFromMCPServertable at line 61; extend with Redis and BackendReplicas cases.cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go— ContainsTestDeploymentForVirtualMCPServerat line 37 with existing replica assertion at line 64 (*deployment.Spec.Replicas == 1); this assertion will need updating after TASK-006 changes the default to nil whenspec.replicasis nil.cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go— ContainsTestCreateVmcpConfigFromVirtualMCPServertable andnewTestConverterhelper; extend for Redis session storage fields.cmd/thv-operator/controllers/mcpserver_test_helpers_test.go—newTestMCPServerReconcilerhelper at line 30; use this to construct the reconciler in all MCPServer tests.cmd/thv-operator/api/v1alpha1/mcpserver_types.go— Source ofConditionStdioReplicaCapped,ConditionSessionStorageWarning, and associated reason constants added by TASK-004; import these rather than using string literals in tests.cmd/thv-operator/controllers/virtualmcpserver_deployment.golines 289–329 —buildOIDCEnvVars— reference for theSecretKeyRefenv var pattern thatbuildRedisPasswordEnvVar(added by TASK-006) follows; read to understand the expectedcorev1.EnvVarshape to assert against.pkg/runner/config.go—RunConfigstruct withBackendReplicasandSessionRedisfields added by TASK-005; importrunner.SessionRedisConfigfor test assertions.pkg/vmcp/config/config.go—Configstruct withSessionStorage *SessionStorageConfigfield added by TASK-007; importvmcpconfig.SessionStorageConfigfor converter test assertions.
Component Interfaces
Key types needed for test assertions (all defined by upstream tasks):
// From cmd/thv-operator/api/v1alpha1/mcpserver_types.go (added by TASK-004)
const ConditionStdioReplicaCapped = "StdioReplicaCapped"
const ConditionSessionStorageWarning = "SessionStorageWarning"
const ConditionReasonStdioReplicaCapped = "StdioTransportCapAt1"
const ConditionReasonSessionStorageMissing = "SessionStorageMissingForReplicas"
const ConditionReasonSessionStorageConfigured = "SessionStorageConfigured"
// From pkg/runner/config.go (added by TASK-005)
type SessionRedisConfig struct {
Address string `json:"address,omitempty" yaml:"address,omitempty"`
DB int32 `json:"db,omitempty" yaml:"db,omitempty"`
KeyPrefix string `json:"key_prefix,omitempty" yaml:"key_prefix,omitempty"`
}
// runner.RunConfig also gains:
// BackendReplicas *int32
// SessionRedis *SessionRedisConfig
// From pkg/vmcp/config/config.go (added by TASK-007)
type SessionStorageConfig struct {
Address string `json:"address,omitempty" yaml:"address,omitempty"`
DB int32 `json:"db,omitempty" yaml:"db,omitempty"`
KeyPrefix string `json:"keyPrefix,omitempty" yaml:"keyPrefix,omitempty"`
}
// vmcpconfig.Config also gains:
// SessionStorage *SessionStorageConfigIllustrative test structure for spec-driven replica nil-passthrough (extend mcpserver_replicas_test.go):
func TestSpecDrivenReplicasNil(t *testing.T) {
t.Parallel()
name, namespace := "spec-nil-replica", testNamespaceDefault
mcpServer := &mcpv1alpha1.MCPServer{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
Spec: mcpv1alpha1.MCPServerSpec{
Image: "test-image:latest", Transport: "sse", ProxyPort: 8080,
Replicas: nil, // explicit nil — hands-off mode
},
}
// ... fake client setup, reconcile, fetch deployment
assert.Nil(t, deployment.Spec.Replicas, "spec.replicas == nil must yield Deployment.Spec.Replicas == nil")
}Illustrative test for stdio cap with condition assertion:
func TestSpecDrivenStdioCapAt1(t *testing.T) {
t.Parallel()
replicas := int32(3)
mcpServer := &mcpv1alpha1.MCPServer{
// ...
Spec: mcpv1alpha1.MCPServerSpec{
Transport: "stdio", Replicas: &replicas, // ...
},
}
// ... reconcile
// Assert deployment has 1 replica
assert.Equal(t, int32(1), *deployment.Spec.Replicas)
// Assert condition is set
condition := apimeta.FindStatusCondition(updatedMCPServer.Status.Conditions,
mcpv1alpha1.ConditionStdioReplicaCapped)
require.NotNil(t, condition)
assert.Equal(t, metav1.ConditionTrue, condition.Status)
assert.Equal(t, mcpv1alpha1.ConditionReasonStdioReplicaCapped, condition.Reason)
}Illustrative test for Redis env var injection in VirtualMCPServer deployment:
func TestBuildRedisPasswordEnvVar(t *testing.T) {
t.Parallel()
tests := []struct {
name string
sessionStorage *mcpv1alpha1.SessionStorageConfig
wantEnvVars int
wantEnvName string
wantSecretName string
wantSecretKey string
}{
{
name: "redis with passwordRef injects env var",
sessionStorage: &mcpv1alpha1.SessionStorageConfig{
Provider: "redis",
PasswordRef: &mcpv1alpha1.SecretKeyRef{Name: "redis-secret", Key: "password"},
},
wantEnvVars: 1, wantEnvName: "THV_SESSION_REDIS_PASSWORD",
wantSecretName: "redis-secret", wantSecretKey: "password",
},
{name: "nil sessionStorage returns no env vars", sessionStorage: nil, wantEnvVars: 0},
{name: "memory provider returns no env vars",
sessionStorage: &mcpv1alpha1.SessionStorageConfig{Provider: "memory"}, wantEnvVars: 0},
{name: "redis without passwordRef returns no env vars",
sessionStorage: &mcpv1alpha1.SessionStorageConfig{Provider: "redis"}, wantEnvVars: 0},
}
// ...
}Testing Strategy
Unit Tests
MCPServer reconciler / deployment builder (extend mcpserver_replicas_test.go):
-
TestSpecDrivenReplicasNil—spec.replicas == nilproduces a Deployment withSpec.Replicas == nil(nil-passthrough) -
TestSpecDrivenReplicas1—spec.replicas = 1produces*Spec.Replicas == 1 -
TestSpecDrivenReplicas3SseTransport—spec.replicas = 3, transportsseproduces*Spec.Replicas == 3 -
TestSpecDrivenStdioCapAt1—spec.transport = "stdio",spec.replicas = 3produces a Deployment with*Spec.Replicas == 1and setsStdioReplicaCappedcondition with statusTrueand reasonStdioTransportCapAt1 -
TestSessionStorageWarningConditionNilStorage—spec.replicas = 2,spec.sessionStorage = nilsetsSessionStorageWarningcondition with statusTrueand reasonSessionStorageMissingForReplicas -
TestSessionStorageWarningConditionMemory—spec.replicas = 2,spec.sessionStorage.provider = "memory"setsSessionStorageWarningwith statusTrue -
TestSessionStorageWarningConditionRedis—spec.replicas = 2,spec.sessionStorage.provider = "redis"setsSessionStorageWarningwith statusFalseand reasonSessionStorageConfigured -
TestTerminationGracePeriodMCPServer—deploymentForMCPServeroutput always hasSpec.Template.Spec.TerminationGracePeriodSecondsequal to the expected constant
MCPServer RunConfig (extend mcpserver_runconfig_test.go):
-
backendReplicas = nil→RunConfig.BackendReplicas == nil -
backendReplicas = 3→*RunConfig.BackendReplicas == 3 -
sessionStorage.provider = "memory"→RunConfig.SessionRedis == nil -
sessionStorage = nil→RunConfig.SessionRedis == nil -
sessionStorage.provider = "redis", address"redis.svc:6379", db2, keyPrefix"thv:"→RunConfig.SessionRedisequals{Address: "redis.svc:6379", DB: 2, KeyPrefix: "thv:"} - Serialized RunConfig JSON omits
backend_replicaswhen nil; includes it when set - Serialized RunConfig JSON does not contain any password or credential when
passwordRefis set in the CRD spec
VirtualMCPServer deployment builder (extend virtualmcpserver_deployment_test.go):
-
spec.replicas = nil→Spec.Replicas == nil -
spec.replicas = 1→*Spec.Replicas == 1 -
spec.replicas = 3→*Spec.Replicas == 3 -
TerminationGracePeriodSecondsis set to the expected constant in all replica variants -
buildRedisPasswordEnvVarwithprovider = "redis"andpasswordRef.name = "redis-secret",passwordRef.key = "password"returns onecorev1.EnvVarnamedTHV_SESSION_REDIS_PASSWORDwith correctValueFrom.SecretKeyRef -
buildRedisPasswordEnvVarwith nilsessionStoragereturns no env vars -
buildRedisPasswordEnvVarwithprovider = "memory"returns no env vars -
buildRedisPasswordEnvVarwithprovider = "redis"and nilpasswordRefreturns no env vars
VirtualMCPServer vMCP ConfigMap (extend virtualmcpserver_vmcpconfig_test.go):
-
provider = "redis"with all fields set → marshaledconfig.yamlcontainssessionStorage.address,sessionStorage.db,sessionStorage.keyPrefix -
provider = "redis"→ marshaledconfig.yamldoes NOT contain any password field -
provider = "memory"→config.yamlomitssessionStorageentirely -
spec.sessionStorage = nil→config.yamlomitssessionStorageentirely
Integration Tests
- Not in scope for this task; these unit tests provide sufficient automated coverage for the new operator behaviors
Edge Cases
-
spec.replicas = 0— Deployment has*Spec.Replicas == 0(scale-to-zero is valid); verify the stdio cap logic does not interfere with zero-replica requests for stdio transport -
backendReplicas = 0—RunConfig.BackendReplicasis a pointer toint32(0), not nil; verifyjson.Marshalincludes the field (omitempty only applies to nil pointers, not zero-value pointed-to integers) -
sessionStorage.db = 0— Verify the zero-value DB field is correctly serialized in both RunConfig and vMCP config YAML (omitempty onint32will omit zero; if vMCP requires explicitdb: 0, the YAML tag may need to beyaml:"db"without omitempty — coordinate with TASK-007 implementer on the chosen behavior) - Nil-passthrough after initial set: updating MCPServer from
spec.replicas = 2tospec.replicas = nildoes not setDeployment.Spec.Replicasto any value (verifydeploymentNeedsUpdatepreserves this invariant) - Pre-existing
TestDefaultCreationHasOneReplicamay need updating if TASK-004 changes the default behavior forspec.replicas = nilfrom1tonil; coordinate with the TASK-004 implementer before marking that test as passing
Out of Scope
- Application-level session storage implementation inside proxyrunner or vMCP — separate epics
- Integration tests with a real Kubernetes API server or live Redis instance
- HPA integration tests
- Redis deployment, provisioning, or lifecycle
- CLI, UI, or any changes outside
cmd/thv-operator/andpkg/runner/andpkg/vmcp/config/ - New feature implementation — this task writes tests only; all implementation is in TASK-004 through TASK-007
References
- Epic THV-0047: Horizontal Scaling - CRD and Operator Changes — https://github.com/stacklok/stacklok-epics/issues/264
- Upstream TASK-004: MCPServer reconciler and deployment builder updates — https://github.com/stacklok/stacklok-epics/issues/280
- Upstream TASK-005: MCPServer RunConfig Redis and BackendReplicas injection — https://github.com/stacklok/stacklok-epics/issues/281
- Upstream TASK-006: VirtualMCPServer deployment builder updates — https://github.com/stacklok/stacklok-epics/issues/278
- Upstream TASK-007: VirtualMCPServer vMCP ConfigMap Redis injection — https://github.com/stacklok/stacklok-epics/issues/277
- RFC THV-0047: Manual Horizontal Scaling for vMCP and Proxy Runner — RFC: Horizontal Scaling for vMCP and Proxy Runner toolhive-rfcs#47
- Existing HPA-scenario replica tests:
cmd/thv-operator/controllers/mcpserver_replicas_test.go - Existing RunConfig tests:
cmd/thv-operator/controllers/mcpserver_runconfig_test.go - Existing VirtualMCPServer deployment tests:
cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go - Existing vMCP ConfigMap tests:
cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go