Module 3: Integration Testing & Non-Deterministic Strategies
7. Project: Integration Test Suite
Description
This is the Module 3 mini-project. You'll expand the test suite from Modules 1-2 with: integration tests that call the real LLM with budget controls, semantic similarity assertions, property-based tests with Hypothesis, flaky test management, and a three-environment setup (mock/sandbox/real). When you finish you'll have a complete suite: deterministic unit tests + integration tests that handle non-determinism robustly.
Project objectives
Upon completing this project you'll have:
- Budget tracker that limits spending on API calls during tests
- E2E tests with the real LLM and flexible assertions (maximum $0.50 per run)
- At least 2 tests with a semantic similarity assertion
- At least 2 tests with Hypothesis (property-based, with a mock)
- Flaky management: retry on unstable tests, quarantine for the problematic ones
- Environment setup: mock by default, integration when there's an API key
- Basic CI/CD: GitHub Actions configuration
Project structure
your-project/
├── src/
│ └── app/
│ ├── __init__.py
│ ├── config.py
│ ├── parsers.py
│ ├── processors.py
│ ├── sentiment.py
│ └── main.py
├── tests/
│ ├── __init__.py
│ ├── helpers.py # create_openai_chat_response, etc.
│ ├── semantic.py # assert_semantically_similar, etc.
│ ├── conftest.py # Shared fixtures: budget, clients
│ ├── unit/ # M1-M2: contract, parsers, regression
│ │ ├── conftest.py
│ │ ├── contracts/
│ │ ├── parsers/
│ │ └── regression/
│ └── integration/ # M3: E2E, semantic, property
│ ├── __init__.py
│ ├── conftest.py # Integration-specific fixtures
│ ├── test_e2e.py # E2E with the real LLM
│ ├── test_semantic.py # Semantic similarity assertions
│ └── test_property.py # Property-based with Hypothesis
├── .github/
│ └── workflows/
│ └── tests.yml # CI/CD config
├── pytest.ini
└── requirements.txt
Step 1: Update tests/semantic.py
# tests/semantic.py
import numpy as np
from functools import lru_cache
from typing import Optional
def cosine_similarity(a: list[float], b: list[float]) -> float:
a_arr = np.array(a, dtype=float)
b_arr = np.array(b, dtype=float)
norm_a = np.linalg.norm(a_arr)
norm_b = np.linalg.norm(b_arr)
if norm_a == 0 or norm_b == 0:
return 0.0
return float(np.dot(a_arr, b_arr) / (norm_a * norm_b))
@lru_cache(maxsize=1)
def _get_local_model():
"""Loads the sentence-transformers model once."""
try:
from sentence_transformers import SentenceTransformer
return SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
except ImportError:
return None
_embedding_cache: dict[str, list[float]] = {}
def get_embedding_local(text: str) -> list[float]:
"""Local embedding with sentence-transformers. No API cost."""
if text in _embedding_cache:
return _embedding_cache[text]
model = _get_local_model()
if model is None:
raise ImportError("sentence-transformers not installed. Run: pip install sentence-transformers")
embedding = model.encode(text, normalize_embeddings=True)
_embedding_cache[text] = embedding.tolist()
return _embedding_cache[text]
def get_embedding_openai(text: str, client) -> list[float]:
"""Embedding via the OpenAI API."""
if text in _embedding_cache:
return _embedding_cache[text]
response = client.embeddings.create(
model="text-embedding-3-small",
input=text.strip()
)
embedding = response.data[0].embedding
_embedding_cache[text] = embedding
return embedding
def assert_semantically_similar(
actual: str,
expected: str,
threshold: float = 0.80,
client=None,
message: str = None
) -> float:
"""
Semantic assertion: fails if actual and expected are not similar enough.
Args:
actual: The real LLM output
expected: The expected meaning (description of the expected content)
threshold: Minimum acceptable similarity (default: 0.80)
client: OpenAI client (if None, uses local sentence-transformers)
message: Custom error message
Returns:
The computed similarity (useful for debugging)
"""
if client is not None:
emb_actual = get_embedding_openai(actual, client)
emb_expected = get_embedding_openai(expected, client)
else:
emb_actual = get_embedding_local(actual)
emb_expected = get_embedding_local(expected)
similarity = cosine_similarity(emb_actual, emb_expected)
default_msg = (
f"Semantic similarity {similarity:.3f} < threshold {threshold}\n"
f" Actual: '{actual[:100]}'\n"
f" Expected: '{expected[:100]}'"
)
assert similarity >= threshold, message or default_msg
return similarity
Step 2: Update tests/conftest.py
# tests/conftest.py
import pytest
import os
import threading
from unittest.mock import MagicMock, AsyncMock
import json
from tests.helpers import create_openai_chat_response, create_sentiment_response
# ─── Budget Tracker ────────────────────────────────────────────────────────
class BudgetTracker:
PRICES = {
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
"gpt-4o": {"input": 2.50, "output": 10.00},
}
def __init__(self, max_usd: float = 0.50):
self.max_usd = max_usd
self.spent = 0.0
self._lock = threading.Lock()
self.calls = []
def add_cost(self, model: str, prompt_tokens: int, completion_tokens: int) -> float:
prices = self.PRICES.get(model, self.PRICES["gpt-4o-mini"])
cost = (
prompt_tokens / 1_000_000 * prices["input"] +
completion_tokens / 1_000_000 * prices["output"]
)
with self._lock:
self.spent += cost
self.calls.append({"model": model, "cost": cost})
return cost
def check_budget(self):
if self.spent >= self.max_usd:
pytest.skip(f"E2E budget exceeded: ${self.spent:.4f} >= ${self.max_usd:.2f}")
@pytest.fixture(scope="session")
def e2e_budget():
max_budget = float(os.getenv("E2E_BUDGET_USD", "0.50"))
tracker = BudgetTracker(max_usd=max_budget)
yield tracker
print(f"\n💰 E2E Budget: ${tracker.spent:.4f} / ${tracker.max_usd:.2f} ({len(tracker.calls)} calls)")
# ─── Integration Client ─────────────────────────────────────────────────────
@pytest.fixture(scope="session")
def integration_client(e2e_budget):
"""
Real OpenAI client for integration tests.
Automatic skip if there's no API key or the budget was exceeded.
"""
api_key = os.getenv("OPENAI_API_KEY")
run_integration = os.getenv("RUN_INTEGRATION", "false").lower() == "true"
if not api_key:
pytest.skip("OPENAI_API_KEY not configured — skipping integration tests")
if not run_integration and not os.getenv("FULL_VALIDATION"):
pytest.skip("RUN_INTEGRATION not enabled — use RUN_INTEGRATION=true for integration")
e2e_budget.check_budget()
import openai
return openai.OpenAI(api_key=api_key)
# ─── Mock Factories ─────────────────────────────────────────────────────────
@pytest.fixture
def make_sentiment_client():
def _create(sentiment="neutral", score=0.5, explanation="Test analysis", keywords=None):
if keywords is None:
keywords = []
client = MagicMock()
client.chat.completions.create.return_value = create_sentiment_response(
sentiment=sentiment, score=score, explanation=explanation, keywords=keywords
)
return client
return _create
@pytest.fixture
def make_error_client():
import openai
def _create(error_type="generic"):
client = MagicMock()
errors = {
"rate_limit": openai.RateLimitError("Rate limit", response=MagicMock(status_code=429), body={}),
"timeout": openai.APITimeoutError(request=MagicMock()),
"connection": openai.APIConnectionError(request=MagicMock()),
}
if error_type == "empty_response":
client.chat.completions.create.return_value = create_openai_chat_response("")
elif error_type in errors:
client.chat.completions.create.side_effect = errors[error_type]
else:
client.chat.completions.create.side_effect = Exception(f"Error: {error_type}")
return client
return _create
Step 3: tests/integration/conftest.py
# tests/integration/conftest.py
import pytest
import os
@pytest.fixture(autouse=True)
def check_integration_available(request):
"""
Auto-fixture: automatically verifies whether integration tests can run.
Only acts on tests marked with @pytest.mark.integration.
"""
if request.node.get_closest_marker("integration"):
api_key = os.getenv("OPENAI_API_KEY")
run_integration = (
os.getenv("RUN_INTEGRATION", "false").lower() == "true" or
os.getenv("FULL_VALIDATION", "false").lower() == "true"
)
if not api_key or not run_integration:
pytest.skip(
"Integration tests disabled. To enable:\n"
" export OPENAI_API_KEY=sk-...\n"
" export RUN_INTEGRATION=true"
)
Step 4: tests/integration/test_e2e.py
# tests/integration/test_e2e.py
import pytest
import os
from app.sentiment import analyze_sentiment
@pytest.mark.integration
@pytest.mark.e2e
class TestSentimentE2E:
"""
E2E tests for the sentiment analyzer with the real LLM.
Assertions are flexible — they verify properties, not exact values.
"""
@pytest.mark.timeout(30)
def test_positive_sentiment_detection(self, integration_client, e2e_budget):
"""Clearly positive text → positive sentiment."""
text = "I love this product, it's absolutely incredible and I recommend it."
result = analyze_sentiment(text, client=integration_client)
# Record the estimated cost
e2e_budget.add_cost("gpt-4o-mini", prompt_tokens=100, completion_tokens=50)
# Property assertions
assert result["sentiment"] in ["positive", "negative", "neutral"]
assert 0 <= result["score"] <= 1
# For such positive text, the score must be high
assert result["score"] >= 0.6, \
f"For clearly positive text, score must be >=0.6. Got: {result['score']}"
@pytest.mark.timeout(30)
def test_negative_sentiment_detection(self, integration_client, e2e_budget):
"""Clearly negative text → negative sentiment."""
text = "Terrible experience, the worst product I've bought. Horrible quality."
result = analyze_sentiment(text, client=integration_client)
e2e_budget.add_cost("gpt-4o-mini", prompt_tokens=100, completion_tokens=50)
assert result["sentiment"] == "negative"
assert result["score"] <= 0.4
@pytest.mark.timeout(30)
def test_neutral_factual_text(self, integration_client, e2e_budget):
"""Factual text with no emotional charge → neutral sentiment."""
text = "The product arrived in a box. It has dimensions of 30x20x10 cm."
result = analyze_sentiment(text, client=integration_client)
e2e_budget.add_cost("gpt-4o-mini", prompt_tokens=80, completion_tokens=50)
assert result["sentiment"] in ["neutral", "positive"] # Can go either way
assert isinstance(result["keywords"], list)
@pytest.mark.timeout(30)
def test_full_pipeline_no_crash(self, integration_client, e2e_budget):
"""The complete pipeline doesn't crash for any valid text."""
texts = [
"normal text",
"How great!",
"12345 numbers",
"text in english is also fine",
"Short text"
]
for text in texts:
result = analyze_sentiment(text, client=integration_client)
e2e_budget.add_cost("gpt-4o-mini", prompt_tokens=60, completion_tokens=40)
assert isinstance(result, dict), f"Pipeline didn't return a dict for: '{text}'"
assert "sentiment" in result, f"Missing 'sentiment' key for: '{text}'"
@pytest.mark.timeout(15)
def test_short_text_handled(self, integration_client, e2e_budget):
"""Very short texts are handled without crashing."""
result = analyze_sentiment("Good", client=integration_client)
e2e_budget.add_cost("gpt-4o-mini", prompt_tokens=50, completion_tokens=40)
assert result["sentiment"] in ["positive", "negative", "neutral"]
Step 5: tests/integration/test_semantic.py
# tests/integration/test_semantic.py
import pytest
from app.sentiment import analyze_sentiment
from tests.semantic import assert_semantically_similar
@pytest.mark.integration
class TestSentimentSemanticQuality:
"""
Semantic quality tests using embeddings to compare meaning.
They use local sentence-transformers (no additional API cost).
"""
def test_positive_explanation_is_positive(self, integration_client, e2e_budget):
"""
The explanation for positive text must be semantically positive.
"""
result = analyze_sentiment(
"Excellent quality, it exceeded all my expectations",
client=integration_client
)
e2e_budget.add_cost("gpt-4o-mini", 100, 60)
# Verify that the explanation is semantically positive
assert_semantically_similar(
actual=result.get("explanation", ""),
expected="The text expresses satisfaction and positive appraisal",
threshold=0.65, # Flexible — many ways to express positivity
client=None # Local embeddings to save cost
)
def test_negative_explanation_is_negative(self, integration_client, e2e_budget):
"""
The explanation for negative text must be semantically negative.
"""
result = analyze_sentiment(
"Terrible, disappointing, I wouldn't recommend it to anyone",
client=integration_client
)
e2e_budget.add_cost("gpt-4o-mini", 100, 60)
assert_semantically_similar(
actual=result.get("explanation", ""),
expected="The text uses negative language and expresses dissatisfaction",
threshold=0.60,
client=None
)
def test_explanations_for_opposite_sentiments_are_different(
self, integration_client, e2e_budget
):
"""
The explanations for opposite sentiments must be semantically different.
"""
from tests.semantic import get_embedding_local, cosine_similarity
result_pos = analyze_sentiment("Incredible, wonderful, perfect", client=integration_client)
result_neg = analyze_sentiment("Terrible, horrible, disappointing", client=integration_client)
e2e_budget.add_cost("gpt-4o-mini", 200, 120)
if result_pos.get("explanation") and result_neg.get("explanation"):
emb_pos = get_embedding_local(result_pos["explanation"])
emb_neg = get_embedding_local(result_neg["explanation"])
similarity = cosine_similarity(emb_pos, emb_neg)
assert similarity < 0.70, (
f"Explanations for opposite sentiments are too similar: {similarity:.3f}\n"
f"Positive: {result_pos['explanation']}\n"
f"Negative: {result_neg['explanation']}"
)
Step 6: tests/integration/test_property.py
# tests/integration/test_property.py
import pytest
import json
from hypothesis import given, settings
import hypothesis.strategies as st
from unittest.mock import MagicMock
from tests.helpers import create_openai_chat_response
# Strategy for valid LLM outputs
valid_sentiment_output = st.fixed_dictionaries({
"sentiment": st.sampled_from(["positive", "negative", "neutral"]),
"score": st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
"explanation": st.text(max_size=200),
"keywords": st.lists(
st.text(min_size=1, max_size=30, alphabet=st.characters(
whitelist_categories=("L", "N", "Zs")
)),
max_size=5
)
})
@given(llm_output=valid_sentiment_output)
@settings(max_examples=50)
def test_pipeline_contract_for_any_valid_llm_output(llm_output):
"""
PROPERTY: For any valid LLM output (mocked),
the pipeline produces a result that satisfies the contract.
"""
from app.sentiment import analyze_sentiment
mock_client = MagicMock()
mock_client.chat.completions.create.return_value = create_openai_chat_response(
json.dumps(llm_output, ensure_ascii=False)
)
result = analyze_sentiment("test text", client=mock_client)
assert result["sentiment"] in ["positive", "negative", "neutral"]
assert 0.0 <= result["score"] <= 1.0
assert isinstance(result["keywords"], list)
assert isinstance(result["explanation"], str)
@given(raw_score=st.floats(allow_nan=False, allow_infinity=False))
def test_processor_score_always_clamped(raw_score):
"""
PROPERTY: For any input score, the processor always returns [0, 1].
"""
from app.processors import process_sentiment_output
result = process_sentiment_output({"sentiment": "neutral", "score": raw_score})
assert 0.0 <= result["score"] <= 1.0, \
f"Score {raw_score} → {result['score']} — not in [0, 1]"
@given(sentiment=st.text(max_size=100))
def test_processor_sentiment_always_valid(sentiment):
"""
PROPERTY: For any input sentiment, the result is always one of the three valid ones.
"""
from app.processors import process_sentiment_output
result = process_sentiment_output({"sentiment": sentiment, "score": 0.5})
assert result["sentiment"] in ["positive", "negative", "neutral"]
@given(raw_input=st.one_of(
st.just(""),
st.just(" "),
st.just("\n\n"),
))
def test_parser_empty_always_raises(raw_input):
"""
PROPERTY: Any empty/whitespace input always raises ValueError.
"""
from app.parsers import parse_json_response
with pytest.raises(ValueError):
parse_json_response(raw_input)
@given(
content=st.fixed_dictionaries({
"x": st.integers(min_value=-1000, max_value=1000),
"y": st.text(min_size=0, max_size=100)
})
)
def test_parser_roundtrip(content):
"""
PROPERTY: json.dumps → parse_json_response → same result.
"""
from app.parsers import parse_json_response
raw = json.dumps(content)
result = parse_json_response(raw)
assert result["x"] == content["x"]
assert result["y"] == content["y"]
Step 7: Update pytest.ini
[pytest]
testpaths = tests
addopts = -v --tb=short
markers =
unit: Unit tests (deterministic, no API calls)
integration: Tests with the real LLM (require OPENAI_API_KEY and RUN_INTEGRATION=true)
e2e: End-to-end tests of the complete flow
contract: Prompt contract tests
property: Property-based tests with Hypothesis
semantic: Tests with semantic similarity assertions
flaky: Tests with retry enabled due to LLM variance
quarantine: Unstable tests — they don't run in regular CI
smoke: Basic smoke tests
# By default: only unit tests (no integration, no quarantine)
# For integration: pytest -m integration
# For everything except quarantine: pytest -m "not quarantine"
Step 8: CI/CD with GitHub Actions
# .github/workflows/tests.yml
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
schedule:
- cron: "0 2 * * *" # Nightly at 2am UTC
jobs:
unit-tests:
name: Unit Tests (always)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: "3.11"
- run: pip install -r requirements.txt
- run: pytest -m "not integration and not quarantine" -v --tb=short
# No API key, no cost, deterministic
integration-tests:
name: Integration Tests (main only)
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
RUN_INTEGRATION: "true"
E2E_BUDGET_USD: "0.25"
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: "3.11"
- run: pip install -r requirements.txt
- run: pytest -m "integration and not quarantine" --timeout=60 -v --tb=short
nightly-full:
name: Full Test Suite (nightly)
runs-on: ubuntu-latest
if: github.event_name == 'schedule'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
RUN_INTEGRATION: "true"
FULL_VALIDATION: "true"
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: "3.11"
- run: pip install -r requirements.txt
- run: pytest -v --timeout=120 # Includes quarantine for monitoring
Step 9: Final verification
# 1. Verify that the unit tests pass (without an API key)
pytest -m "not integration" -v --tb=short
# Expected: 50+ passed, 0 failed, 0 errors
# 2. With an API key: verify integration tests
export OPENAI_API_KEY=sk-...
export RUN_INTEGRATION=true
pytest -m integration -v --timeout=60 --tb=short
# Expected: the tests run (they don't get skipped), some may take 5-10s
# 3. Property-based tests
pytest -m property -v --tb=short
# Expected: tests run with multiple Hypothesis examples
# 4. Full coverage
pytest -m "not integration" --cov=app --cov-report=term-missing
# Expected: >80% coverage in parsers and processors
# 5. Timing: unit tests must be fast
time pytest -m "not integration" -q
# Expected: <15 seconds
Delivery checklist
Tests written
- At least 4 E2E tests with the real LLM in
test_e2e.py - At least 2 tests with semantic similarity in
test_semantic.py - At least 4 property-based tests in
test_property.py(with a mock) - Flaky management tests: at least 1 test with
@pytest.mark.flaky - Property tests for parsers and processors
Quality
- Budget tracker implemented and working
- Automatic skip when there's no API key
- Flexible assertions in integration tests (no exact equality)
- Property-based tests with clear, specific properties
- At least 1 test in quarantine with a documented reason
Infrastructure
-
pytest.iniwith all markers registered - CI/CD configured: unit on every push, integration only on main
- README documentation on how to run each type
Additional exercises
Exercise 1: Add a second prompt
Add a classify_document(text) prompt that returns {category, confidence, tags}. Write:
- Contract test (unit, with a mock)
- E2E test (integration, with the real LLM)
- Semantic test for the category
See guide
# Contract test (M2 style):
def test_classify_contract(make_classification_client):
client = make_classification_client(category="technology", confidence=0.9)
result = classify_document("Article about Python", client=client)
assert result["category"] in VALID_CATEGORIES
assert 0 <= result["confidence"] <= 1
# E2E test (M3 style):
@pytest.mark.integration
def test_classify_e2e(integration_client, e2e_budget):
result = classify_document("Python is a programming language", client=integration_client)
e2e_budget.add_cost("gpt-4o-mini", 100, 50)
assert result["category"] in VALID_CATEGORIES
# Semantic test:
@pytest.mark.integration
def test_classify_semantic_category(integration_client, e2e_budget):
result = classify_document("Python is a programming language", client=integration_client)
e2e_budget.add_cost("gpt-4o-mini", 100, 60)
assert_semantically_similar(
actual=result["category"],
expected="technology or programming",
threshold=0.65
)
Exercise 2: Model drift test
Design a test that detects whether the model produces results very different from a saved reference set:
See guide
# tests/integration/test_drift.py
REFERENCE_CASES = [
{"input": "I love it", "expected_sentiment": "positive"},
{"input": "I hate it", "expected_sentiment": "negative"},
{"input": "It arrived on Wednesday", "expected_sentiment": "neutral"},
]
@pytest.mark.integration
def test_model_drift(integration_client, e2e_budget):
"""Detects whether the model produces sentiments different from the reference ones."""
mismatches = []
for case in REFERENCE_CASES:
result = analyze_sentiment(case["input"], client=integration_client)
e2e_budget.add_cost("gpt-4o-mini", 60, 40)
if result["sentiment"] != case["expected_sentiment"]:
mismatches.append({
"input": case["input"],
"expected": case["expected_sentiment"],
"got": result["sentiment"]
})
assert len(mismatches) == 0, (
f"Model drift detected: {len(mismatches)}/{len(REFERENCE_CASES)} cases changed.\n"
+ "\n".join(f" {m['input']}: {m['expected']} → {m['got']}" for m in mismatches)
)
Project summary
Upon completing this project you have:
| Type | Tests | Strategy | Execution |
|---|---|---|---|
| Unit (M2) | 50+ | Mock, deterministic | Always |
| E2E | 5+ | Real LLM, flexible assertions | main + nightly |
| Semantic | 3+ | sentence-transformers, calibrated threshold | main + nightly |
| Property | 5+ | Hypothesis, with a mock | Always |
| Total | 65+ | Mix of strategies | Depending on the type |
Cost per integration run: <$0.02 with gpt-4o-mini Total unit test time: <15 seconds Total time with integration: 1-3 minutes
Additional resources
- pytest-rerunfailures — Retry for flaky tests
- sentence-transformers — Local embeddings for semantic assertions
- Hypothesis — Property-based testing
- GitHub Actions — CI/CD configuration
- OpenAI Usage — Monitor real API costs
- Module 4: Guardrails — Next step