Ansible + Testinfra — Validate Infrastructure with Python Tests
Introduction
Writing Ansible playbooks is only half the job. How do you verify that your automation actually produced the desired state? Testinfra is a Python testing framework that validates infrastructure state — checking packages, services, files, ports, users, and more — using familiar pytest syntax.
Combined with Molecule, Testinfra creates a complete test-driven infrastructure workflow: provision → configure → verify.
Install Testinfra
```bash
Install testinfra with SSH backend
pip install pytest-testinfra paramiko
Or with Molecule
pip install molecule[docker] pytest-testinfra ```
Basic Test Structure
```python
tests/test_webserver.py
import pytest
def test_nginx_is_installed(host): """Verify nginx package is installed.""" nginx = host.package("nginx") assert nginx.is_installed assert nginx.version.startswith("1.")
def test_nginx_is_running(host): """Verify nginx service is running and enabled.""" nginx = host.service("nginx") assert nginx.is_running assert nginx.is_enabled
def test_nginx_listening_on_port_80(host): """Verify nginx is listening on port 80.""" socket = host.socket("tcp://0.0.0.0:80") assert socket.is_listening
def test_nginx_config_exists(host): """Verify nginx configuration file exists.""" config = host.file("/etc/nginx/nginx.conf") assert config.exists assert config.is_file assert config.user == "root" assert config.group == "root" assert config.mode == 0o644
def test_nginx_config_valid(host): """Verify nginx config passes syntax check.""" cmd = host.run("nginx -t") assert cmd.rc == 0
def test_homepage_returns_200(host): """Verify the homepage is accessible.""" cmd = host.run("curl -s -o /dev/null -w '%{http_code}' localhost/index.html") assert cmd.stdout == "200" ```
Running Tests
```bash
Test against localhost
pytest tests/test_webserver.py -v
Test against remote host via SSH
pytest tests/test_webserver.py --hosts=ssh://user@webserver.example.com -v
Test against multiple hosts
pytest tests/test_webserver.py --hosts=ssh://web1,ssh://web2 -v
Test against Ansible inventory
pytest tests/test_webserver.py --hosts='ansible://webservers' -v
Test inside Docker container
pytest tests/test_webserver.py --hosts='docker://my_container' -v ```
Testinfra Modules Reference
Packages
```python def test_packages(host): for pkg_name in ["nginx", "python3", "curl", "jq"]: pkg = host.package(pkg_name) assert pkg.is_installed, f"{pkg_name} not installed" ```
Services
```python def test_services(host): for svc_name in ["nginx", "sshd", "firewalld"]: svc = host.service(svc_name) assert svc.is_running, f"{svc_name} not running" assert svc.is_enabled, f"{svc_name} not enabled" ```
Files and Directories
```python def test_app_directory(host): d = host.file("/var/www/app") assert d.exists assert d.is_directory assert d.user == "www-data" assert d.mode == 0o755
def test_config_content(host): f = host.file("/etc/myapp/config.yml") assert f.exists assert f.contains("database_host: db.example.com") assert not f.contains("password: changeme") ```
Users and Groups
```python def test_app_user(host): user = host.user("appuser") assert user.exists assert user.uid == 1001 assert "docker" in user.groups assert user.home == "/home/appuser" assert user.shell == "/bin/bash"
def test_deploy_group(host): group = host.group("deploy") assert group.exists ```
Sockets and Ports
```python def test_ports(host): # TCP port assert host.socket("tcp://0.0.0.0:80").is_listening assert host.socket("tcp://0.0.0.0:443").is_listening # Specific interface assert host.socket("tcp://127.0.0.1:5432").is_listening # UDP port assert host.socket("udp://0.0.0.0:53").is_listening ```
System Info
```python def test_system_info(host): assert host.system_info.type == "linux" assert host.system_info.distribution in ["ubuntu", "debian"] assert host.system_info.release.startswith("22.")
def test_kernel(host): kernel = host.run("uname -r") assert "5.15" in kernel.stdout or "6." in kernel.stdout ```
Firewall
```python def test_firewall(host): rules = host.iptables assert rules.rules("filter", "INPUT") is not None
# Check specific rule
cmd = host.run("iptables -L INPUT -n | grep 'dpt:80'")
assert cmd.rc == 0
```
Processes
```python def test_processes(host): nginx_procs = host.process.filter(comm="nginx") assert len(nginx_procs) > 0
# Check process is running as correct user
master = host.process.get(comm="nginx", ppid=1)
assert master.user == "root"
```
Molecule + Testinfra Workflow
Directory Structure
``` my_role/ ├── defaults/ │ └── main.yml ├── handlers/ │ └── main.yml ├── tasks/ │ └── main.yml ├── templates/ │ └── nginx.conf.j2 ├── molecule/ │ └── default/ │ ├── molecule.yml │ ├── converge.yml │ └── verify.yml └── tests/ └── test_default.py ```
molecule.yml
```yaml
dependency: name: galaxy driver: name: docker platforms:
- name: instance image: geerlingguy/docker-ubuntu2404-ansible pre_build_image: true privileged: true command: /lib/systemd/systemd provisioner: name: ansible verifier: name: testinfra directory: ../../tests/ ```
Run Molecule Tests
```bash
Full test lifecycle
molecule test
Step by step
molecule create # Create container molecule converge # Run playbook molecule verify # Run Testinfra tests molecule destroy # Clean up
Keep container for debugging
molecule converge && molecule verify molecule login # SSH into test container ```
Parameterized Tests
```python import pytest
@pytest.mark.parametrize("pkg", [ "nginx", "python3", "curl", "git", "jq", ]) def test_packages_installed(host, pkg): assert host.package(pkg).is_installed
@pytest.mark.parametrize("port", [80, 443, 8080]) def test_ports_listening(host, port): assert host.socket(f"tcp://0.0.0.0:{port}").is_listening
@pytest.mark.parametrize("path,owner,mode", [ ("/etc/nginx/nginx.conf", "root", 0o644), ("/var/www/html/index.html", "www-data", 0o644), ("/etc/ssl/private/server.key", "root", 0o600), ]) def test_file_permissions(host, path, owner, mode): f = host.file(path) assert f.exists assert f.user == owner assert f.mode == mode ```
CI/CD Integration
```yaml
.github/workflows/test-role.yml
name: Test Ansible Role on: [push, pull_request]
jobs: molecule: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
pip install molecule[docker] pytest-testinfra ansible-core
- name: Run Molecule tests
run: molecule test
```
Common Mistakes
1. Testing Ansible logic instead of state: Test the outcome (file exists, service running), not how it got there.
2. Hardcoding values: Use fixtures and parameterization instead of repeating assertions.
3. Skipping check for idempotency: Run the playbook twice — the second run should report zero changes.
4. Not testing failure cases: Verify that security rules block what they should block.
Troubleshooting
Connection refused: ```bash
For SSH backend, check connectivity
ssh user@host "echo ok"
For Docker, check container is running
docker ps ```
Module import errors: ```bash pip install pytest-testinfra # NOT just "testinfra" ```
Related Articles
- Ansible Molecule Testing Framework
- Ansible CI/CD with GitHub Actions
- Ansible Check Mode and Diff
- Ansible assert Module
- Ansible Lint Best Practices
Conclusion
Testinfra brings the rigor of software testing to infrastructure automation. Combined with Molecule, it enables true test-driven infrastructure: write tests first, then write the Ansible code to make them pass. Start with basic package/service/file checks and expand to cover security, performance, and compliance requirements.