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" ```

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.