MaaS Testing and Contribution Guide
This guide covers what tests exist in the MaaS project, how to run them, and how to add new tests when contributing a feature or fix.
Test Overview
| Type | Location | Language | Runner |
|---|---|---|---|
| Unit tests | maas-api/internal/, maas-api/cmd/ |
Go | make test |
| Unit tests | maas-controller/pkg/ |
Go | make test |
| E2E tests | test/e2e/tests/ |
Python (pytest) | run-tests-quick.sh, smoke.sh |
| CI smoke / E2E | test/e2e/scripts/prow_run_smoke_test.sh |
Bash + pytest | Konflux integration |
| Integration tests | test/integration/postgres.sh |
Bash | See Integration Tests (PostgreSQL) |
| Integration tests | opendatahub-tests | Python (pytest) | ODH CI / Nightly |
Repository Structure (Testing)
maas-api/
├── internal/**/*_test.go # Unit tests for API handlers, services, auth
├── cmd/*_test.go # Unit tests for CLI/server setup
└── test/fixtures/ # Shared Go test helpers (fakes, test data)
maas-controller/
└── pkg/**/*_test.go # Unit tests for CRD reconcilers
test/e2e/
├── tests/ # Pytest E2E test modules
│ ├── conftest.py # Shared session-scoped fixtures
│ └── test_*.py # Test modules (see table below)
├── fixtures/ # Kustomize overlays for test CRs
├── scripts/prow_run_smoke_test.sh # Full deploy + E2E (CI entrypoint)
├── smoke.sh # Run all E2E modules locally
├── run-tests-quick.sh # Quick pytest run (no deploy)
└── requirements.txt # Python dependencies
E2E Test Modules and Groups
Tests are organized into xdist groups for parallel execution. Each group runs on a dedicated pytest-xdist worker. See Parallel E2E for details on how groups work.
| Group | Module | What it covers |
|---|---|---|
readonly |
test_smoke.py |
Health checks, catalog shape, basic inference |
readonly |
test_config_tenant.py |
Read-only Config CR validation |
readonly |
test_tenant.py |
Read-only MaasTenantConfig validation |
readonly |
test_tenant_discovery.py |
Read-only /v1/tenants endpoint checks |
readonly |
test_networkpolicy.py |
NetworkPolicy + connectivity checks |
api_keys |
test_api_keys.py |
API key CRUD, admin authorization, validation |
api_keys |
test_subscription.py |
Subscription enforcement, rate limiting, auth flows |
api_keys |
test_subscription_list_endpoints.py |
Subscription listing endpoints |
api_keys |
test_x_api_key_auth.py |
X-API-Key header authentication (IPP ExternalModel) |
models |
test_models_endpoint.py |
/v1/models subscription-aware filtering |
models |
test_gateway_scoped_authpolicy.py |
Gateway AuthPolicy structure, lifecycle, enforcement gaps |
models |
test_model_identity_conflict.py |
MaaSModelRef model-identity collision detection |
security |
test_negative_security.py |
Header spoofing, expired keys, cross-model access |
security |
test_namespace_scoping.py |
Namespace isolation behavior |
mt_lifecycle |
test_aitenant_lifecycle.py |
AITenant create/migrate/delete |
mt_lifecycle |
test_multi_tenant_integration.py |
Full lifecycle, two-tenant coexistence |
mt_lifecycle |
test_multi_tenant_maas_api.py |
Per-tenant Deployment, Service, HTTPRoute |
mt_lifecycle |
test_tenant_namespace_discovery.py |
Namespace-label discovery, webhook validation |
mt_lifecycle |
test_tenant_discovery_isolation.py |
Per-tenant /v1/tenants isolation |
mt_lifecycle |
test_crd_watch_resilience.py |
Dynamic CRD watch registration (restarts controller) |
tenant_isolation |
test_tenant_auth_isolation.py |
Cross-tenant key rejection, OIDC scoping |
tenant_isolation |
test_tenant_model_inference.py |
Cross-gateway inference isolation |
tenant_isolation |
test_tenant_rate_limit_isolation.py |
Per-tenant rate limit independence |
tenant_isolation |
test_tenant_subscription_isolation.py |
Per-tenant subscription scoping |
tenant_isolation |
test_per_tenant_ipp_isolation.py |
Per-tenant IPP stacks, routing isolation |
external |
test_external_oidc.py |
External OIDC token flows (skipped unless EXTERNAL_OIDC=true) |
external |
test_external_models.py |
ExternalModel/ExternalProvider, egress, body routing |
Running Tests
Unit Tests (Go)
Both generate a coverage.html report in their respective directories.
Integration Tests (PostgreSQL)
Integration tests for maas-api verify JSONB storage, GIN index queries, and NULL handling against a real PostgreSQL database.
Integration tests skip protection:
Note: TestPostgres is compiled with the other unit tests. To avoid the postgresql integration tests running during unit testing with make test the following must be true for the postgresql tests to be included in make test.
- Build tag check (requires
-tags=integration) testing.Short()check (skipped in short mode)- Environment variable check (skips if
TEST_DATABASE_URLnot set)
E2E Tests (Python)
Prerequisites
OpenShift cluster with MaaS deployed, oc logged in as cluster-admin, Python 3.9+.
cd test/e2e
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
export GATEWAY_HOST="maas.apps.your-cluster.example.com"
export E2E_SKIP_TLS_VERIFY=true
pytest tests/ -v # all tests
pytest tests/test_subscription.py -v # one module
pytest tests/test_api_keys.py::TestAPIKeyCreation -v # one class
Key Environment Variables
The E2E framework auto-discovers most values from the cluster. These are the most common overrides:
| Variable | Description |
|---|---|
GATEWAY_HOST |
Gateway hostname (required unless MAAS_API_BASE_URL is set) |
MAAS_API_BASE_URL |
Full MaaS API URL (auto-derived if not set) |
TOKEN |
User bearer token (falls back to oc whoami -t) |
ADMIN_OC_TOKEN |
Admin token; admin tests skip if unset |
E2E_SKIP_TLS_VERIFY |
Set true to skip TLS verification |
MODEL_NAME |
Override model ID (defaults to first from catalog) |
EXTERNAL_OIDC |
Set true to enable external OIDC tests |
E2E_PARALLEL_WORKERS |
pytest-xdist worker count (default 7, one per group). Set to 1 for serial debugging. |
See test/e2e/tests/conftest.py and individual test module docstrings for the full set of supported variables.
Parallel E2E (pytest-xdist)
The E2E suite runs in two passes with E2E_PARALLEL_WORKERS=7 (default):
- Pass 1 (parallel):
-m "not serial"with--dist=loadgroup -n 7— tests distributed byxdist_groupmarker, one group per worker - Pass 2 (serial):
-m serial— tests that mutate shared cluster state (subscription delete/restore, controller/Kuadrant scaling)
Set E2E_PARALLEL_WORKERS=1 for a single serial pass (useful for debugging).
xdist Groups
Every test file must have a module-level xdist_group marker:
All tests in a file run on the same xdist worker, grouped with other files sharing the same group name. Tests without an xdist_group marker are scattered round-robin across workers, which can cause resource conflicts.
Group Assignment Rules
When adding a new test file, assign it to a group using these criteria (in priority order):
-
Mutation scope: Tests that create, modify, or delete the same CRs (MaaSAuthPolicy, MaaSSubscription, MaaSModelRef, etc.) must be in the same group. This prevents two workers from concurrently mutating the same resource.
-
Functional domain: Group tests by what they exercise. API key tests go with
api_keys, gateway AuthPolicy tests go withmodels, multi-tenant lifecycle tests go withmt_lifecycle, etc. -
Read-only tests go in
readonly: Tests that only read cluster state (no creates, no deletes, no patches) belong in thereadonlygroup. This group finishes fast and never conflicts with other groups. -
Self-managing tenant tests go in
mt_lifecycleortenant_isolation: Tests that create their own AITenant CRs belong in one of these groups.mt_lifecyclefor lifecycle operations (create/delete/migrate),tenant_isolationfor cross-tenant isolation assertions using theshared_test_tenantsfixture. -
Externally-gated tests go in
external: Tests that require external infrastructure (Keycloak OIDC, external endpoints) and usepytest.mark.skipifto self-gate belong in theexternalgroup. -
Prefer existing groups over new ones: Adding more groups increases worker count and cluster resource pressure. Only create a new group if the test's mutation scope genuinely conflicts with all existing groups.
Serial Marker
Tests that mutate global cluster state must be marked @pytest.mark.serial:
Serial tests run in Pass 2 on a single worker. Use @serial when the test:
- Deletes or modifies a shared fixture (e.g.,
simulator-subscription,simulator-access) - Scales or restarts a cluster operator (maas-controller, Kuadrant)
- Modifies a CRD or cluster-scoped resource that other groups depend on
Do not use @serial for tests that only create/delete their own uniquely-named resources — those are safe to run in parallel within their group.
CI Pipeline
CI runs automatically on every PR and push to main. Here's how the different test types fit together:
graph TB
A[PR opened / push to main] --> B{What changed?}
B -->|maas-api/**| C[GitHub Actions: lint + unit tests]
B -->|maas-controller/api/**| D[GitHub Actions: verify-codegen]
B -->|Any non-docs change| E[Konflux: Build images]
E --> F[Integration test on ephemeral cluster]
F --> G[prow_run_smoke_test.sh → E2E pytest]
B -->|docs/** or *.md only| H[Skip build + E2E]
| System | Trigger | What runs |
|---|---|---|
| GitHub Actions | maas-api/** changes |
golangci-lint, make test (Go unit tests), image build |
| GitHub Actions | maas-controller/api/** or deployment/** changes |
make verify-codegen, kustomize manifest validation |
| Konflux | Any non-docs PR or push to main |
Builds multi-arch images, then runs full E2E on an ephemeral OpenShift cluster |
Konflux provisions a fresh cluster, deploys ODH + MaaS with the PR's built images, and runs test/e2e/scripts/prow_run_smoke_test.sh. Nightly builds use the same script against the latest main images — there is no separate nightly test suite.
Deploy phase timings and fail-fast
/group-test is sequential: cluster provision → deploy + validate → pytest. prow_run_smoke_test.sh writes UTC start/end stamps for deploy_platform, deploy_models, validate, pytest_pass1, and pytest_pass2 to phase-timings.txt.
| Where | Path |
|---|---|
| Local | $PROJECT_ROOT/test/e2e/reports/phase-timings.txt (or $ARTIFACT_DIR / $ARTIFACTS / $LOG_DIR if set) |
| Konflux / Prow | artifacts/<job>/<step>/phase-timings.txt (OpenShift CI copies ARTIFACT_DIR) |
Each line looks like 2026-08-19T20:01:02Z deploy_platform start. A start without a matching end means that phase failed or the job was killed.
Readiness gates fail the job before pytest so a bad DSC, AuthPolicy, or ODH install does not burn another ~40 minutes of pytest:
- DataScienceCluster wait in
prow_run_smoke_test.shexits 1 and dumps DSC conditions. - After models are applied, AuthPolicy
Enforced=Trueis required (wait_for_auth_policies_enforcedreturns 1 on timeout).SKIP_AUTH_CHECKstill defaults to true before models exist (RHOAIENG-48760 chicken-egg). .github/hack/install-odh.shexits 1 if the operator webhook, DSCInitialization, or DataScienceCluster never become Ready.validate-deployment.shretries after polling gateway/pods, not after a fixed 30s sleep.
SKIP_DEPLOYMENT=true still skips platform and model install; timings are then recorded only for validate and pytest.
Docs-only changes
PRs that only touch docs/** or *.md files skip Konflux builds and E2E entirely (controlled via CEL expressions in .tekton/ pipeline definitions).
Adding New Tests
Adding a Go Unit Test
- Create a
*_test.gofile next to the code you're testing (standard Go convention) - Use
testify/assertandtestify/requirefor assertions - For
maas-apihandlers: usenet/http/httptestand the fake listers inmaas-api/test/fixtures/ - For
maas-controllerreconcilers: usecontroller-runtime/pkg/client/fakewith scheme setup - Run
make testin the relevant directory to verify
Quick reference — a minimal handler test in maas-api:
func TestMyHandler_Returns200(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/my-endpoint", nil)
rec := httptest.NewRecorder()
handler := NewMyHandler(/* fake dependencies */)
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
}
Quick reference — a minimal reconciler test in maas-controller:
func TestMyReconciler_Succeeds(t *testing.T) {
client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(/* seed */).Build()
r := &MyReconciler{Client: client, Scheme: scheme}
result, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: key})
require.NoError(t, err)
require.False(t, result.Requeue)
}
CRD codegen
If you modify API types under maas-controller/api/, run make -C maas-controller generate manifests and commit the generated files. CI will fail if they're stale.
Adding an E2E Test
-
Pick the right module — add your test to an existing
test_*.pyfile if it fits the scope (see the E2E test modules table). Create a new module only if your feature doesn't fit any existing one. -
Assign an xdist group — every test file must have a module-level
xdist_groupmarker. Follow the group assignment rules to pick the right group. If adding to an existing file, it already has a group. -
Mark serial tests — if your test mutates shared cluster state (deletes shared fixtures, scales operators), add
@pytest.mark.serial. See Serial Marker for criteria. -
Use shared fixtures —
conftest.pyprovides session-scoped fixtures likemaas_api_base_url,headers,token,admin_headers,api_key, etc. ImportTLS_VERIFYfromconftestfor HTTP calls. -
Add test resources if needed — if your feature requires new MaaS CRs (models, subscriptions, auth policies), add a kustomize overlay under
test/e2e/fixtures/and include it in the basekustomization.yaml. -
Register new modules in CI —
prow_run_smoke_test.shruns an explicit file list. If you create a new test module, add it to thee2e_test_filesarray inrun_e2e_tests():# In test/e2e/scripts/prow_run_smoke_test.sh, inside run_e2e_tests() local -a e2e_test_files=( ... "$test_dir/tests/test_my_new_feature.py" # ← add here )Warning
run-tests-quick.shauto-discovers all files undertests/, butprow_run_smoke_test.shdoes not. Your new module will not run in Konflux CI unless you add it to thee2e_test_filesarray. -
Use skip markers for optional features — if your test depends on optional infrastructure (e.g., external OIDC, IPP ExternalModel CRD), gate it with
pytest.mark.skipifso the same module runs cleanly in all environments.
Integration Testing with ODH Operator
MaaS is a component of Open Data Hub (ODH). Beyond the in-repo E2E tests, MaaS is also validated as part of the broader ODH integration test suite in opendatahub-tests.
How It Works
- The opendatahub-tests repo contains cross-component integration tests for the entire ODH platform, including model serving and MaaS functionality.
- These tests run in ODH CI and nightly pipelines against full ODH deployments with all components installed.
- MaaS-related integration tests validate that MaaS works correctly when deployed through the ODH operator alongside KServe, Authorino, and other platform components.
Contributing Integration Tests
If your change affects how MaaS integrates with the ODH operator or other ODH components, you may need to add or update tests in the opendatahub-tests repo:
- Follow the opendatahub-tests contributing guide and developer guide
- Tests are organized by component — look for MaaS-related tests under the relevant directory
- The project uses openshift-python-wrapper for Kubernetes/OpenShift API interactions
- Follow the project's style guide and run
pre-commitchecks before submitting
When to Add In-Repo vs Integration Tests
| Scenario | Where to add tests |
|---|---|
| New MaaS API endpoint or controller behavior | In-repo: Go unit tests + E2E in test/e2e/ |
| MaaS CRD changes or subscription logic | In-repo: controller unit tests + E2E |
| Interaction with ODH operator, KServe, or Authorino | opendatahub-tests repo |
| End-to-end model serving through the full ODH stack | opendatahub-tests repo |
| Bug fix with regression test | In-repo (unit or E2E depending on scope) |
Checklist: Adding a New Test
- Unit test: Add
*_test.goalongside your source inmaas-api/ormaas-controller/; runmake test - E2E test: Add to an existing
test_*.pyor create a new module intest/e2e/tests/ - xdist group: Ensure the test file has a
pytestmark = pytest.mark.xdist_group("group_name")marker (see group assignment rules) - Serial marker: Add
@pytest.mark.serialif the test mutates shared fixtures or scales operators - Test fixtures: If new CRs are needed, add a kustomize overlay in
test/e2e/fixtures/ - CI registration: Add new E2E modules to the
e2e_test_filesarray inprow_run_smoke_test.sh - Skip markers: Use
pytest.mark.skipiffor tests requiring optional infrastructure - Local validation: Run
make testand/or./test/e2e/run-tests-quick.shbefore pushing - Integration tests: If your change affects ODH operator integration, update tests in opendatahub-tests