testingapideveloper-guidequalitypython

Testing Body Measurement API Integrations: Unit, Integration, and Property Tests

· 6 min read · Martin Hejda

Testing a body measurement API integration has a specific challenge: the API is an external service with real costs per request. Running the full test suite against the live API on every commit is expensive and creates fragile tests that fail when the network is slow or the service has an incident.

The solution is a layered testing strategy: unit tests run against mocked responses, integration tests hit the real API in limited scenarios, and property-based tests verify the logic that maps API outputs to size recommendations.


Layer 1: Unit tests with mocked API responses

Unit tests should run instantly, offline, and for free. Mock the HTTP client to return pre-recorded API responses.

# tests/test_sizing_service.py
import pytest
from unittest.mock import patch, MagicMock
import json
from pathlib import Path

# Fixture: pre-recorded API response for a specific input (female, 168 cm, 62 kg)
# Matches the actual body_dimensions dict structure returned by the API
MOCK_RESPONSE_FEMALE_168_62 = {
    "header": {"status": "success", "calculation_model_used": "ADULT"},
    "body_dimensions": {
        "chest_circumference": {
            "label": "Chest Circumference",
            "value": 924.0,
            "unit": "mm",
            "type": "FLESH",
            "confidence_score": 78,
            "range_95": [854.0, 994.0],
            "iso_code": None,
            "biological_limit_status": "OK"
        },
        "waist_circumference_natural": {
            "label": "Waist Circumference (Natural)",
            "value": 726.0,
            "unit": "mm",
            "type": "FLESH",
            "confidence_score": 76,
            "range_95": [626.0, 826.0],
            "iso_code": None,
            "biological_limit_status": "OK"
        },
        "hip_circumference": {
            "label": "Hip Circumference",
            "value": 965.0,
            "unit": "mm",
            "type": "FLESH",
            "confidence_score": 78,
            "range_95": [885.0, 1045.0],
            "iso_code": None,
            "biological_limit_status": "OK"
        }
    },
    "system_info": {"api_version": "1.4.0", "model_version": "4.0", "computation_time_ms": 12}
}

@pytest.fixture
def mock_api_response():
    """Fixture providing a mock response for 168cm/62kg female."""
    return MOCK_RESPONSE_FEMALE_168_62

def test_extract_dimensions_from_response(mock_api_response):
    """Test that dimension extraction handles the API response format correctly."""
    from sizing_service import extract_dimensions
    
    dims = extract_dimensions(mock_api_response)
    
    assert "chest_circumference" in dims
    assert dims["chest_circumference"]["value"] == 924.0
    assert dims["chest_circumference"]["confidence_score"] == 78
    assert dims["chest_circumference"]["range_95"][0] == 854.0

def test_size_recommendation_from_dimensions(mock_api_response):
    """Test size chart matching with known inputs."""
    from sizing_service import extract_dimensions, recommend_size
    
    dims = extract_dimensions(mock_api_response)
    
    # Brand A women's tops: M covers chest 901–960mm
    recommendation = recommend_size(
        chest_mm=dims["chest_circumference"]["value"],
        waist_mm=dims["waist_circumference_natural"]["value"],
        hip_mm=dims["hip_circumference"]["value"],
        chart_key="brand_a_womens_top"
    )
    
    assert recommendation["recommended_size"] == "M"
    assert recommendation["confidence"] in ("HIGH", "MEDIUM")

@patch("sizing_service.requests.post")
def test_api_call_uses_mm_for_height(mock_post, mock_api_response):
    """
    Critical: body_height must be in mm, not cm.
    Test that the service multiplies height_cm × 10 before calling the API.
    """
    mock_response = MagicMock()
    mock_response.json.return_value = mock_api_response
    mock_response.status_code = 200
    mock_post.return_value = mock_response
    
    from sizing_service import get_prediction
    get_prediction(gender="female", height_cm=168, weight_kg=62, region="GLOBAL")
    
    # Verify the API was called with height in mm
    call_args = mock_post.call_args
    body = call_args.kwargs.get("json") or call_args.args[1]
    
    anchors = body["input_data"]["anchors"]
    assert anchors["body_height"] == 1680, (
        f"body_height must be in mm (1680), got {anchors['body_height']}. "
        "Did you forget to multiply height_cm × 10?"
    )

@patch("sizing_service.requests.post")
def test_api_call_passes_region(mock_post, mock_api_response):
    """Test that the region parameter is passed to both input and output settings."""
    mock_response = MagicMock()
    mock_response.json.return_value = mock_api_response
    mock_response.status_code = 200
    mock_post.return_value = mock_response
    
    from sizing_service import get_prediction
    get_prediction(gender="male", height_cm=180, weight_kg=80, region="ASIA_PACIFIC")
    
    body = mock_post.call_args.kwargs.get("json") or mock_post.call_args.args[1]
    
    assert body["input_data"]["subject"]["input_origin_region"] == "ASIA_PACIFIC"
    assert body["output_settings"]["calculation"]["target_region"] == "ASIA_PACIFIC"

