featured image

Pytest Parametrization & GitHub Actions CI Pipeline

A comprehensive guide to writing efficient, maintainable tests using pytest's parametrization features, along with setting up a GitHub Actions workflow for continuous integration. This tutorial covers best practices for test organization, fixtures, and automated quality gates in a professional software development environment.

Published

Thu Jun 12 2025

Technologies Used

Python Pytest
Intermediate 27 minutes

Twenty Functions That All Do the Same Thing

Most developers start out writing tests like this:

def test_validate_pressure_empty():
    result, valid = validate_pressure("")
    assert valid == True

def test_validate_pressure_invalid_string():
    result, valid = validate_pressure("ABC")
    assert valid == False

def test_validate_pressure_too_low():
    result, valid = validate_pressure("3")
    assert valid == False

# ... 15 more nearly identical functions

The problems compound fast. Same logic repeated twenty times. A bug fix touches twenty files. When one fails, “test_validate_pressure_too_low FAILED” tells you which case broke, but there’s still nothing stopping the next developer from adding a 21st copy instead of a 21st parameter.

The right approach is parametrized tests: one test function that pytest expands into as many cases as you give it, each running independently and reporting individually. This tutorial covers the parametrization patterns used in the CPAP monitor test suite — test_server.py and test_patient_GUI.py — and the GitHub Actions workflow in .github/workflows/pytest_runner.yml that runs them automatically on every push.

What You Need Before Diving Into Data-Driven Tests

Knowledge Base:

  • Python basics (functions, decorators)
  • Pytest fundamentals (assert, test discovery)
  • Git basics (commits, branches, push)
  • YAML syntax (for GitHub Actions)

Environment:

pip install pytest pytest-pycodestyle testfixtures
python --version  # 3.10+

GitHub Setup:

  • Repository hosted on GitHub
  • Actions enabled (enabled by default for public repos)

From One Test Function to Eleven Cases: Basic Parametrize

Instead of writing 11 separate functions for different CPAP pressure inputs, you write one function and hand pytest a list of tuples:

import pytest

@pytest.mark.parametrize("pressure, msg, met",
                         [("", "Information Uploaded", True),
                          ("A", "CPAP pressure is not an integer", False),
                          ("A1", "CPAP pressure is not an integer", False),
                          ("@1", "CPAP pressure is not an integer", False),
                          ("0", "CPAP Pressure is not between 4 and 25", False),
                          ("-1", "CPAP Pressure is not between 4 and 25", False),
                          ("3", "CPAP Pressure is not between 4 and 25", False),
                          ("26", "CPAP Pressure is not between 4 and 25", False),
                          ("99999", "CPAP Pressure is not between 4 and 25", False),
                          ("4", "Information Uploaded", True),
                          ("25", "Information Uploaded", True)
                          ])
def test_validate_pressure(pressure, msg, met):
    from patient_GUI import validate_pressure
    answer0, answer1 = validate_pressure(pressure)
    assert answer0 == msg
    assert answer1 == met

What pytest does with this: it parses the parameter names from the first argument string ("pressure, msg, met"), creates eleven separate test executions — one per tuple — and runs them independently. One failure doesn’t stop the others. The output shows exactly which input case broke:

$ pytest test_patient_GUI.py::test_validate_pressure -v

