Pytest Options And Markers
This suite comes with an opinionated pytest setup. If you run pytest or uv run pytest from the repository root, it already knows where tests live, which config file to load,
how to write JUnit XML, and how to behave when you enable xdist.
Warning: A real test run requires
source_providerandstorage_class. The session start hook exits early if either is missing. In practice, you usually pass them with--tc=....
uv run pytest -m copyoffload \
-v \
${CLUSTER_HOST:+--tc=cluster_host:${CLUSTER_HOST}} \
${CLUSTER_USERNAME:+--tc=cluster_username:${CLUSTER_USERNAME}} \
${CLUSTER_PASSWORD:+--tc=cluster_password:${CLUSTER_PASSWORD}} \
--tc=source_provider:vsphere-8.0.3.00400 \
--tc=storage_class:my-block-storageclass
Default pytest behavior
The repo-level defaults live in pytest.ini:
[pytest]
testpaths = tests
addopts =
-s
-o log_cli=true
-p no:logging
--tc-file=tests/tests_config/config.py
--tc-format=python
--junit-xml=junit-report.xml
--show-progress
--strict-markers
--dist=loadscope
markers =
tier0: Core functionality tests (smoke tests)
tier1: Extended functionality tests
remote: Remote cluster migration tests
warm: Warm migration tests
copyoffload: Copy-offload (XCOPY) tests
copyoffload_sanity: Copy-offload sanity tests - core scenarios for quick validation
copyoffload_snapshots: Copy-offload snapshot tests (vSphere only)
shared_disk: Shared disk migration tests (vSphere only)
incremental: marks tests as incremental (xfail on previous failure)
min_mtv_version: mark test to require minimum MTV version (e.g., @pytest.mark.min_mtv_version("2.6.0"))
ova: OVA provider-specific tests
vsphere: vSphere provider-specific tests
esxi: ESXi provider-specific tests
rhv: RHV provider-specific tests
hyperv: Hyper-V provider-specific tests
openstack: OpenStack provider-specific tests
openshift: OpenShift provider-specific tests
deep_inspection: Deep Inspection / Conversion CR tests (vSphere only)
ca_crt: CA certificate field (ca.crt) provider secret tests
aap: AAP (Ansible Automation Platform) hook integration tests (vSphere only)
upgrade: MTV operator upgrade tests
junit_logging = all
That is 21 registered markers. Because --strict-markers is on, any @pytest.mark.<name> that is not listed above is a collection-time error, not a warning.
What that means in day-to-day use:
testpaths = testslimits default discovery to thetests/directory.-sdisables output capture, so test output is shown live.-p no:loggingdisables pytest’s built-in logging plugin. The suite sets up its own console and file logging inconftest.py.-
--tc-file=tests/tests_config/config.pyand--tc-format=pythonload defaults throughpytest-testconfig. The same plugin provides the per-run--tc=key:valueoverrides used above. -
--junit-xml=junit-report.xmlalways writes a JUnit report in the repo working directory. junit_logging = allmeans logs are included in the JUnit output.--show-progressenables progress output frompytest-progress.--strict-markersturns marker typos into immediate errors instead of silently ignoring them.--dist=loadscopepreconfigures xdist scheduling, but it does not start parallel workers by itself. You still need to add-nif you want xdist.
pytest_configure also sets a unique basetemp per session when you did not pass one:
def pytest_configure(config: pytest.Config) -> None:
"""Set a unique basetemp directory per session to prevent cross-session conflicts.
Args:
config (pytest.Config): Pytest configuration object used to set basetemp.
"""
if config.option.basetemp is None:
config.option.basetemp = str(Path(tempfile.gettempdir()) / f"pytest-{uuid.uuid4().hex[:8]}")
--basetemp=/path still wins. The auto-generated directory is removed again at session finish (shutil.rmtree) on real runs, so nothing is left behind in /tmp.
The suite also rewrites collected item names so reports are easier to read:
for item in items:
item.name = f"{item.name}-{py_config.get('source_provider')}-{py_config.get('storage_class')}"
Note: Because collected names are rewritten, terminal output and JUnit entries include the selected
source_providerandstorage_class, not just the raw test function name. The rewrite runstryfirstinpytest_collection_modifyitems, so it happens before pytest deselects by keyword or marker.-kstill matches class names and parametrize ids because pytest matches against parent node names as well.
Marker reference
pytest.ini registers 21 markers. The descriptions below are taken verbatim from that file.
| Marker | Registered description | Kind |
|---|---|---|
tier0 |
Core functionality tests (smoke tests) | Tier |
tier1 |
Extended functionality tests | Tier |
remote |
Remote cluster migration tests | Scenario |
warm |
Warm migration tests | Scenario |
copyoffload |
Copy-offload (XCOPY) tests | Scenario |
copyoffload_sanity |
Copy-offload sanity tests - core scenarios for quick validation | Scenario |
copyoffload_snapshots |
Copy-offload snapshot tests (vSphere only) | Scenario |
shared_disk |
Shared disk migration tests (vSphere only) | Scenario |
incremental |
Marks tests as incremental (xfail on previous failure) | Lifecycle |
min_mtv_version |
Mark test to require minimum MTV version (e.g., @pytest.mark.min_mtv_version("2.6.0")) |
Lifecycle |
ova |
OVA provider-specific tests | Provider |
vsphere |
vSphere provider-specific tests | Provider |
esxi |
ESXi provider-specific tests | Provider |
rhv |
RHV provider-specific tests | Provider |
hyperv |
Hyper-V provider-specific tests | Provider |
openstack |
OpenStack provider-specific tests | Provider |
openshift |
OpenShift provider-specific tests | Provider |
deep_inspection |
Deep Inspection / Conversion CR tests (vSphere only) | Integration |
ca_crt |
CA certificate field (ca.crt) provider secret tests | Integration |
aap |
AAP (Ansible Automation Platform) hook integration tests (vSphere only) | Integration |
upgrade |
MTV operator upgrade tests | Integration |
Which files apply each marker
Paths are relative to tests/.
Tier markers:
-
tier0:cold/test_cold_migration_comprehensive.py,cold/test_mtv_cold_migration.py,cold/test_ova_cold_migration.py,hooks/test_post_hook_retain_failed_vm.py,shared_disk/test_shared_disk_rhel_migration.py,warm/test_mtv_warm_clusterrole_migration.py,warm/test_mtv_warm_migration.py,warm/test_warm_migration_comprehensive.py -
tier1:cold/test_ca_crt_migration.py,cold/test_cold_migration_xfs.py,cold/test_dual_nic_cold_migration.py,cold/test_insecure_skip_verify_migration.py,deep_inspection/test_plan_driven_di.py,deep_inspection/test_standalone_di.py,hooks/test_aap_hook_migration.py,luks/test_luks_cold_migration.py,plan_lifecycle/test_plan_archive_pvc_cleanup.py,shared_disk/test_shared_disk_windows_migration.py,warm/test_mtv_warm_clusterrole_migration.py,warm/test_warm_migration_xfs.py
Scenario markers:
-
remote:cold/test_mtv_cold_migration.py,warm/test_mtv_warm_clusterrole_migration.py,warm/test_mtv_warm_migration.py -
warm:copyoffload/test_copyoffload_migration.py,deep_inspection/test_plan_driven_di.py,warm/test_mtv_warm_clusterrole_migration.py,warm/test_mtv_warm_migration.py,warm/test_warm_migration_comprehensive.py,warm/test_warm_migration_xfs.py -
copyoffload,copyoffload_sanity,copyoffload_snapshots:copyoffload/test_copyoffload_migration.pyonly -
shared_disk:shared_disk/test_shared_disk_rhel_migration.py,shared_disk/test_shared_disk_windows_migration.py
Lifecycle markers:
incremental: applied in all 21 test modules of the suite, in practice on every multi-step classmin_mtv_version: no test in the repo uses it yet; themtv_version_checkerfixture inconftest.pyimplements it
Provider markers:
-
vsphere: every test module exceptcold/test_ova_cold_migration.py -
esxi:cold/test_ca_crt_migration.py,cold/test_insecure_skip_verify_migration.py,cold/test_mtv_cold_migration.py -
rhv:cold/test_ca_crt_migration.py,cold/test_cold_migration_comprehensive.py,cold/test_insecure_skip_verify_migration.py,cold/test_mtv_cold_migration.py,hooks/test_post_hook_retain_failed_vm.py,plan_lifecycle/test_plan_archive_pvc_cleanup.py,warm/test_mtv_warm_migration.py,warm/test_warm_migration_comprehensive.py -
hyperv:cold/test_cold_migration_comprehensive.py,cold/test_mtv_cold_migration.py -
openstack:cold/test_ca_crt_migration.py,cold/test_cold_migration_comprehensive.py,cold/test_insecure_skip_verify_migration.py,cold/test_mtv_cold_migration.py,hooks/test_post_hook_retain_failed_vm.py,plan_lifecycle/test_plan_archive_pvc_cleanup.py -
openshift:cold/test_cold_migration_comprehensive.py,cold/test_mtv_cold_migration.py,hooks/test_post_hook_retain_failed_vm.py,plan_lifecycle/test_plan_archive_pvc_cleanup.py -
ova:cold/test_ova_cold_migration.py
Integration markers:
deep_inspection:deep_inspection/test_plan_driven_di.py,deep_inspection/test_standalone_di.pyca_crt:cold/test_ca_crt_migration.pyaap:hooks/test_aap_hook_migration.pyupgrade:upgrade/test_upgrade_migration.py,warm/test_mtv_warm_migration.py
Provider markers are documentation, not enforcement: a class tagged rhv still runs if you point source_provider at something else. Only the collection-time rules further down
actually skip anything.
Copy-offload sub-markers in detail
Inside tests/copyoffload/test_copyoffload_migration.py the sub-markers select smaller slices of the same file:
-
copyoffload_sanity:TestCopyoffloadThickEagerSnapshotsMigration,TestCopyoffloadRdmVirtualDiskMigration,TestCopyoffloadMultiDatastoreMigration,TestCopyoffloadMixedDatastoreMigration,TestCopyoffload10MixedDisksMigration,TestCopyoffloadWarmMigration,TestCopyoffloadScaleMigration -
copyoffload_snapshots:TestCopyoffloadThickLazySnapshotsMigration,TestCopyoffloadThickEagerSnapshotsMigration,TestCopyoffloadThinSnapshotsMigration,TestCopyoffload2TbVmSnapshotsMigration -
warm:TestCopyoffloadWarmRdmVirtualDiskMigration,TestCopyoffloadWarmMigration
Marker combinations in real classes
A typical warm class stacks several markers at once:
@pytest.mark.vsphere
@pytest.mark.rhv
@pytest.mark.tier0
@pytest.mark.warm
@pytest.mark.upgrade
@pytest.mark.incremental
@pytest.mark.parametrize(
"class_plan_config",
[
pytest.param(
py_config["tests_params"]["test_sanity_warm_mtv_migration"],
)
],
indirect=True,
ids=["rhel8"],
)
@pytest.mark.usefixtures("precopy_interval_forkliftcontroller", "cleanup_migrated_vms")
class TestSanityWarmMtvMigration:
A remote-only class adds remote plus an explicit config gate:
@pytest.mark.vsphere
@pytest.mark.rhv
@pytest.mark.warm
@pytest.mark.remote
@pytest.mark.incremental
@pytest.mark.skipif(not get_value_from_py_config("remote_ocp_cluster"), reason="No remote OCP cluster provided")
The repo also supports version-gated tests through min_mtv_version, but the marker only has effect when the checker fixture is active:
@pytest.mark.usefixtures("mtv_version_checker")
@pytest.mark.min_mtv_version("2.10.0")
def test_something(...):
# Test runs only if MTV >= 2.10.0
Collection-time provider skip rules
pytest_collection_modifyitems in conftest.py reads .providers.json, resolves the configured source_provider, and adds skip markers before any test runs. All of these rules
are skipped entirely in dry-run mode.
| Source provider type | Markers that get skipped | Skip reason emitted |
|---|---|---|
openstack, openshift, ova, hyperv |
warm |
<type> warm migration is not supported. |
Anything other than vsphere |
copyoffload, shared_disk, deep_inspection, aap, and the luks keyword |
Test is only applicable to vSphere source providers |
openshift, ova |
ca_crt |
<type> does not use CA certificates in provider secret |
Note: The
lukskeyword in that second rule is defensive only. No test in the repo registers aluksmarker, andluksis not a registered marker inpytest.ini, so the keyword currently matches nothing.
Because these rules live in a hook rather than in module-level skipif markers, -m warm on an unsupported provider still collects the tests and reports them as skipped
instead of deselecting them.
Selection and dry-run modes
This suite supports the standard pytest selection tools, and they map cleanly to how the tests are organized.
Marker selection with -m
Use project markers to slice the suite by scenario type. --strict-markers only validates marks applied to tests, not -m expressions; an unknown name in -m silently
selects no tests, so double-check the spelling against the table above.
uv run pytest -m copyoffload \
-v \
${CLUSTER_HOST:+--tc=cluster_host:${CLUSTER_HOST}} \
${CLUSTER_USERNAME:+--tc=cluster_username:${CLUSTER_USERNAME}} \
${CLUSTER_PASSWORD:+--tc=cluster_password:${CLUSTER_PASSWORD}} \
--tc=source_provider:vsphere-8.0.3.00400 \
--tc=storage_class:my-block-storageclass
In the same way, you can select other registered markers such as tier0, tier1, warm, remote, copyoffload_sanity, copyoffload_snapshots, shared_disk, or upgrade, and
combine them with pytest’s or / and syntax:
uv run pytest -m "tier0 or warm" -v --tc=source_provider:vsphere-8.0.3.00400 --tc=storage_class:my-block-storageclass
Keyword selection with -k
Copy-offload tests are class-based and parameterized with stable Jira-style ids, so -k works best on the class name or the id slug:
# Whole class
uv run pytest -m copyoffload -k TestCopyoffloadThinMigration ...
# Slug from the parametrize id (ids=["MTV-559:copyoffload-thin"])
uv run pytest -m copyoffload -k "copyoffload-thin" ...
| Class | Parametrize id |
|---|---|
TestCopyoffloadThinMigration |
MTV-559:copyoffload-thin |
TestCopyoffloadThickLazyMigration |
MTV-580:copyoffload-thick-lazy |
TestCopyoffloadThickEagerMigration |
MTV-582:copyoffload-thick-eager |
TestCopyoffloadMultiDiskMigration |
MTV-561:copyoffload-multi-disk |
TestCopyoffloadMultiDiskDifferentPathMigration |
MTV-563:copyoffload-multi-disk-different-path |
TestCopyoffloadRdmVirtualDiskMigration |
MTV-562:copyoffload-rdm-virtual |
TestCopyoffloadMultiDatastoreMigration |
MTV-564:copyoffload-multi-datastore |
TestCopyoffloadWarmMigration |
MTV-577:copyoffload-warm |
Note: The keys in
tests/tests_config/config.py(for exampletest_copyoffload_thin_migration) are pytest config keys, not test node names, so-k test_copyoffload_thin_migrationmatches nothing.
Standard pytest path and node selection
This repo does not replace pytest’s normal file, class, or node selection. Tests live in one directory per scenario area, so selecting by file works as expected:
uv run pytest tests/copyoffload/test_copyoffload_migration.py -v --tc=source_provider:vsphere-8.0.3.00400
uv run pytest tests/warm/test_mtv_warm_migration.py::TestSanityWarmMtvMigration -v
Supported dry-run modes
The suite explicitly treats --collect-only and --setup-plan as dry-run modes:
def is_dry_run(config: pytest.Config) -> bool:
"""Check if pytest was invoked in dry-run mode (collectonly or setupplan)."""
return config.option.setupplan or config.option.collectonly
Those dry-run modes are used in repository automation too:
commands = [
[
"uv",
"run",
"pytest",
"--setup-plan",
],
[
"uv",
"run",
"pytest",
"--collect-only",
],
]
The container image also defaults to dry-run discovery:
CMD ["uv", "run", "pytest", "--collect-only"]
--collect-only is the safest way to preview what your -m and -k expression will match:
pytest --collect-only -m copyoffload
--setup-plan is useful when you want pytest to show fixture setup planning without running the tests themselves.
Note: In this repository, dry-run mode is more than “just don’t execute tests.” When
--collect-onlyor--setup-planis active, the suite skips runtime-only validation, teardown, failure data collection, must-gather capture, and AI failure analysis.
Dry-run still validates that your selection expression and configuration are syntactically usable:
Warning: Dry-run does not validate a migration path. It only validates collection and, for
--setup-plan, setup planning. It does not exercise MTV, providers, or cluster-side migration behavior.
Incremental semantics
Most test classes in this repository are structured as a multi-step workflow: create the StorageMap, create the NetworkMap, create the Plan, execute the migration, then
validate the migrated VMs.
That is why incremental matters so much here. The suite implements its own incremental behavior in conftest.py:
# Incremental test support - track failures for class-based tests
if "incremental" in item.keywords and rep.when == "call" and rep.failed:
item.parent._previousfailed = item
# Incremental test support - xfail if previous test in class failed
if "incremental" in item.keywords:
previousfailed = getattr(item.parent, "_previousfailed", None)
if previousfailed is not None:
pytest.xfail(f"previous test failed ({previousfailed.name})")
In practice, that means:
- The first real failure in an incremental class is the one you should focus on.
- Later tests in the same class are converted to
xfailwith a message likeprevious test failed (...). - This prevents a broken early step from creating a long tail of noisy follow-up failures.
There is one important nuance: this implementation only records failures from the call phase. A setup or teardown error does not set _previousfailed the same way a call-phase
failure does.
Tip: When an incremental class fails, start with the earliest failing step in the class. Later
xfailresults are usually downstream effects, not new root causes.
xdist behavior
pytest-xdist is installed and the suite is xdist-aware, but parallel execution is opt-in. Nothing in pytest.ini sets a worker count, so runs stay single-process until you add
-n.
What is preconfigured is the distribution strategy:
--dist=loadscopeis enabled by default.- That is a good fit for this repository because tests are heavily class-based, use shared class attributes, and often rely on
incrementalsemantics. - Keeping related tests together on one worker reduces the chance of splitting a multi-step class across workers.
pytest-harvest needs explicit xdist plumbing, and the suite provides all four hooks. The worker-side dump persists each worker’s session state to disk:
def pytest_harvest_xdist_worker_dump(
worker_id: str,
session_items: list[Any],
fixture_store: dict[str, Any],
) -> None:
"""Persist this xdist worker's harvested results so the controller can merge them.
Args:
worker_id: Identifier of the xdist worker
session_items: Harvested test items collected on this worker
fixture_store: Fixture store captured on this worker
"""
# persist session_items and fixture_store in the file system
with open(RESULTS_PATH / (f"{worker_id}.pkl"), "wb") as f:
try:
pickle.dump((session_items, fixture_store), f)
except Exception as exp:
LOGGER.warning(f"Error while pickling worker {worker_id}'s harvested results: [{exp.__class__}] {exp}")
Those pickles land in ./.xdist_results/ (RESULTS_PATH), which is wiped and recreated by pytest_harvest_xdist_init and removed again by pytest_harvest_xdist_cleanup.
And it protects worker-shared setup where needed. For example, the virtctl_binary fixture uses a file lock and a shared cache directory specifically for xdist-safe downloads.
Tip: If you enable parallelism with
-n, keep the default--dist=loadscope. It matches the suite’s class-based design much better than fine-grained distribution.
Suite-specific pytest options
conftest.py registers six custom options across five groups. All of them are real flags you can pass on any run.
def pytest_addoption(parser: pytest.Parser) -> None:
"""Register the suite's custom pytest options in their groups.
Args:
parser: Pytest command line parser
"""
data_collector_group = parser.getgroup(name="DataCollector")
teardown_group = parser.getgroup(name="Teardown")
openshift_python_wrapper_group = parser.getgroup(name="Openshift Python Wrapper")
analyze_with_ai_group = parser.getgroup(name="Analyze with AI")
analyze_with_ai_group.addoption("--analyze-with-ai", action="store_true", help="Analyze test failures using AI")
providers_group = parser.getgroup(name="Providers")
providers_group.addoption(
"--providers-json",
help="Path to providers JSON configuration file. Falls back to PROVIDERS_JSON_PATH env var, then .providers.json",
default=None,
)
data_collector_group.addoption("--skip-data-collector", action="store_true", help="Collect data for failed tests")
data_collector_group.addoption(
"--data-collector-path", help="Path to store collected data for failed tests", default=".data-collector"
)
teardown_group.addoption(
"--skip-teardown", action="store_true", help="Do not teardown resource created by the tests"
)
openshift_python_wrapper_group.addoption(
"--openshift-python-wrapper-log-debug",
action="store_true",
help="Enable debug logging in the openshift-python-wrapper module",
)
| Option | Group | Behavior |
|---|---|---|
--analyze-with-ai |
Analyze with AI |
Enriches the JUnit XML after a failing run. Disabled automatically when ROOTCOZ_SERVER_URL is unset or in dry-run mode |
--providers-json PATH |
Providers |
Path to the providers file. Falls back to PROVIDERS_JSON_PATH, then .providers.json; a missing file raises FileNotFoundError |
--skip-data-collector |
DataCollector |
Skips failure data collection: no resources.json dump and no must-gather capture |
--data-collector-path PATH |
DataCollector |
Output directory; default .data-collector. Wiped at session start, written at session finish |
--skip-teardown |
Teardown |
Preserves resources after the run instead of deleting them |
--openshift-python-wrapper-log-debug |
Openshift Python Wrapper |
Sets OPENSHIFT_PYTHON_WRAPPER_LOG_LEVEL=DEBUG during session startup |
The real-run guard for required config lives in pytest_sessionstart too:
required_config = ("storage_class", "source_provider")
if not is_dry_run(session.config):
missing_configs: list[str] = []
for _req in required_config:
if not py_config.get(_req):
missing_configs.append(_req)
if missing_configs:
pytest.exit(reason=f"Some required config is missing {required_config=} - {missing_configs=}", returncode=1)
And teardown/data collection behavior is handled in pytest_sessionfinish:
if not session.config.getoption("skip_data_collector"):
collect_created_resources(session_store=_session_store, data_collector_path=_data_collector_path)
if session.config.getoption("skip_teardown"):
LOGGER.warning("User requested to skip teardown of resources")
else:
try:
session_teardown(session_store=_session_store)
except Exception as exp:
LOGGER.error(f"the following resources was left after tests are finished: {exp}")
if not session.config.getoption("skip_data_collector"):
run_must_gather(data_collector_path=_data_collector_path)
Warning:
--skip-teardownis a debugging tool, not a normal operating mode. If you use it, expect to clean up VMs, Plans, Providers, namespaces, and any source-side cloned resources yourself.
The --skip-data-collector help text is misleading. The implementation uses it as a true skip flag: when it is set, the suite neither writes resources.json nor runs
must-gather on failures.
One more dependency deserves a warning, since it is easy to assume otherwise from the old configuration files you may have seen:
Note:
pytest-jirais installed as a dependency and the repo ships ajira.cfg.exampletemplate, but--jirais not part ofaddoptsand no test usespytest.mark.jira. If you want JIRA markers active, add--jirayourself and supplyjira.cfg.
Finally, when you are assembling a complex selection expression:
Tip: Use
--collect-onlyfirst. Once the collected set looks right, rerun the same command without dry-run mode.