@patch("sizing_service.requests.post")
def test_api_error_handling(mock_post):
    """Test graceful handling of API errors."""
    mock_response = MagicMock()
    mock_response.status_code = 429
    mock_response.json.return_value = {"error": "Rate limit exceeded"}
    mock_post.return_value = mock_response
    
    from sizing_service import get_prediction
    
    result = get_prediction(gender="female", height_cm=168, weight_kg=62)
    
    # Should return a fallback, not raise an exception
    assert result.get("fallback") is True or result.get("error") is not None

Layer 2: Test fixtures from recorded API responses

Record real API responses once and use them as fixtures. This gives you realistic test data without hitting the API on every run.

# scripts/record_fixtures.py
"""Run once to record API responses for test fixtures. Not part of CI."""
import json
import requests
from pathlib import Path

FIXTURES_DIR = Path("tests/fixtures")
FIXTURES_DIR.mkdir(exist_ok=True)

TEST_CASES = [
    {"name": "female_168_62_global", "gender": "female", "height_cm": 168, "weight_kg": 62, "region": "GLOBAL"},
    {"name": "male_180_80_global", "gender": "male", "height_cm": 180, "weight_kg": 80, "region": "GLOBAL"},
    {"name": "female_155_50_asia", "gender": "female", "height_cm": 155, "weight_kg": 50, "region": "ASIA_PACIFIC"},
    {"name": "male_195_100_europe", "gender": "male", "height_cm": 195, "weight_kg": 100, "region": "EUROPE"},
    # Edge cases
    {"name": "female_145_45_global", "gender": "female", "height_cm": 145, "weight_kg": 45, "region": "GLOBAL"},
    {"name": "male_210_110_global", "gender": "male", "height_cm": 210, "weight_kg": 110, "region": "GLOBAL"},
]

for case in TEST_CASES:
    response = requests.post(
        "https://dimensionspot-bodysize-engine.p.rapidapi.com/v1/predict",
        json={
            "input_data": {
                "input_unit_system": "metric",
                "subject": {"gender": case["gender"], "input_origin_region": case["region"]},
                "anchors": {"body_height": int(case["height_cm"] * 10), "body_mass": case["weight_kg"]}
            },
            "output_settings": {
                "calculation": {"target_region": case["region"], "body_build_type": "CIVILIAN"},
                "requested_dimensions": {"bundle": "TORSO"},
                "output_format": {"include_range_95": True, "confidence_score_threshold": 50}
            }
        },
        headers={
            "X-RapidAPI-Key": "YOUR_API_KEY",
            "X-RapidAPI-Host": "dimensionspot-bodysize-engine.p.rapidapi.com"
        }
    )
    
    fixture_path = FIXTURES_DIR / f"{case['name']}.json"
    fixture_path.write_text(json.dumps(response.json(), indent=2))
    print(f"Saved fixture: {fixture_path}")

Layer 3: Property-based tests for size chart mapping

Property-based testing (using Hypothesis) verifies that your size chart mapping algorithm holds invariants across all possible inputs, not just the ones you thought to test manually.

# tests/test_size_mapping_properties.py
from hypothesis import given, strategies as st, settings
import pytest

SIZE_ORDER = ["XS", "S", "M", "L", "XL", "XXL"]

@given(
    chest_mm=st.integers(min_value=700, max_value=1500),
    waist_mm=st.integers(min_value=550, max_value=1400),
    hip_mm=st.integers(min_value=700, max_value=1600)
)
@settings(max_examples=500)
def test_size_recommendation_always_returns_valid_size(chest_mm, waist_mm, hip_mm):
    """
    Property: for any valid body measurement input, recommend_size
    always returns a valid size label and a confidence level.
    """
    from sizing_service import recommend_size
    
    result = recommend_size(
        chest_mm=chest_mm,
        waist_mm=waist_mm,
        hip_mm=hip_mm,
        chart_key="brand_a_womens_top"
    )
    
    assert result["recommended_size"] in SIZE_ORDER
    assert result["confidence"] in ("HIGH", "MEDIUM", "LOW")

@given(
    chest_mm=st.integers(min_value=700, max_value=1500)
)
@settings(max_examples=200)
def test_size_recommendation_monotonic_with_chest(chest_mm):
    """
    Property: larger chest measurements should recommend the same or larger size.
    This tests for monotonicity — a key correctness invariant.
    """
    from sizing_service import recommend_size
    
    result_base = recommend_size(chest_mm=chest_mm, waist_mm=750, hip_mm=950, chart_key="brand_a_womens_top")
    result_larger = recommend_size(chest_mm=chest_mm + 50, waist_mm=750, hip_mm=950, chart_key="brand_a_womens_top")
    
    base_idx = SIZE_ORDER.index(result_base["recommended_size"])
    larger_idx = SIZE_ORDER.index(result_larger["recommended_size"])
    
    # A 50mm larger chest should never recommend a smaller size
    assert larger_idx >= base_idx, (
        f"chest={chest_mm}: recommended {result_base['recommended_size']} "
        f"but chest={chest_mm + 50}: recommended {result_larger['recommended_size']} (smaller)"
    )

