← Back to DevBytes

Testing Nox Applications: Unit Tests to Integration

Introduction to Testing Nox Applications

Nox is a flexible test automation tool for Python that allows you to define your test sessions in a Python file rather than a configuration file. This makes it incredibly powerful for orchestrating unit tests, integration tests, linting, type checking, and deployment tasks across multiple Python versions and environments. In this tutorial, we will explore how to build a complete testing strategy for a Nox-based Python application, moving from simple unit tests all the way to robust integration tests.

What Is Nox and Why It Matters

Nox is a command-line tool that automates testing in isolated environments. Unlike tox, which relies on an INI-style configuration file, Nox uses a standard Python file called noxfile.py. This means you can use Python logic, loops, conditionals, and imports directly in your test configuration.

Key Benefits of Nox

Setting Up Your Project

Let us start by creating a sample project structure. We will build a small application that interacts with a REST API and processes data. This gives us meaningful scenarios for both unit and integration testing.

Project Structure

my_app/
├── src/
│   └── my_app/
│       ├── __init__.py
│       ├── client.py
│       └── processor.py
├── tests/
│   ├── __init__.py
│   ├── unit/
│   │   ├── __init__.py
│   │   ├── test_processor.py
│   │   └── test_client.py
│   ├── integration/
│   │   ├── __init__.py
│   │   └── test_api_integration.py
│   └── conftest.py
├── noxfile.py
├── pyproject.toml
└── README.md

Installing Nox

Install Nox using pip. It is recommended to install it globally or in a dedicated tool environment:

pip install nox

You can verify the installation by running:

nox --version

Writing the Application Code

Before we write tests, let us create the application code that we will be testing. Our application will fetch data from an API and process it.

The API Client

# src/my_app/client.py
import requests
from typing import Any, Dict


class APIClient:
    """A simple client for interacting with a REST API."""

    def __init__(self, base_url: str, timeout: int = 10):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout

    def get(self, endpoint: str) -> Dict[str, Any]:
        url = f"{self.base_url}/{endpoint.lstrip('/')}"
        response = requests.get(url, timeout=self.timeout)
        response.raise_for_status()
        return response.json()

    def post(self, endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]:
        url = f"{self.base_url}/{endpoint.lstrip('/')}"
        response = requests.post(url, json=payload, timeout=self.timeout)
        response.raise_for_status()
        return response.json()

The Data Processor

# src/my_app/processor.py
from typing import Any, Dict, List


