asmitha
Polish subscription intelligence portfolio project
fed039b
Raw
History Blame Contribute Delete
10 kB
"""
test_transforms.py - Unit Tests
----------------------------------
Tests for ETL validation logic and MRR calculations.
Supports dual-engine execution: tests PySpark if Java is available,
or falls back to testing the equivalent Pandas logic if Spark is missing.
Run:
pytest tests/ -v
"""
import pytest
import sys
import os
import pandas as pd
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
try:
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
from pyspark.sql.types import (
StructType, StructField, StringType,
IntegerType, TimestampType, BooleanType
)
except ImportError:
SparkSession = None
F = None
StructType = StructField = StringType = IntegerType = TimestampType = BooleanType = None
@pytest.fixture(scope="session")
def spark():
"""Single SparkSession shared across all tests. Returns None if Java is missing."""
import shutil
if SparkSession is not None and shutil.which("java") is not None:
try:
return (
SparkSession.builder
.appName("Tests")
.master("local[2]")
.config("spark.sql.shuffle.partitions", "2")
.config("spark.ui.enabled", "false")
.getOrCreate()
)
except Exception:
pass
return None
# Test data.
if StructType is not None:
EVENTS_SCHEMA = StructType([
StructField("event_id", StringType(), False),
StructField("tenant_id", StringType(), False),
StructField("event_type", StringType(), False),
StructField("plan", StringType(), True),
StructField("amount", IntegerType(), True),
StructField("currency", StringType(), True),
StructField("event_date", TimestampType(), True),
StructField("status", StringType(), True),
StructField("invoice_id", StringType(), True),
StructField("seats", IntegerType(), True),
StructField("trial", BooleanType(), True),
])
else:
EVENTS_SCHEMA = None
SAMPLE_EVENTS = [
("EVT_001", "TNT_001", "invoice_paid", "starter", 299, "INR", "2024-01-15 10:00:00", "success", "INV_001", 2, False),
("EVT_002", "TNT_001", "invoice_paid", "starter", 299, "INR", "2024-02-15 10:00:00", "success", "INV_002", 2, False),
("EVT_003", "TNT_002", "invoice_paid", "growth", 999, "INR", "2024-01-20 10:00:00", "success", "INV_003", 5, False),
("EVT_004", "TNT_001", "subscription_cancelled", "starter", 0, "INR", "2024-03-01 10:00:00", "success", "INV_004", 2, False),
("EVT_005", "TNT_003", "invoice_paid", "business", 2499, "INR", "2024-01-25 10:00:00", "success", "INV_005", 10, False),
("EVT_006", "TNT_999", "invoice_paid", "INVALID", -100, "INR", "2024-01-01 10:00:00", "success", "INV_006", 1, False),
]
def make_events_df(spark, rows=None):
data = rows or SAMPLE_EVENTS
# Parse timestamps from string
from pyspark.sql import functions as F
raw = spark.createDataFrame(data, schema=[
"event_id", "tenant_id", "event_type", "plan", "amount",
"currency", "event_date_str", "status", "invoice_id", "seats", "trial"
])
return raw.withColumn("event_date", F.to_timestamp("event_date_str", "yyyy-MM-dd HH:mm:ss")).drop("event_date_str")
def make_events_df_pandas(rows=None):
data = rows or SAMPLE_EVENTS
df = pd.DataFrame(data, columns=[
"event_id", "tenant_id", "event_type", "plan", "amount",
"currency", "event_date_str", "status", "invoice_id", "seats", "trial"
])
df["event_date"] = pd.to_datetime(df["event_date_str"])
return df.drop(columns=["event_date_str"])
# Tests.
class TestValidation:
def test_negative_amount_rejected(self, spark):
"""Rows with negative amounts must be flagged as invalid."""
if spark is None:
df = make_events_df_pandas()
invalid = df[df["amount"] < 0]
assert len(invalid) == 1, "Expected exactly 1 row with negative amount"
else:
df = make_events_df(spark)
invalid = df.filter(F.col("amount") < 0)
assert invalid.count() == 1, "Expected exactly 1 row with negative amount"
def test_valid_events_count(self, spark):
"""Non-negative, known event_type rows should pass validation."""
valid_types = {"subscription_created", "subscription_upgraded",
"subscription_downgraded", "subscription_cancelled",
"invoice_paid", "invoice_failed", "trial_started", "trial_converted"}
if spark is None:
df = make_events_df_pandas()
clean = df[(df["amount"] >= 0) & (df["event_type"].isin(valid_types))]
assert len(clean) == 5, f"Expected 5 clean rows, got {len(clean)}"
else:
df = make_events_df(spark)
clean = df.filter(
F.col("amount") >= 0
).filter(
F.col("event_type").isin(list(valid_types))
)
assert clean.count() == 5, f"Expected 5 clean rows, got {clean.count()}"
def test_no_null_tenant_ids(self, spark):
"""All sample rows should have non-null tenant_id."""
if spark is None:
df = make_events_df_pandas()
nulls = df["tenant_id"].isna().sum()
assert nulls == 0
else:
df = make_events_df(spark)
nulls = df.filter(F.col("tenant_id").isNull()).count()
assert nulls == 0
class TestMRRCalculation:
def test_mrr_sum_correct(self, spark):
"""MRR for January should be 299 + 999 + 2499 = 3797."""
if spark is None:
df = make_events_df_pandas()
jan_mrr = df[
(df["event_type"] == "invoice_paid") &
(df["status"] == "success") &
(df["event_date"].dt.month == 1) &
(df["amount"] >= 0)
]["amount"].sum()
assert jan_mrr == 3797, f"Expected 3797, got {jan_mrr}"
else:
df = make_events_df(spark)
jan_mrr = (
df
.filter(F.col("event_type") == "invoice_paid")
.filter(F.col("status") == "success")
.filter(F.month("event_date") == 1)
.filter(F.col("amount") >= 0)
.agg(F.sum("amount").alias("total"))
.collect()[0]["total"]
)
assert jan_mrr == 3797, f"Expected 3797, got {jan_mrr}"
def test_mrr_per_tenant(self, spark):
"""TNT_001 should have MRR of 299 in January."""
if spark is None:
df = make_events_df_pandas()
tnt001_jan = df[
(df["tenant_id"] == "TNT_001") &
(df["event_type"] == "invoice_paid") &
(df["event_date"].dt.month == 1)
]["amount"].sum()
assert tnt001_jan == 299
else:
df = make_events_df(spark)
tnt001_jan = (
df
.filter(F.col("tenant_id") == "TNT_001")
.filter(F.col("event_type") == "invoice_paid")
.filter(F.month("event_date") == 1)
.agg(F.sum("amount"))
.collect()[0][0]
)
assert tnt001_jan == 299
def test_cancelled_tenant_zero_revenue(self, spark):
"""Cancelled event should contribute 0 to revenue."""
if spark is None:
df = make_events_df_pandas()
cancel_revenue = df[df["event_type"] == "subscription_cancelled"]["amount"].sum()
assert cancel_revenue == 0
else:
df = make_events_df(spark)
cancel_revenue = (
df
.filter(F.col("event_type") == "subscription_cancelled")
.agg(F.sum("amount"))
.collect()[0][0]
)
assert cancel_revenue == 0 or cancel_revenue is None
class TestChurnLogic:
def test_churn_event_count(self, spark):
"""Sample data has 1 cancellation event."""
if spark is None:
df = make_events_df_pandas()
churn_count = len(df[df["event_type"] == "subscription_cancelled"])
assert churn_count == 1
else:
df = make_events_df(spark)
churn_count = df.filter(F.col("event_type") == "subscription_cancelled").count()
assert churn_count == 1
def test_churn_rate_below_100(self, spark):
"""Churn rate should never exceed 100%."""
if spark is None:
df = make_events_df_pandas()
active = len(df[df["event_type"] == "invoice_paid"])
churned = len(df[df["event_type"] == "subscription_cancelled"])
rate = (churned / active * 100) if active > 0 else 0
assert rate <= 100
else:
df = make_events_df(spark)
active = df.filter(F.col("event_type") == "invoice_paid").count()
churned = df.filter(F.col("event_type") == "subscription_cancelled").count()
rate = (churned / active * 100) if active > 0 else 0
assert rate <= 100
class TestEnrichment:
def test_year_month_extraction(self, spark):
"""event_year and event_month should be correctly extracted."""
if spark is None:
df = make_events_df_pandas()
df["yr"] = df["event_date"].dt.year
df["mo"] = df["event_date"].dt.month
jan_rows = len(df[df["mo"] == 1])
assert jan_rows == 4 # EVT_001, EVT_003, EVT_005, and EVT_006 are in January
else:
df = make_events_df(spark)
enriched = df.withColumn("yr", F.year("event_date")).withColumn("mo", F.month("event_date"))
jan_rows = enriched.filter(F.col("mo") == 1).count()
assert jan_rows == 4 # EVT_001, EVT_003, EVT_005, and EVT_006 are in January