This document defines the comprehensive test strategy for the azlin CLI tool following TDD principles and the 60/30/10 testing pyramid.
- 60% Unit Tests: Fast, isolated tests for individual functions and classes
- 30% Integration Tests: Tests for module interactions and external service integrations
- 10% E2E Tests: Complete workflow tests from CLI entry to VM provisioning
Based on the 10 user requirements, we'll organize tests as follows:
-
CLI Interface Module (12 tests)
- Argument parsing (with --repo, without --repo)
- Command validation
- Help text generation
- Error message formatting
- Exit code handling
- Configuration loading
-
Azure Authentication Module (10 tests)
- Credential detection (az CLI, managed identity, env vars)
- Token validation
- Credential caching
- Error handling for missing credentials
- Mock Azure SDK responses
-
VM Provisioning Module (12 tests)
- VM configuration building
- Size validation (specific VM sizes)
- Region validation
- Ubuntu image selection
- Network configuration
- Resource group creation
- Error handling for quota limits
-
Tool Installation Module (9 tests)
- Installation script generation (for 9 tools)
- Tool verification logic
- Installation order validation
- Error handling for failed installations
-
SSH Configuration Module (10 tests)
- SSH key generation
- SSH config file updates
- Connection string building
- Auto-connect logic
- Known hosts management
-
tmux Session Module (8 tests)
- Session creation commands
- Session persistence verification
- Session attachment logic
- Error handling for tmux not installed
-
GitHub Repository Module (7 tests)
- Repo URL parsing
- Clone command generation
- gh auth detection
- Conditional cloning logic
-
Progress Display Module (4 tests)
- Progress bar rendering
- Status message formatting
- Step tracking
- Error display
-
Azure SDK Integration (6 tests)
- Full authentication flow with mocked Azure
- VM creation with mocked Azure SDK
- Resource cleanup with mocked Azure SDK
- Error propagation from Azure SDK
- Retry logic for transient failures
-
SSH Integration (6 tests)
- Full SSH setup workflow
- Key-based authentication flow
- Auto-connect integration with VM provisioning
- SSH config persistence
-
GitHub Integration (5 tests)
- gh CLI detection and usage
- Repository cloning workflow
- gh auth flow integration
- Conditional repo cloning based on --repo flag
-
Tool Installation Integration (6 tests)
- Sequential installation of all 9 tools
- Installation failure recovery
- Tool verification after installation
- Installation script execution
-
Progress Display Integration (5 tests)
- Progress updates through full workflow
- Error state display
- Multi-step progress tracking
- imessR notification integration
-
Configuration Management (4 tests)
- Config file loading and merging
- Environment variable overrides
- Default configuration
- Invalid configuration handling
-
Error Recovery Integration (4 tests)
- Partial failure handling
- Rollback on critical failures
- State persistence for resumption
- Error notification via imessR
-
Complete Workflow Tests (4 tests)
- Full workflow with --repo flag (mocked Azure/SSH)
- Full workflow without --repo flag (mocked Azure/SSH)
- Workflow with pre-existing VM
- Workflow with authentication failure
-
Failure Recovery Tests (3 tests)
- VM creation failure and recovery
- Tool installation failure and partial success
- Network failure during setup
-
Edge Cases (3 tests)
- Multiple concurrent azlin runs
- VM already exists with same name
- SSH key already exists
-
Notification Tests (2 tests)
- Success notification via imessR
- Failure notification via imessR
-
Azure SDK
- Mock:
azure.identity,azure.mgmt.compute,azure.mgmt.network,azure.mgmt.resource - Strategy: Create fake Azure responses for VM operations
- Tools:
unittest.mock.Mock,pytest-mock
- Mock:
-
SSH/Subprocess
- Mock:
subprocess.run(),paramiko(if used) - Strategy: Capture SSH commands, simulate successful connections
- Tools:
unittest.mock.patch, custom subprocess mock
- Mock:
-
GitHub CLI (gh)
- Mock:
subprocesscalls toghcommand - Strategy: Mock gh CLI responses, simulate auth flow
- Tools:
unittest.mock.patch
- Mock:
-
File System Operations
- Mock: SSH config writes, key generation
- Strategy: Use temporary directories, mock file operations
- Tools:
pytest.tmpdir,unittest.mock.mock_open
-
imessR Service
- Mock: HTTP calls to imessR API
- Strategy: Mock requests to notification service
- Tools:
responseslibrary orunittest.mock.patch
# Pattern 1: Azure SDK Mock
@pytest.fixture
def mock_azure_client():
with patch('azure.mgmt.compute.ComputeManagementClient') as mock:
mock.return_value.virtual_machines.begin_create_or_update.return_value.result.return_value = Mock(
id='/subscriptions/sub-id/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/test-vm',
name='test-vm',
location='eastus'
)
yield mock
# Pattern 2: Subprocess Mock
@pytest.fixture
def mock_subprocess():
with patch('subprocess.run') as mock:
mock.return_value = Mock(returncode=0, stdout='success', stderr='')
yield mock
# Pattern 3: GitHub CLI Mock
@pytest.fixture
def mock_gh_cli():
with patch('subprocess.run') as mock:
def gh_side_effect(cmd, *args, **kwargs):
if 'gh' in cmd and 'auth' in cmd:
return Mock(returncode=0, stdout='Logged in as user')
elif 'gh' in cmd and 'repo' in cmd:
return Mock(returncode=0, stdout='')
return Mock(returncode=1, stderr='command not found')
mock.side_effect = gh_side_effect
yield mocktests/
├── __init__.py
├── conftest.py # Shared fixtures
├── unit/ # 60% - Unit tests
│ ├── __init__.py
│ ├── test_cli.py
│ ├── test_azure_auth.py
│ ├── test_vm_provisioning.py
│ ├── test_tool_installation.py
│ ├── test_ssh_config.py
│ ├── test_tmux_session.py
│ ├── test_github_repo.py
│ └── test_progress_display.py
├── integration/ # 30% - Integration tests
│ ├── __init__.py
│ ├── test_azure_integration.py
│ ├── test_ssh_integration.py
│ ├── test_github_integration.py
│ ├── test_tool_installation_integration.py
│ ├── test_progress_integration.py
│ ├── test_config_management.py
│ └── test_error_recovery.py
├── e2e/ # 10% - End-to-end tests
│ ├── __init__.py
│ ├── test_complete_workflow.py
│ ├── test_failure_recovery.py
│ ├── test_edge_cases.py
│ └── test_notifications.py
├── fixtures/ # Test data and fixtures
│ ├── __init__.py
│ ├── azure_responses.py
│ ├── ssh_configs.py
│ └── sample_configs.py
└── mocks/ # Custom mock implementations
├── __init__.py
├── azure_mock.py
├── subprocess_mock.py
└── github_mock.py
import pytest
from pathlib import Path
from unittest.mock import Mock, patch
@pytest.fixture
def temp_ssh_dir(tmp_path):
"""Temporary SSH directory for testing."""
ssh_dir = tmp_path / '.ssh'
ssh_dir.mkdir()
return ssh_dir
@pytest.fixture
def temp_config_dir(tmp_path):
"""Temporary config directory for testing."""
config_dir = tmp_path / '.azlin'
config_dir.mkdir()
return config_dir
@pytest.fixture
def mock_azure_credentials():
"""Mock Azure credentials."""
with patch('azure.identity.DefaultAzureCredential') as mock:
mock.return_value.get_token.return_value = Mock(
token='fake-token',
expires_on=9999999999
)
yield mock
@pytest.fixture
def sample_vm_config():
"""Sample VM configuration."""
return {
'name': 'test-vm',
'size': 'Standard_D2s_v3',
'region': 'eastus',
'image': 'Canonical:0001-com-ubuntu-server-jammy:22_04-lts:latest'
}
@pytest.fixture
def mock_progress_display():
"""Mock progress display to avoid output during tests."""
with patch('azlin.progress.ProgressDisplay') as mock:
yield mock.return_value# tests/utils.py
from typing import List, Dict, Any
from unittest.mock import Mock
class AzureResponseBuilder:
"""Builder for creating fake Azure API responses."""
@staticmethod
def create_vm_response(name: str, location: str, vm_size: str) -> Mock:
return Mock(
id=f'/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/{name}',
name=name,
location=location,
hardware_profile=Mock(vm_size=vm_size),
provisioning_state='Succeeded'
)
@staticmethod
def create_error_response(error_code: str, message: str) -> Exception:
return Exception(f'{error_code}: {message}')
class SubprocessCapture:
"""Capture subprocess calls for verification."""
def __init__(self):
self.calls: List[List[str]] = []
def capture(self, cmd: List[str], **kwargs) -> Mock:
self.calls.append(cmd)
return Mock(returncode=0, stdout='', stderr='')
def assert_called_with_command(self, command: str):
assert any(command in ' '.join(call) for call in self.calls), \
f"Expected command '{command}' not found in {self.calls}""""Sample Azure API responses for testing."""
SAMPLE_VM_RESPONSE = {
'id': '/subscriptions/sub-id/resourceGroups/azlin-rg/providers/Microsoft.Compute/virtualMachines/dev-vm',
'name': 'dev-vm',
'location': 'eastus',
'properties': {
'vmId': 'vm-12345',
'hardwareProfile': {'vmSize': 'Standard_D2s_v3'},
'storageProfile': {
'imageReference': {
'publisher': 'Canonical',
'offer': '0001-com-ubuntu-server-jammy',
'sku': '22_04-lts',
'version': 'latest'
}
},
'osProfile': {
'computerName': 'dev-vm',
'adminUsername': 'azureuser'
},
'networkProfile': {
'networkInterfaces': [{
'id': '/subscriptions/sub-id/resourceGroups/azlin-rg/providers/Microsoft.Network/networkInterfaces/dev-vm-nic'
}]
},
'provisioningState': 'Succeeded'
}
}
SAMPLE_NETWORK_INTERFACE = {
'id': '/subscriptions/sub-id/resourceGroups/azlin-rg/providers/Microsoft.Network/networkInterfaces/dev-vm-nic',
'properties': {
'ipConfigurations': [{
'properties': {
'publicIPAddress': {
'id': '/subscriptions/sub-id/resourceGroups/azlin-rg/providers/Microsoft.Network/publicIPAddresses/dev-vm-ip'
}
}
}]
}
}
SAMPLE_PUBLIC_IP = {
'id': '/subscriptions/sub-id/resourceGroups/azlin-rg/providers/Microsoft.Network/publicIPAddresses/dev-vm-ip',
'properties': {
'ipAddress': '20.123.45.67'
}
}"""Sample SSH configurations for testing."""
SAMPLE_SSH_CONFIG = """
Host azlin-dev
HostName 20.123.45.67
User azureuser
IdentityFile ~/.ssh/azlin_rsa
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
"""
SAMPLE_SSH_KEY_PUBLIC = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC... azureuser@azlin"
# Note: For testing, generate keys dynamically rather than including sample private keys-
RED: Write failing tests first
- Start with unit tests for core functionality
- Tests should fail because implementation doesn't exist yet
- Focus on testing behavior, not implementation
-
GREEN: Write minimal code to pass tests
- Implement just enough to make tests pass
- No premature optimization
- Keep it simple and focused
-
REFACTOR: Improve code while keeping tests green
- Extract common patterns
- Improve naming and structure
- Ensure tests still pass
# Example 1: CLI Argument Parsing (RED phase)
def test_cli_accepts_repo_argument():
"""Test that CLI accepts --repo argument."""
# This test will fail - no implementation yet
from azlin.cli import parse_args
args = parse_args(['--repo', 'https://github.com/user/repo'])
assert args.repo == 'https://github.com/user/repo'
assert args.provision_vm is True # Default
# Example 2: Azure Authentication (RED phase)
def test_azure_auth_detects_az_cli():
"""Test that Azure auth detects az CLI credentials."""
from azlin.azure_auth import AzureAuthenticator
with patch('subprocess.run') as mock_run:
mock_run.return_value = Mock(returncode=0, stdout='{"accessToken": "token123"}')
auth = AzureAuthenticator()
credentials = auth.get_credentials()
assert credentials is not None
assert credentials.method == 'az_cli'
# Example 3: VM Provisioning (RED phase)
def test_vm_provisioner_creates_vm_with_correct_size():
"""Test VM provisioner uses correct VM size."""
from azlin.vm_provisioning import VMProvisioner
provisioner = VMProvisioner(size='Standard_D2s_v3', region='eastus')
config = provisioner.build_vm_config()
assert config.hardware_profile.vm_size == 'Standard_D2s_v3'
assert config.location == 'eastus'- Empty inputs: Test with no --repo argument, empty config files
- Invalid inputs: Test with invalid VM sizes, regions, repo URLs
- Missing tools: Test behavior when gh CLI, tmux not installed
- Authentication failures: Test all Azure auth methods failing
- Network failures: Test SSH connection failures, Azure API timeouts
- Azure quota exceeded: VM creation fails due to quota
- SSH connection timeout: Cannot connect to newly created VM
- Tool installation failure: One or more of the 9 tools fails to install
- GitHub clone failure: Repository doesn't exist or is private
- Partial completion: VM created but tool installation fails
- Resume after failure: Test resuming from failed tool installation
- Cleanup on abort: Test proper cleanup when user cancels
- Multiple runs: Test running azlin multiple times with same config
- State file corruption: Test recovery from corrupted state file
# Run all tests
pytest
# Run only unit tests (fast)
pytest tests/unit/
# Run with coverage
pytest --cov=azlin --cov-report=html
# Run specific test file
pytest tests/unit/test_cli.py
# Run tests matching pattern
pytest -k "test_azure"
# Run with verbose output
pytest -v
# Run tests in parallel
pytest -n auto# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -e ".[dev]"
- name: Run unit tests
run: pytest tests/unit/ --cov=azlin
- name: Run integration tests
run: pytest tests/integration/
- name: Run E2E tests
run: pytest tests/e2e/
- name: Upload coverage
uses: codecov/codecov-action@v3- Unit Tests: 90%+ coverage of business logic
- Integration Tests: 80%+ coverage of integration points
- E2E Tests: 100% coverage of critical user workflows
- Unit Tests: < 5 seconds total
- Integration Tests: < 30 seconds total
- E2E Tests: < 2 minutes total
- Full Suite: < 3 minutes total
- All tests must pass before merge
- No decrease in code coverage
- No new test warnings or failures
- All new features must have tests
- All bug fixes must have regression tests
[project.optional-dependencies]
dev = [
"pytest>=7.4.0",
"pytest-cov>=4.1.0",
"pytest-mock>=3.11.0",
"pytest-asyncio>=0.21.0",
"pytest-xdist>=3.3.0", # Parallel test execution
"responses>=0.23.0", # HTTP mocking
"freezegun>=1.2.0", # Time mocking
]- New features: Add tests before implementing feature
- Bug fixes: Add regression test before fixing bug
- Refactoring: Ensure all tests still pass
- Dependency updates: Verify mocks still work with new versions
- Keep tests focused: One test per behavior
- Avoid test interdependence: Tests should not depend on each other
- Use descriptive names: Test name should describe the behavior
- Mock external services: Never hit real Azure API in tests
- Keep tests fast: Unit tests should run in milliseconds
This test strategy provides:
- Comprehensive coverage: 120 tests following 60/30/10 pyramid
- TDD approach: Write tests first, then implementation
- Strategic mocking: Mock all external services (Azure, SSH, GitHub, imessR)
- Clear organization: Separate unit, integration, and E2E tests
- Fast feedback: Unit tests run in seconds
- Maintainability: Well-structured fixtures and utilities
The strategy ensures high confidence in the azlin CLI while maintaining fast test execution and clear test organization.