def filter_active_users(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """Return only users whose 'active' field is True."""
    return [user for user in users if user.get("active") is True]


def calculate_average_age(users: List[Dict[str, Any]]) -> float:
    """Calculate the average age of a list of users."""
    if not users:
        return 0.0
    total_age = sum(user.get("age", 0) for user in users)
    return total_age / len(users)


def summarize_users(users: List[Dict[str, Any]]) -> Dict[str, Any]:
    """Produce a summary of active users including count and average age."""
    active = filter_active_users(users)
    return {
        "total_users": len(users),
        "active_users": len(active),
        "average_age": calculate_average_age(active),
    }

Configuring the Noxfile

The noxfile.py is the heart of your Nox setup. It defines sessions, each of which runs in its own virtual environment. Let us create a comprehensive noxfile that handles unit tests, integration tests, linting, and type checking.

# noxfile.py
import nox
from nox import session

# Python versions to test against
PYTHON_VERSIONS = ["3.9", "3.10", "3.11", "3.12"]

# Locations that should not be checked for linting
LINT_LOCATIONS = ["src/my_app", "tests", "noxfile.py"]


@session(python=PYTHON_VERSIONS)
def unit_tests(session):
    """Run the unit test suite."""
    session.install("-e", ".")
    session.install("pytest", "pytest-cov", "pytest-mock")
    session.run(
        "pytest",
        "tests/unit/",
        "--cov=my_app",
        "--cov-report=term-missing",
        "-v",
    )


@session(python="3.11")
def integration_tests(session):
    """Run the integration test suite."""
    session.install("-e", ".")
    session.install("pytest", "pytest-mock", "responses")
    session.run("pytest", "tests/integration/", "-v", "--tb=short")


@session(python="3.11")
def lint(session):
    """Run flake8 and black checks."""
    session.install("flake8", "black", "isort")
    session.run("flake8", *LINT_LOCATIONS)
    session.run("black", "--check", *LINT_LOCATIONS)
    session.run("isort", "--check-only", *LINT_LOCATIONS)


@session(python="3.11")
def type_check(session):
    """Run mypy type checking."""
    session.install("-e", ".")
    session.install("mypy")
    session.run("mypy", "src/my_app")


@session(python="3.11")
def tests(session):
    """Run all tests (unit and integration)."""
    session.install("-e", ".")
    session.install("pytest", "pytest-cov", "pytest-mock", "responses")
    session.run(
        "pytest",
        "tests/",
        "--cov=my_app",
        "--cov-report=term-missing",
        "-v",
    )

Understanding Sessions

Each function decorated with @session becomes a Nox session. When you run nox without arguments, all sessions execute. You can run a specific session by name:

nox -s unit_tests
nox -s integration_tests
nox -s lint

You can also list all available sessions:

nox -l

Writing Unit Tests

Unit tests focus on testing individual functions and classes in isolation. External dependencies should be mocked so that the tests are fast, deterministic, and independent of network conditions.

Testing the Processor

The processor functions are pure functions with no external dependencies, making them ideal candidates for straightforward unit tests.

# tests/unit/test_processor.py
import pytest
from my_app.processor import (
    filter_active_users,
    calculate_average_age,
    summarize_users,
)


@pytest.fixture
def sample_users():
    return [
        {"name": "Alice", "age": 30, "active": True},
        {"name": "Bob", "age": 25, "active": False},
        {"name": "Charlie", "age": 35, "active": True},
        {"name": "Diana", "age": 28, "active": True},
    ]


class TestFilterActiveUsers:
    def test_returns_only_active_users(self, sample_users):
        result = filter_active_users(sample_users)
        names = [user["name"] for user in result]
        assert names == ["Alice", "Charlie", "Diana"]

    def test_empty_list_returns_empty(self):
        assert filter_active_users([]) == []

    def test_all_inactive_returns_empty(self):
        users = [{"name": "Eve", "active": False}]
        assert filter_active_users(users) == []

    def test_missing_active_field_treated_as_inactive(self):
        users = [{"name": "Frank", "age": 40}]
        assert filter_active_users(users) == []


class TestCalculateAverageAge:
    def test_correct_average(self, sample_users):
        active = filter_active_users(sample_users)
        result = calculate_average_age(active)
        assert result == pytest.approx(31.0)

    def test_empty_list_returns_zero(self):
        assert calculate_average_age([]) == 0.0

    def test_single_user(self):
        users = [{"age": 42}]
        assert calculate_average_age(users) == 42.0

    def test_missing_age_defaults_to_zero(self):
        users = [{"name": "Grace"}, {"name": "Heidi", "age": 20}]
        assert calculate_average_age(users) == 10.0


class TestSummarizeUsers:
    def test_summary_structure(self, sample_users):
        result = summarize_users(sample_users)
        assert "total_users" in result
        assert "active_users" in result
        assert "average_age" in result

    def test_summary_values(self, sample_users):
        result = summarize_users(sample_users)
        assert result["total_users"] == 4
        assert result["active_users"] == 3
        assert result["average_age"] == pytest.approx(31.0)

    def test_empty_users(self):
        result = summarize_users([])
        assert result == {
            "total_users": 0,
            "active_users": 0,
            "average_age": 0.0,
        }

Testing the Client with Mocks

The API client uses the requests library, so we need to mock HTTP calls. We will use pytest-mock for this purpose.

# tests/unit/test_client.py
import pytest
from unittest.mock import patch, Mock
from my_app.client import APIClient


class TestAPIClient:
    def test_get_sends_request_to_correct_url(self):
        client = APIClient("https://api.example.com/")

        with patch("my_app.client.requests.get") as mock_get:
            mock_response = Mock()
            mock_response.json.return_value = {"status": "ok"}
            mock_response.raise_for_status.return_value = None
            mock_get.return_value = mock_response

            result = client.get("/users")

            mock_get.assert_called_once_with(
                "https://api.example.com/users",
                timeout=10,
            )
            assert result == {"status": "ok"}

    def test_post_sends_json_payload(self):
        client = APIClient("https://api.example.com")

        with patch("my_app.client.requests.post") as mock_post:
            mock_response = Mock()
            mock_response.json.return_value = {"id": 1}
            mock_response.raise_for_status.return_value = None
            mock_post.return_value = mock_response

            result = client.post("/users", {"name": "Alice"})

            mock_post.assert_called_once_with(
                "https://api.example.com/users",
                json={"name": "Alice"},
                timeout=10,
            )
            assert result == {"id": 1}

    def test_get_raises_on_http_error(self):
        client = APIClient("https://api.example.com")

        with patch("my_app.client.requests.get") as mock_get:
            mock_response = Mock()
            mock_response.raise_for_status.side_effect = Exception("404 Not Found")
            mock_get.return_value = mock_response

            with pytest.raises(Exception, match="404 Not Found"):
                client.get("/missing")

    def test_custom_timeout_is_respected(self):
        client = APIClient("https://api.example.com", timeout=30)

        with patch("my_app.client.requests.get") as mock_get:
            mock_response = Mock()
            mock_response.json.return_value = {}
            mock_response.raise_for_status.return_value = None
            mock_get.return_value = mock_response

            client.get("/data")

            _, kwargs = mock_get.call_args
            assert kwargs["timeout"] == 30

Writing Integration Tests

Integration tests verify that multiple components work together correctly. They may interact with real or simulated external services. For our application, we will use the responses library to simulate HTTP responses, allowing us to test the full flow from client to processor without hitting a real server.

Shared Fixtures in conftest.py

# tests/conftest.py
import pytest
from my_app.client import APIClient


@pytest.fixture
def api_client():
    """Provide an APIClient pointing at a mock base URL."""
    return APIClient("https://api.example.com", timeout=5)


@pytest.fixture
def mock_user_data():
    return [
        {"id": 1, "name": "Alice", "age": 30, "active": True},
        {"id": 2, "name": "Bob", "age": 25, "active": False},
        {"id": 3, "name": "Charlie", "age": 35, "active": True},
        {"id": 4, "name": "Diana", "age": 28, "active": True},
        {"id": 5, "name": "Eve", "age": 22, "active": False},
    ]

Integration Test: Full Data Flow

# tests/integration/test_api_integration.py
import pytest
import responses
from my_app.client import APIClient
from my_app.processor import summarize_users, filter_active_users


class TestFullDataFlow:
    @responses.activate
    def test_fetch_and_summarize_users(self, api_client, mock_user_data):
        # Register the mock response
        responses.add(
            responses.GET,
            "https://api.example.com/users",
            json=mock_user_data,
            status=200,
        )

        # Fetch data through the real client
        data = api_client.get("/users")

        # Process the data through the real processor
        summary = summarize_users(data)

        assert summary["total_users"] == 5
        assert summary["active_users"] == 3
        assert summary["average_age"] == pytest.approx(31.0)

    @responses.activate
    def test_fetch_active_users_only(self, api_client, mock_user_data):
        responses.add(
            responses.GET,
            "https://api.example.com/users",
            json=mock_user_data,
            status=200,
        )

        data = api_client.get("/users")
        active = filter_active_users(data)

        assert len(active) == 3
        assert all(user["active"] for user in active)

    @responses.activate
    def test_client_handles_server_error(self, api_client):
        responses.add(
            responses.GET,
            "https://api.example.com/users",
            json={"error": "Internal Server Error"},
            status=500,
        )

        with pytest.raises(Exception):
            api_client.get("/users")

    @responses.activate
    def test_post_user_and_verify(self, api_client):
        responses.add(
            responses.POST,
            "https://api.example.com/users",
            json={"id": 10, "name": "Frank", "age": 40, "active": True},
            status=201,
        )

        new_user = api_client.post("/users", {"name": "Frank", "age": 40})
        assert new_user["id"] == 10

        # Now simulate fetching the user list with the new user included
        responses.add(
            responses.GET,
            "https://api.example.com/users",
            json=[new_user],
            status=200,
        )

        data = api_client.get("/users")
        summary = summarize_users(data)
        assert summary["active_users"] == 1
        assert summary["average_age"] == 40.0

Running Your Tests with Nox

Now that everything is in place, let us run the tests. Here are the most common commands:

# Run all sessions
nox

# Run only unit tests across all Python versions
nox -s unit_tests

# Run only integration tests
nox -s integration_tests

# Run the combined test session
nox -s tests

# Run linting and type checking
nox -s lint type_check

# Reuse existing virtualenvs instead of recreating them (faster)
nox -r

# Stop on first failure
nox -s unit_tests -- -x

Expected Output

When you run nox -s unit_tests, you should see output similar to:

nox > Running session unit_tests(python='3.11')
nox > Creating virtualenv using python3.11 in .nox/unit_tests-python-3-11
nox > python -m pip install -e .
nox > python -m pip install pytest pytest-cov pytest-mock
nox > pytest tests/unit/ --cov=my_app --cov-report=term-missing -v
========================= test session starts ==========================
platform linux -- Python 3.11.x, pytest-8.x.x, pluggy-1.x.x
collected 14 items

tests/unit/test_processor.py::TestFilterActiveUsers::test_returns_only_active_users PASSED
tests/unit/test_processor.py::TestFilterActiveUsers::test_empty_list_returns_empty PASSED
...
tests/unit/test_client.py::TestAPIClient::test_get_sends_request_to_correct_url PASSED
...

========================= 14 passed in 0.45s ==========================
nox > Session unit_tests(python='3.11') was successful.

Best Practices for Testing with Nox

1. Separate Unit and Integration Tests

Keep unit tests and integration tests in separate directories and separate Nox sessions. Unit tests should be fast and run on every commit. Integration tests may be slower and can run less frequently, such as on pull requests or before deployment.

2. Parametrize Sessions for Multiple Python Versions

Use the python parameter to test against all supported Python versions. This catches version-specific bugs early:

@session(python=["3.9", "3.10", "3.11", "3.12"])
def unit_tests(session):
    session.install("-e", ".")
    session.install("pytest", "pytest-cov")
    session.run("pytest", "tests/unit/", "-v")

3. Use pyproject.toml for Dependencies

Define your project dependencies in pyproject.toml so that session.install("-e", ".") pulls in everything needed. Here is a minimal example:

[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.backends._legacy:_Backend"

[project]
name = "my_app"
version = "0.1.0"
dependencies = [
    "requests>=2.28",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0",
    "pytest-cov",
    "pytest-mock",
    "responses",
    "flake8",
    "black",
    "isort",
    "mypy",
]

4. Keep Sessions Focused

Each session should have a single responsibility. Avoid combining linting, testing, and building into one giant session. This makes it easier to run only what you need and to identify failures quickly.

5. Use nox -r During Development

Recreating virtual environments on every run is slow. During active development, use nox -r to reuse existing environments. In CI, always create fresh environments to ensure clean, reproducible results.

6. Add a CI Pipeline

Integrate Nox into your CI pipeline. Here is an example GitHub Actions workflow:

# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.9", "3.10", "3.11", "3.12"]

    steps:
      - uses: actions/checkout@v4
      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - name: Install nox
        run: pip install nox
      - name: Run unit tests
        run: nox -s "unit_tests(python='${{ matrix.python-version }}')"
      - name: Run linting
        run: nox -s lint
      - name: Run type checking
        run: nox -s type_check

  integration:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - uses: actions/checkout@v4
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - name: Install nox
        run: pip install nox
      - name: Run integration tests
        run: nox -s integration_tests

7. Enforce Coverage Thresholds

Use pytest-cov with a minimum coverage threshold to prevent code from shipping without tests:

session.run(
    "pytest",
    "tests/unit/",
    "--cov=my_app",
    "--cov-report=term-missing",
    "--cov-fail-under=80",
    "-v",
)

8. Document Your Sessions

Every session function should have a docstring. Nox uses these docstrings when listing sessions with nox -l, making it clear what each session does:

@session(python="3.11")
def integration_tests(session):
    """Run integration tests against mocked HTTP responses."""
    ...

Conclusion

Testing Nox applications effectively requires a layered approach: fast, isolated unit tests that verify individual components, and broader integration tests that confirm those components work together correctly. By leveraging Nox's Python-native configuration, you can define clean, focused sessions for each testing concern, run them across multiple Python versions, and integrate them seamlessly into your CI pipeline. The key takeaways are to keep your sessions single-purpose, mock external dependencies in unit tests, simulate realistic scenarios in integration tests, and enforce quality gates like coverage thresholds and linting. With this structure in place, you can confidently iterate on your application knowing that your test suite will catch regressions before they reach production.

— Ad —

Google AdSense will appear here after approval

← Back to all articles