@given(
    size=st.sampled_from(SIZE_ORDER),
    chest_center_mm=st.integers(min_value=750, max_value=1200)
)
def test_recommendation_returns_expected_size_when_centered(size, chest_center_mm):
    """
    Property: if the chest measurement is at the center of a size's range,
    that size should be recommended with HIGH confidence.
    """
    from sizing_service import recommend_size, BRAND_CHART
    
    chart = BRAND_CHART.get("brand_a_womens_top", [])
    target_entry = next((e for e in chart if e.label == size), None)
    
    if target_entry is None or target_entry.chest_min_mm is None:
        return  # Skip if no chart entry for this size
    
    center_mm = (target_entry.chest_min_mm + target_entry.chest_max_mm) // 2
    
    result = recommend_size(
        chest_mm=center_mm,
        waist_mm=750,
        hip_mm=950,
        chart_key="brand_a_womens_top"
    )
    
    assert result["recommended_size"] == size
    assert result["confidence"] == "HIGH"

Layer 4: Integration tests (selective, against live API)

Run integration tests only in specific CI scenarios — not on every pull request, but on release branches or nightly.

# tests/integration/test_live_api.py
import pytest
import requests
import os

LIVE_API_KEY = os.environ.get("PREDICTION_API_KEY")

@pytest.mark.integration
@pytest.mark.skipif(not LIVE_API_KEY, reason="PREDICTION_API_KEY not set")
class TestLiveAPI:
    
    def test_basic_prediction_returns_dimensions(self):
        """Smoke test: the API returns predicted dimensions for valid inputs."""
        response = requests.post(
            "https://dimensionspot-bodysize-engine.p.rapidapi.com/v1/predict",
            json={
                "input_data": {
                    "input_unit_system": "metric",
                    "subject": {"gender": "female", "input_origin_region": "GLOBAL"},
                    "anchors": {"body_height": 1680, "body_mass": 62}
                },
                "output_settings": {
                    "calculation": {"target_region": "GLOBAL", "body_build_type": "CIVILIAN"},
                    "requested_dimensions": {"bundle": "TORSO"},
                    "output_format": {"include_range_95": True}
                }
            },
            headers={
                "X-RapidAPI-Key": LIVE_API_KEY,
                "X-RapidAPI-Host": "dimensionspot-bodysize-engine.p.rapidapi.com"
            },
            timeout=10
        )
        
        assert response.status_code == 200
        data = response.json()
        assert "body_dimensions" in data
        assert len(data["body_dimensions"]) > 0
        
        # Verify a specific dimension is in plausible range
        chest_dim = data["body_dimensions"].get("chest_circumference")
        assert chest_dim is not None
        assert 750 <= chest_dim["value"] <= 1200, f"Unexpected chest value: {chest_dim['value']}mm"
    
    def test_invalid_gender_returns_422(self):
        """Test that invalid input returns 422, not 500."""
        response = requests.post(
            "https://dimensionspot-bodysize-engine.p.rapidapi.com/v1/predict",
            json={
                "input_data": {
                    "input_unit_system": "metric",
                    "subject": {"gender": "unknown", "input_origin_region": "GLOBAL"},
                    "anchors": {"body_height": 1700, "body_mass": 70}
                },
                "output_settings": {
                    "calculation": {"target_region": "GLOBAL"},
                    "requested_dimensions": {"bundle": "TORSO"}
                }
            },
            headers={
                "X-RapidAPI-Key": LIVE_API_KEY,
                "X-RapidAPI-Host": "dimensionspot-bodysize-engine.p.rapidapi.com"
            }
        )
        
        assert response.status_code == 422

CI/CD configuration

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install -r requirements-test.txt
      - run: pytest tests/ -m "not integration" -v
        # Runs instantly, no API calls, no secrets needed
  
  integration-tests:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'  # Only on main branch
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install -r requirements-test.txt
      - run: pytest tests/integration/ -m "integration" -v
        env:
          PREDICTION_API_KEY: ${{ secrets.PREDICTION_API_KEY }}
        # Runs real API calls, only on main — controlled cost

The key insight is that most of your tests should not hit the live API. Record fixtures once, use them for all unit tests, and reserve live API calls for smoke tests on main branch. This gives you fast, cheap, reliable test coverage without sacrificing the ability to verify the actual API integration periodically.

Try DimensionsPot

Free tier — 100 requests/month, no credit card required.

Get API on RapidAPI