Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
213410e
Move Python tests to new directory
simonkurtz-MSFT May 23, 2025
96a408d
Add passing tests
simonkurtz-MSFT May 23, 2025
1e6da13
Add basic working tests
simonkurtz-MSFT May 23, 2025
4d57874
Move code coverage files
simonkurtz-MSFT May 23, 2025
eaf1a6f
Update Python Tests workflow
simonkurtz-MSFT May 23, 2025
204e092
Add test scripts, instructions, and update Readme
simonkurtz-MSFT May 23, 2025
4c24e90
Update test information
simonkurtz-MSFT May 23, 2025
5298dfe
Add Python tests badge
simonkurtz-MSFT May 23, 2025
26bc173
Enable workflow dispatch
simonkurtz-MSFT May 23, 2025
d142cb9
Fix module path
simonkurtz-MSFT May 23, 2025
d90587e
Separate test results by python version
simonkurtz-MSFT May 23, 2025
fb02f8f
Update tests/python/test_apimrequests.py
simonkurtz-MSFT May 23, 2025
3a6bfa2
JUnit test results
simonkurtz-MSFT May 23, 2025
5d89692
Publish Unit Test Results to the PR
simonkurtz-MSFT May 23, 2025
feb99d0
Fix permissions
simonkurtz-MSFT May 23, 2025
7b86a49
Customize test results title
simonkurtz-MSFT May 23, 2025
9208c9c
Fix extract_json docstring
simonkurtz-MSFT May 23, 2025
74ad544
Ensure code coverage and test comments still occur upon test failures
simonkurtz-MSFT May 23, 2025
84aacdf
Fix is_string_json
simonkurtz-MSFT May 23, 2025
0876c5d
Rename test run files
simonkurtz-MSFT May 23, 2025
d9da993
Add tests, fix failing code
simonkurtz-MSFT May 23, 2025
7b7d6d0
Clean up
simonkurtz-MSFT May 23, 2025
86141d2
Clean up
simonkurtz-MSFT May 23, 2025
f0d281e
Add infra deployment cleanup
simonkurtz-MSFT May 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: Python Tests

on:
workflow_dispatch:
push:
branches: [ main ]
pull_request:
Expand All @@ -25,6 +26,16 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests
- name: Install pytest and pytest-cov
run: |
python -m unittest discover shared/python-tests
pip install pytest pytest-cov

- name: Run pytest with coverage
run: |
COVERAGE_FILE=tests/python/.coverage pytest --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov tests/python/

- name: Upload coverage HTML report
uses: actions/upload-artifact@v4
with:
name: coverage-html
path: tests/python/htmlcov/
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,8 @@ labs-in-progress/

# Exclude sensitive or generated files
.env


# Coverage data and reports
.coverage
tests/python/htmlcov/
50 changes: 47 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Azure API Management Samples