test_patient_GUI.py::test_validate_pressure["-Information Uploaded-True] PASSED    [  9%]
test_patient_GUI.py::test_validate_pressure[A-CPAP pressure is not an integer-False] PASSED    [ 18%]
...
test_patient_GUI.py::test_validate_pressure[25-Information Uploaded-True] PASSED    [100%]

========== 11 passed in 0.23s ==========

One thing to watch out for: the parameter names string and the tuple lengths must match. Three names, two-value tuples raises a TypeError immediately — pytest won’t silently skip the mismatched argument.

Testing API Input Validation With Nested Dictionaries

When you’re testing functions that validate JSON payloads, the parametrize list gets more complex. Each test case is a full dictionary. The approach is the same:

@pytest.mark.parametrize("in_data, expected_keys, expected_types, Error",
                         [({"patient_mrn": "123",
                            "patient_name": "Jason",
                            "pressure": "55"},
                           ["patient_mrn", "patient_name", "pressure"],
                           [int, str, int],
                           True),
                          ({"patient_mrn": "a123",
                            "patient_name": "Jason",
                            "pressure": "55"},
                           ["patient_mrn", "patient_name", "pressure"],
                           [int, str, int],
                           "Key patient_mrn is not an int or numeric string"),
                          ({"patient_mrn": "123",
                            "pressure": "55"},
                           ["patient_mrn", "patient_name", "pressure"],
                           [int, str, int],
                           "Key patient_name is missing from input"),
                          (["patient_mrn", "a123",
                            "patient_name", "Jason"],
                           ["patient_mrn", "patient_name", "pressure"],
                           [int, str, int],
                           "Input is not a dictionary"),
                          ])
def test_validate_input_data_generic(in_data, expected_keys,
                                     expected_types, Error):
    from server import validate_input_data_generic
    answer = validate_input_data_generic(in_data, expected_keys,
                                         expected_types)
    assert answer == Error

When a test suite grows large, grouping cases by category before concatenating them helps enormously. I usually split the parameter list into named variables — VALID_INPUTS, INVALID_TYPE_INPUTS, MISSING_KEY_INPUTS — and pass them as VALID_INPUTS + INVALID_TYPE_INPUTS + MISSING_KEY_INPUTS to the decorator. The test IDs get long and unreadable for complex objects by default; pass ids=["readable-name", ...] to the decorator to fix that.

Fixtures: Reusable Setup With Controlled Scope

Some tests need a clean database before they run. Pytest fixtures handle setup and teardown cleanly:

import pytest
from PatientModel import Patient, Session

@pytest.fixture(scope="function")
def clean_database():
    session = Session()
    try:
        patients = session.query(Patient).all()
        for patient in patients:
            session.delete(patient)
        session.commit()
    finally:
        session.close()

    yield  # Test runs here

    # Teardown runs after the test completes


def test_add_patient_to_db(clean_database):
    from server import new_patient_to_db

    in_data = {
        "patient_name": "Jason",
        "patient_mrn": 100,
        "room_number": 100,
        "CPAP_pressure": 10,
        "breath_rate": 10.0,
        "apnea_count": 10,
        "flow_image": ""
    }

    result = new_patient_to_db(in_data, "2023-04-26 00:00:00")

    session = Session()
    try:
        patient = session.query(Patient).filter_by(room_number=100).first()
        assert patient is not None
        assert patient.patient_name == "Jason"
    finally:
        session.close()

The scope parameter controls how often the fixture runs. scope="function" (the default) cleans the database before each individual test. That’s safe but slow — if cleanup takes 200ms and you have 50 tests, that’s 10 seconds before any test logic runs.

scope="module" runs the fixture once per test file. Much faster, but now you have a shared database state across tests. If test_create_patient adds a patient with id=1 and test_create_duplicate assumes a clean database and tries to create another patient with id=1, you get a duplicate key error. The second test fails not because the code is wrong, but because the first test left state behind. Use module scope only when your tests are designed to share state or when you’re confident about ordering.

The GitHub Actions Workflow

Every push to GitHub should automatically run the tests. The workflow lives at .github/workflows/pytest_runner.yml:

name: Pytest with Pycodestyle

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2

    - name: Set up Python 3.10
      uses: actions/setup-python@v2
      with:
        python-version: '3.10'

    - name: Install dependencies
      run: pip install -r requirements.txt

    - name: Test with pytest
      run: pytest -v --pycodestyle

on: [push, pull_request] fires the workflow on pushes to any branch and on any pull request. The ubuntu-latest runner is a free VM that gets destroyed after the workflow completes. actions/checkout@v2 clones your repository. The -v flag makes pytest name each test in the output; --pycodestyle runs the PEP 8 style checker on all files.

When a run fails, you navigate to the Actions tab in your GitHub repo, click the failed run, and expand the “Test with pytest” step to see the full pytest output. The error message looks like:

FAILED test_server.py::test_validate_input_data_generic[in_data1-...] - AssertionError
E       AssertionError: assert "Key patient_mrn has the incorrect value type" == True

That’s the failing parameter set, the actual value, and the expected value — enough to debug without running anything locally.

Pycodestyle: Keeping the Style Consistent

Adding --pycodestyle to the test run means the CI pipeline enforces PEP 8 on every push. Configuration lives in pytest.ini or setup.cfg:

[pycodestyle]
max-line-length = 120
ignore = E402,W503
exclude = .venv,venv,env,.git,__pycache__

Common violations that show up in CI and not locally: trailing whitespace (W291), missing blank lines before function definitions (E302), and missing whitespace after commas (E231). The autopep8 tool can fix most of these automatically if you want to skip the manual edits.

For tests that need database credentials or API keys, use GitHub Secrets rather than hardcoding values. Add the secret under repo → Settings → Secrets and variables → Actions, then reference it in the workflow:

- name: Test with pytest
  env:
    DB_USER: ${{ secrets.DB_USER }}
    DB_PASS: ${{ secrets.DB_PASS }}
  run: pytest -v

If the secret might be absent (running tests locally without secrets configured), skip those tests conditionally:

import os
import pytest

@pytest.mark.skipif(
    "DB_USER" not in os.environ,
    reason="Database credentials not available"
)
def test_database_connection():
    conn = connect(username=os.environ["DB_USER"],
                   password=os.environ["DB_PASS"])

Never put credentials in source code. GitHub scans for secrets and will alert you, and once a credential is in git history, it’s effectively public even if you delete the file.

How pytest Actually Finds and Runs Tests

Pytest’s discovery rules are simple. It scans for test_*.py or *_test.py files, looks for functions starting with test_ and classes starting with Test, and expands any parametrized tests into separate cases. The test IDs it generates look like:

test_file.py::test_function
test_file.py::test_function[param1]
test_file.py::TestClass::test_method[param2]

Internally, @pytest.mark.parametrize stores the argument names and value tuples as metadata on the function. During collection, pytest reads that metadata and creates one test case object per tuple. The function itself is never duplicated — only the parameter sets differ. For 100 test cases, memory usage is 1 function plus 100 small tuples. The function-as-factory approach also means the test ID encoding is deterministic: pytest names each case by stringifying the parameter values, which is why readable parameter values make the output so much easier to scan.

The total infrastructure for this test suite — parametrize, fixtures, pycodestyle, and the GitHub Actions workflow — is maybe 50 lines of configuration and boilerplate on top of your actual test logic. That’s a reasonable price for tests that run on every push, report exactly which input broke, and enforce code style automatically.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!