"""Tests for the predictor module.
A small model is trained on synthetic data and saved to a temp path, so tests
never depend on the real (gitignored) model artifact.
"""
import time
import pandas as pd
import pytest
from app.ml import train
from app.models import predictor
from app.models.data_loader import MODEL_FEATURES
def _make_features(positive: bool = True) -> dict:
"""Build a complete, valid feature dict for one customer."""
return {
"job": "student" if positive else "admin.",
"marital": "single" if positive else "married",
"education": "university.degree",
"default": "no",
"housing": "yes",
"loan": "no",
"contact": "cellular",
"month": "may",
"day_of_week": "mon",
"poutcome": "success" if positive else "failure",
"age": 25 if positive else 50,
"duration": 800 if positive else 100,
"campaign": 1,
"pdays": 5 if positive else 999,
"previous": 2 if positive else 0,
"emp_var_rate": -1.8 if positive else 1.4,
"cons_price_index": 92.0,
"cons_conf_index": -40.0,
"lending_rate3m": 1.0,
"nr_employed": 5000.0,
}
@pytest.fixture
def model_path(tmp_path):
"""Train a tiny model on synthetic data and return its file path as str."""
rows = [_make_features(positive=(i % 3 == 0)) for i in range(60)]
df = pd.DataFrame(rows)
df["subscribe"] = ["yes" if i % 3 == 0 else "no" for i in range(60)]
pipeline, _ = train.train_model(df)
out = tmp_path / "model.pkl"
train.save_model(pipeline, path=out)
return str(out)
class TestLoadModel:
def test_missing_model_raises(self, tmp_path):
missing = str(tmp_path / "nope.pkl")
with pytest.raises(predictor.ModelNotFoundError):
predictor.load_model(missing)
def test_load_returns_payload(self, model_path):
payload = predictor.load_model(model_path)
assert "pipeline" in payload
assert payload["feature_columns"] == MODEL_FEATURES
class TestPredict:
def test_returns_expected_keys(self, model_path):
result = predictor.predict(_make_features(), model_path=model_path)
assert set(result.keys()) == {"subscribe", "probability", "confidence"}
def test_types(self, model_path):
result = predictor.predict(_make_features(), model_path=model_path)
assert isinstance(result["subscribe"], bool)
assert isinstance(result["probability"], float)
assert isinstance(result["confidence"], str)
def test_probability_in_range(self, model_path):
result = predictor.predict(_make_features(), model_path=model_path)
assert 0.0 <= result["probability"] <= 1.0
def test_subscribe_consistent_with_probability(self, model_path):
result = predictor.predict(_make_features(), model_path=model_path)
assert result["subscribe"] == (result["probability"] >= 0.5)
def test_missing_feature_raises(self, model_path):
features = _make_features()
del features["age"]
with pytest.raises(ValueError, match="Missing required features"):
predictor.predict(features, model_path=model_path)
def test_invalid_numeric_raises(self, model_path):
features = _make_features()
features["age"] = "not-a-number"
with pytest.raises(ValueError, match="must be numeric"):
predictor.predict(features, model_path=model_path)
def test_unknown_category_handled(self, model_path):
features = _make_features()
features["job"] = "astronaut" # unseen category
result = predictor.predict(features, model_path=model_path)
assert 0.0 <= result["probability"] <= 1.0
def test_response_under_one_second(self, model_path):
predictor.predict(_make_features(), model_path=model_path) # warm cache
start = time.perf_counter()
predictor.predict(_make_features(), model_path=model_path)
assert (time.perf_counter() - start) < 1.0
class TestConfidenceLabel:
@pytest.mark.parametrize(
"prob,expected",
[
(0.95, "high"),
(0.05, "high"),
(0.7, "medium"),
(0.3, "medium"),
(0.5, "low"),
(0.55, "low"),
],
)
def test_confidence_labels(self, prob, expected):
assert predictor._confidence_label(prob) == expected