| name | write-sdk-integration-test |
| description | Guide for writing SDK integration tests using the module-per-command structure in packages/sdk/tests/local_operations/, covering shared helpers, the it_should_* naming convention, test isolation, and module registration. Triggers on "sdk integration test", "write sdk test", "test sdk method", "add sdk test", "integration test for sdk", "test deployer method", or "sdk test module". |
| metadata | {"author":"torrust","version":"1.0"} |
Write an SDK Integration Test
SDK integration tests live in packages/sdk/tests/local_operations/ and are
the only tests written entirely against the public SDK API — with no
access to internal implementation details. They exercise the SDK exactly as an
external consumer would.
No infrastructure (LXD, Docker, SSH) is required for these tests; they only
cover local operations.
Quick Decision Tree
- Adding tests for a new method? → Follow the full guide below to create a
new module file and register it.
- Adding an extra test case to an existing method? → Skip to
Phase 3 — Write the Test Function.
Directory Structure
packages/sdk/tests/
└── local_operations/
├── main.rs ← entry point: shared helpers + `mod` declarations
├── builder.rs ← DeployerBuilder error cases
├── create.rs ← create_environment (typed + JSON file)
├── destroy.rs ← destroy
├── exists.rs ← exists before/after create
├── list.rs ← list (populated + empty)
├── purge.rs ← purge
├── show.rs ← show + not-found
├── validate.rs ← validate (valid + invalid config)
└── workflow.rs ← chained operations end-to-end
One module per SDK command. Mirror this structure when adding a new command.
Phase 1: Create the Module File
Create packages/sdk/tests/local_operations/{command}.rs.
Use the file module template below; replace my_command with the command name
and write real test cases instead of the placeholder.
use crate::{create_environment, deployer_in_temp_dir, minimal_config};
#[test]
fn it_should_succeed_when_environment_exists() {
let (deployer, _tmp) = deployer_in_temp_dir();
let name = create_environment(&deployer, "my-cmd-test");
let result = deployer.my_command(&name);
assert!(result.is_ok(), "my_command failed: {:?}", result.err());
}
#[test]
fn it_should_return_error_when_environment_does_not_exist() {
let (deployer, _tmp) = deployer_in_temp_dir();
let name = minimal_config("nonexistent").name;
let result = deployer.my_command(&name);
assert!(result.is_err(), "expected error for unknown environment");
}
Naming convention
All test functions follow the pattern:
it_should_{behavior}_when_{condition}
Examples:
it_should_return_empty_list_when_no_environments_exist
it_should_return_error_when_environment_not_found
it_should_succeed_when_environment_was_previously_created
Phase 2: Register the Module in main.rs
Add a mod declaration at the top of
packages/sdk/tests/local_operations/main.rs alongside the other module
declarations:
mod builder;
mod create;
mod destroy;
mod exists;
mod list;
mod my_command;
mod purge;
mod show;
mod validate;
mod workflow;
Phase 3: Write the Test Function
Shared helpers (from main.rs — import via crate::)
All helpers are pub(crate) and resolved through the crate:: prefix inside
any module file.
| Helper | Purpose |
|---|
deployer_in_temp_dir() -> (Deployer, TempDir) | Fresh isolated workspace per test. Always bind _tmp — dropping it deletes the dir. |
minimal_config(name: &str) -> EnvironmentCreationConfig | Minimal valid config using fixtures/ SSH keys. |
create_environment(deployer: &Deployer, name: &str) -> EnvironmentName | Create environment and unwrap; panics with context on failure. |
write_config_json(dir: &Path, filename: &str, env_name: &str) -> PathBuf | Write a minimal valid JSON config to disk. |
fixture_ssh_keys() -> (PathBuf, PathBuf) | Absolute paths to fixtures/testing_rsa keypair. |
repo_root() -> PathBuf | Absolute path to the repository root. |
assert_environment_exists(deployer, name) | Asserts deployer.exists(name) is true. |
assert_environment_not_exists(deployer, name) | Asserts deployer.exists(name) is false. |
Isolation pattern
Each test must get its own deployer_in_temp_dir(). Tests must not share
state or depend on the execution order of other tests.
#[test]
fn it_should_return_true_when_environment_was_created() {
let (deployer, _tmp) = deployer_in_temp_dir();
let name = create_environment(&deployer, "exists-test");
assert!(deployer.exists(&name).unwrap());
}
Error-path tests
Import error types from the public SDK surface only:
use torrust_tracker_deployer_sdk::CreateCommandHandlerError;
#[test]
fn it_should_return_already_exists_when_created_twice() {
let (deployer, _tmp) = deployer_in_temp_dir();
let config = crate::minimal_config("dup");
deployer.create_environment(config.clone()).unwrap();
let result = deployer.create_environment(config);
assert!(
matches!(result, Err(CreateCommandHandlerError::AlreadyExists(_))),
"expected AlreadyExists, got: {result:?}"
);
}
Workflow / multi-step tests
For tests that chain multiple operations, place them in workflow.rs:
#[test]
fn it_should_allow_full_local_lifecycle() {
let (deployer, _tmp) = deployer_in_temp_dir();
let name = create_environment(&deployer, "lifecycle");
assert_environment_exists(&deployer, &name);
let list = deployer.list().unwrap();
assert_eq!(list.len(), 1);
let info = deployer.show(&name).unwrap();
assert_eq!(info.name, name);
deployer.destroy(&name).unwrap();
assert_environment_not_exists(&deployer, &name);
}
Phase 4: Run the Tests
cargo test -p torrust-tracker-deployer-sdk
cargo test -p torrust-tracker-deployer-sdk my_command
Expected: the new tests appear in the output and pass.
Phase 5: Run All Checks
cargo run --bin linter all
Fix any linting issues (unused imports, missing doc-comments on public items)
before committing.
Commit
Follow the commit conventions in .github/skills/dev/git-workflow/commit-changes/skill.md.
Typical subject line:
test(sdk): add integration tests for my_command
Group in a single commit:
packages/sdk/tests/local_operations/my_command.rs (new file)
packages/sdk/tests/local_operations/main.rs (module declaration)