[![Python Tests](https://github.com/Azure-Samples/Apim-Samples/actions/workflows/python-tests.yml/badge.svg?branch=main)](https://github.com/Azure-Samples/Apim-Samples/actions/workflows/python-tests.yml)

This repository provides a playground to safely experiment with and learn Azure API Management (APIM) policies in various architectures.

## Objectives
Expand Down Expand Up @@ -87,11 +89,53 @@ The repo uses the bicep linter and has rules defined in `bicepconfig.json`. See

We welcome contributions! Please consider forking the repo and creating issues and pull requests to share your samples. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details. Thank you!

### Testing

Python modules and files in the `shared` folder are supported with tests in the `python-tests` folder. They can be invoked manually via `pytest -v` but are also run upon push to GitHub origin. The `pytest.ini` file in the root sets up the test structure.
### Testing & Code Coverage

Python modules in `shared/python` are covered by comprehensive unit tests located in `tests/python`. All tests use [pytest](https://docs.pytest.org/) and leverage modern pytest features, including custom markers for unit and HTTP tests.

#### Running Tests Locally

- **PowerShell (Windows):**
- Run all tests with coverage: `./tests/python/run_coverage.ps1`
- **Shell (Linux/macOS):**
- Run all tests with coverage: `./tests/python/run_coverage.sh`

Both scripts:
- Run all tests in `tests/python` using pytest
- Generate a code coverage report (HTML output in `tests/python/htmlcov`)
- Store the raw coverage data in `tests/python/.coverage`

You can also run tests manually and see details in the console:
```sh
pytest -v --cov=shared/python --cov-report=html:tests/python/htmlcov --cov-report=term tests/python
```

#### Viewing Coverage Reports

After running tests, open `tests/python/htmlcov/index.html` in your browser to view detailed coverage information.

#### Pytest Markers

- `@pytest.mark.unit` — marks a unit test
- `@pytest.mark.http` — marks a test involving HTTP/mocking

Markers are registered in `pytest.ini` to avoid warnings.

#### Continuous Integration (CI)

On every push or pull request, GitHub Actions will:
- Install dependencies
- Run all Python tests in `tests/python` with coverage
- Store the `.coverage` file in `tests/python`
- Upload the HTML coverage report as a workflow artifact for download

#### Additional Notes

- The `.gitignore` is configured to exclude coverage output and artifacts.
- All test and coverage features work both locally and in CI.

Additional information can be found [here](# https://docs.pytest.org/en/8.2.x/).
For more details on pytest usage, see the [pytest documentation](https://docs.pytest.org/en/8.2.x/).

---

Expand Down
7 changes: 0 additions & 7 deletions pytest.ini

This file was deleted.

3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@

requests
setuptools
pytest
pytest
pytest-cov
1 change: 0 additions & 1 deletion shared/python-tests/__init__.py

This file was deleted.

32 changes: 0 additions & 32 deletions shared/python-tests/test_apimtypes.py

This file was deleted.

2 changes: 1 addition & 1 deletion shared/python/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ def extract_json(text: str) -> any:
if text[start] in ('{', '['):
try:
obj, end = decoder.raw_decode(text[start:])
return json.loads(obj)
return obj
except Exception:
continue

Expand Down
11 changes: 11 additions & 0 deletions tests/python/.coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[run]
branch = True
source =
shared/python
omit =
*/__init__.py
*/__pycache__/*

[report]
show_missing = True
skip_covered = True
3 changes: 3 additions & 0 deletions shared/python-tests/.pylintrc → tests/python/.pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ disable =
C0115, # Missing class docstring
C0116, # Missing function or method docstring
W0212, # Access to a protected member _ of a client class
R0903, # Too few public methods
R0913, # Too many arguments
W0621, # Redefining name from outer scope
3 changes: 3 additions & 0 deletions tests/python/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""
Unit tests package for API Management samples.
"""
36 changes: 36 additions & 0 deletions tests/python/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""
Shared test configuration and fixtures for pytest.
"""
import os
import sys
from typing import Any

import pytest

# Add the shared/python directory to the Python path for all tests
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../shared/python')))


# ------------------------------
# SHARED FIXTURES
# ------------------------------

@pytest.fixture(scope="session")
def shared_python_path() -> str:
"""Provide the path to the shared Python modules."""
return os.path.abspath(os.path.join(os.path.dirname(__file__), '../../shared/python'))

@pytest.fixture(scope="session")
def test_data_path() -> str:
"""Provide the path to test data files."""
return os.path.abspath(os.path.join(os.path.dirname(__file__), 'data'))

@pytest.fixture
def sample_test_data() -> dict[str, Any]:
"""Provide sample test data for use across tests."""
return {
"test_url": "https://test-apim.azure-api.net",
"test_subscription_key": "test-subscription-key-12345",
"test_resource_group": "rg-test-apim-01",
"test_location": "eastus2"
}
13 changes: 13 additions & 0 deletions tests/python/pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[pytest]
markers =
apimtypes: tests for apimtypes module
apimrequests: tests for apimrequests module
utils: tests for utils module
integration: integration tests that require external services
slow: tests that take a long time to run
unit: marks tests as unit tests
http: marks tests that mock or use HTTP
testpaths =
tests/python
python_files =
test_*.py
3 changes: 3 additions & 0 deletions tests/python/run_coverage.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# PowerShell script to run pytest with coverage and store .coverage in tests/python
$env:COVERAGE_FILE = "tests/python/.coverage"
pytest --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov tests/python/
4 changes: 4 additions & 0 deletions tests/python/run_coverage.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Shell script to run pytest with coverage and store .coverage in tests/python
COVERAGE_FILE=tests/python/.coverage
export COVERAGE_FILE
pytest --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov tests/python/
124 changes: 124 additions & 0 deletions tests/python/test_apimrequests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import pytest
import requests
from unittest.mock import patch, MagicMock
from shared.python.apimrequests import ApimRequests
from shared.python.apimtypes import HTTP_VERB, SUBSCRIPTION_KEY_PARAMETER_NAME

# Sample values for tests
default_url = "https://example.com/apim/"
default_key = "test-key"
default_path = "/test"
default_headers = {"Custom-Header": "Value"}
default_data = {"foo": "bar"}

@pytest.fixture
def apim():
return ApimRequests(default_url, default_key)


@pytest.mark.unit
def test_init_sets_headers():
"""Test that headers are set correctly when subscription key is provided."""
apim = ApimRequests(default_url, default_key)
assert apim.url == default_url
assert apim.apimSubscriptionKey == default_key
assert apim.headers[SUBSCRIPTION_KEY_PARAMETER_NAME] == default_key
assert apim.headers["Accept"] == "application/json"


@pytest.mark.unit
def test_init_no_key():
"""Test that headers are set correctly when no subscription key is provided."""
apim = ApimRequests(default_url)
assert apim.url == default_url
assert apim.apimSubscriptionKey is None
assert "Ocp-Apim-Subscription-Key" not in apim.headers
assert apim.headers["Accept"] == "application/json"

@pytest.mark.http
@patch("shared.python.apimrequests.requests.request")
@patch("shared.python.apimrequests.utils.print_message")
@patch("shared.python.apimrequests.utils.print_info")
@patch("shared.python.apimrequests.utils.print_error")
def test_single_get_success(mock_print_error, mock_print_info, mock_print_message, mock_request, apim):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/json"}
mock_response.json.return_value = {"result": "ok"}
mock_response.text = '{"result": "ok"}'
mock_response.raise_for_status.return_value = None
mock_request.return_value = mock_response

with patch.object(apim, "_print_response") as mock_print_response:
result = apim.singleGet(default_path, printResponse=True)
assert result == '{\n "result": "ok"\n}'
mock_print_response.assert_called_once_with(mock_response)
mock_print_error.assert_not_called()

@pytest.mark.http
@patch("shared.python.apimrequests.requests.request")
@patch("shared.python.apimrequests.utils.print_message")
@patch("shared.python.apimrequests.utils.print_info")
@patch("shared.python.apimrequests.utils.print_error")
def test_single_get_error(mock_print_error, mock_print_info, mock_print_message, mock_request, apim):
mock_request.side_effect = requests.exceptions.RequestException("fail")
result = apim.singleGet(default_path, printResponse=True)
assert result is None
mock_print_error.assert_called_once()

@pytest.mark.http
@patch("shared.python.apimrequests.requests.request")
@patch("shared.python.apimrequests.utils.print_message")
@patch("shared.python.apimrequests.utils.print_info")
@patch("shared.python.apimrequests.utils.print_error")
def test_single_post_success(mock_print_error, mock_print_info, mock_print_message, mock_request, apim):
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.headers = {"Content-Type": "application/json"}
mock_response.json.return_value = {"created": True}
mock_response.text = '{"created": true}'
mock_response.raise_for_status.return_value = None
mock_request.return_value = mock_response

with patch.object(apim, "_print_response") as mock_print_response:
result = apim.singlePost(default_path, data=default_data, printResponse=True)
assert result == '{\n "created": true\n}'
mock_print_response.assert_called_once_with(mock_response)
mock_print_error.assert_not_called()

@pytest.mark.http
@patch("shared.python.apimrequests.requests.Session")
@patch("shared.python.apimrequests.utils.print_message")
@patch("shared.python.apimrequests.utils.print_info")
def test_multi_get_success(mock_print_info, mock_print_message, mock_session, apim):
mock_sess = MagicMock()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/json"}
mock_response.json.return_value = {"result": "ok"}
mock_response.text = '{"result": "ok"}'
mock_response.raise_for_status.return_value = None
mock_sess.request.return_value = mock_response
mock_session.return_value = mock_sess

with patch.object(apim, "_print_response_code") as mock_print_code:
result = apim.multiGet(default_path, runs=2, printResponse=True)
assert len(result) == 2
for run in result:
assert run["status_code"] == 200
assert run["response"] == '{\n "result": "ok"\n}'
assert mock_sess.request.call_count == 2
mock_print_code.assert_called()

@pytest.mark.http
@patch("shared.python.apimrequests.requests.Session")
@patch("shared.python.apimrequests.utils.print_message")
@patch("shared.python.apimrequests.utils.print_info")
def test_multi_get_error(mock_print_info, mock_print_message, mock_session, apim):
mock_sess = MagicMock()
mock_sess.request.side_effect = requests.exceptions.RequestException("fail")
mock_session.return_value = mock_sess
with patch.object(apim, "_print_response_code"):
# Should raise inside the loop, but function should still close session and return what it has
with pytest.raises(requests.exceptions.RequestException):
apim.multiGet(default_path, runs=1, printResponse=True)
Loading
Loading