Skip to main content

Functional Tests

Functional testing is a pytest test suite (rooted in functional_tests/) for tests that are meant to be run in an isolated environment. Functional testing is comprised of:

  • Native pytest test cases.
  • Unit-test like Rust functions.

They are used to test:

  • Functionality that cannot work in isolation and depends on external resources (e.g. binaries on the system)
  • The more dangerous parts of the codebase, generally things you wouldn't want to test in your development environment such as:
    • Operations that require root access.
    • Operations that manipulate disks, RAID arrays, mounts, filesystems, etc.
    • Operations that modify the OS in any way.

This document aims to explain the architecture of how functional tests work, and how they are implemented.

Native Python Tests

Native Python tests are pytest tests that are written in Python. They live in python files in the functional_tests/custom directory.

Generally, these leverage the vm fixture, which is an Azure Linux VM that has been created using virt-deploy and netlaunch. The vm fixture is defined in conftest.py.

Rust-Based Tests

Conditional Compilation

Rust-based functional tests should be contained within a module called functional_test gated by the feature functional-test. For example:

#[cfg(feature = "functional-test")]
#[cfg_attr(not(test), allow(unused_imports, dead_code))]
mod functional_test {
// tests here
}

Test Case Definition

Each test case should have the proc-macro attribute #[functional_test] applied to it. The attribute is defined in pytest_gen.

This attribute expands roughly to the following:

inventory::submit!{pytest::TestCaseMetadata {
module: module_path!(),
function: #function,
negative: #negative,
feature: #feature,
type_: #test_type,
}}

#[test]
#(#attrs)*
#vis #sig {
#block
}

For example, the following test case:

#[functional_test]
fn test_case() {
// test here
}

Expands to:

inventory::submit!{pytest::TestCaseMetadata {
module: module_path!(), // This will be `crate::module1::moduleN::functional_test`
function: "test_case",
negative: false,
feature: "",
type_: "",
}}

#[test]
fn test_case() {
// test here
}

The key part is the inventory::submit! macro. This macro comes from the inventory crate, which is generally used to register plug-ins. In this case, the object being registered is a TestCaseMetadata object, which contains all the information needed to run the test case.

The inventory crate creates a global list of all the registered objects, which can be consumed lated. This happens in the pytest crate.

Inventory Collection

The pytest crate contains the definition of the TestCaseMetadata object, and thus, also contains the macro inventory::collect! which is used to collect all the registered TestCaseMetadata objects.

The pytest crate is responsible for iterating over all the submitted test cases. This happens in the generate_functional_test_manifest() function, which is called by Trident's pytest subcommand. This subcommand is ONLY available when the pytest-generator cargo feature is enabled.

Rust-Pytest Interface

To run the functional tests, pytest must be aware of all the test cases that exist within the test binaries.

Rust Export to JSON

The pytest crate collects all the test cases, and builds a tree representing rust's modules, and all the test cases within them. This tree is then serialized into a JSON file called ft.json, and placed in the functional_tests/ directory.

The underlying structure of the JSON file is:

/// Represents a rust module.
#[derive(Serialize, Default, Debug)]
struct Module {
/// Test cases in this module.
#[serde(skip_serializing_if = "HashMap::is_empty")]
test_cases: HashMap<String, TestCaseInfo>,

/// Submodules of this module.
#[serde(skip_serializing_if = "HashMap::is_empty")]
submodules: HashMap<String, Module>,
}

/// Represents a specific test case.
#[derive(Serialize, Default, Debug)]
struct TestCaseInfo {
/// Pytest markers to apply to this test case.
#[serde(skip_serializing_if = "Vec::is_empty")]
markers: Vec<String>,
}

let mut json_output: HashMap<String, Module> = HashMap::new();

An example of the JSON output is:

{
"osutils": {
"submodules": {
"sfdisk": {
"submodules": {
"functional_test": {
"test_cases": {
"test_get": {
"markers": [
"functional",
"positive",
"helpers"
]
}
}
}
}
},
"container": {
"submodules": {
"functional_test": {
"test_cases": {
"test_get_host_root_path_in_simulated_container": {
"markers": [
"functional",
"positive",
"helpers"
]
},
"test_get_host_root_path_fails_in_simulated_container_without_host_mount": {
"markers": [
"functional",
"negative",
"helpers"
]
},
}
}
}
}
}
}
}

Pytest Collection from JSON

In pytest, we use the hook pytest_collect_file to look out for the ft.json file. When we find it, we parse it, and create a FuncTestCollector object, which returns one RustModule object per crate.

The RustModule object explores the tree and recursively yields more RustModule objects, until it reaches the test cases, which it yields as pytest.Function objects.

The pytest.Function objects are partials created from the function

def run_rust_functional_test(vm, crate, module_path, test_case):
"""Runs a rust test on the VM."""
from functional_tests.tools.runner import RunnerTool

testRunner = RunnerTool(vm)
testRunner.run(
crate,
f"{module_path}::{test_case}",
)

All the parameters except for vm get filled in during collection.

Pytest will then run the run_rust_functional_test function, which will run the test case inside of the VM.

Isolated Environment Creation

The functional test pytest suite defines the vm fixture. This VM is created using virt-deploy with a prebuilt Azure Linux 3 image.