On this page
- Twenty Functions That All Do the Same Thing
- What You Need Before Diving Into Data-Driven Tests
- From One Test Function to Eleven Cases: Basic Parametrize
- Testing API Input Validation With Nested Dictionaries
- Fixtures: Reusable Setup With Controlled Scope
- The GitHub Actions Workflow
- Pycodestyle: Keeping the Style Consistent
- How pytest Actually Finds and Runs Tests
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.