Testing Guide¶
Test Philosophy¶
All code in villaCalculateCost4 is written test-first following the RED-GREEN-REFACTOR cycle:
- RED -- Write a failing test that describes the desired behavior.
- GREEN -- Write the minimum code to make the test pass.
- REFACTOR -- Clean up the implementation while keeping tests green.
This is not aspirational. The entire codebase was built this way. Every model, formula, adapter, and handler method has a corresponding test that was written before the production code existed.
The test suite enforces two invariants:
- Core purity -- No test in
test/core/importsboto3,requests,pynamodb, or any AWS/HTTP library. If a core test needs external data, it uses a mock service injected via the constructor. - Output equivalence -- Integration tests compare the response of villaCalculateCost4 against the deployed legacy endpoint field-by-field. The two must produce identical results.
Test Tiers¶
Tier 1: Unit Tests (test/core/, test/interfaces/)¶
Purpose: Validate pure business logic with zero external dependencies.
Characteristics:
- No AWS credentials needed
- No network access needed
- All services are mock implementations
- Execution time: < 2 seconds for the full suite
- Run anywhere: CI, local machine, air-gapped laptop
What they cover: Data model construction and serialization, financial formulas, weight calculation, shipping fee routing, coupon payload building, calculator orchestration end-to-end, and protocol conformance.
Tier 2: Adapter Unit Tests (test/adapters/)¶
Purpose: Validate that adapters correctly translate between protocol interfaces and external service APIs.
Characteristics:
- All external calls are mocked (boto3, requests)
- No real AWS credentials or network access needed
- Execution time: < 1 second
- Tests focus on request construction, response parsing, error mapping, and caching behavior
Tier 3: Integration Tests (test/integration/)¶
Purpose: Validate end-to-end behavior against real deployed services.
Characteristics:
- Requires AWS credentials with cross-account access
- Requires network access to deployed endpoints
- Execution time: ~2 minutes (serial external calls)
- Uses real production order data as fixtures
- Compares responses against the deployed legacy endpoint
Tier 4: Equivalence Tests (test/test_equivalence.py)¶
Purpose: Prove that villaCalculateCost4 produces byte-identical output to the deployed legacy system.
Characteristics:
- Submits the same order payload to both endpoints
- Compares every field in both responses
- Covers edge cases from real paid orders (coupons, vouchers, nationwide shipping, express, pickup)
- Serves as the acceptance gate for production deployment
Running Tests¶
Unit tests only (no AWS needed)¶
Integration tests (needs AWS credentials + network)¶
All tests¶
With coverage report¶
pytest test/interfaces/ test/core/ test/adapters/ test/test_handler.py \
--cov=calculatecost4 \
--cov-branch \
--cov-report=term-missing
Quick smoke test (fastest feedback loop)¶
Test Structure¶
Model Tests¶
| Test File | Tests | What It Covers |
|---|---|---|
test/core/models/test_product.py |
Product | from_dict deserialization, _asdict serialization, computed properties (rowTotal, discountedRowTotal, settlementPrice), weight defaults, controlled product flag, clearDiscount method, edge cases (zero quantity, missing fields) |
test/core/models/test_order.py |
Order | from_dict parsing with unknown key filtering, branchId propagation to products, to_dict serialization, product list filtering, total accumulation across products, weight aggregation |
test/core/models/test_shipping.py |
Shipping, Schedule, Enums | ShippingMode, ShippingType, NationwideMode enum values, Schedule.from_dict deserialization, Shipping.from_dict with nested scheduleList, roundtrip serialization, default values |
test/core/models/test_voucher.py |
Voucher | Validity rules: isActive check, isUsed check (non-empty usedOnOrder), owner matching (checkOwner), composite isValid property, edge cases (None owner, empty strings) |
test/core/models/test_discount.py |
Discount, DiscountResult | Discount field defaults, DiscountResult with multiple discounts, failedCoupons list, error dict |
test/core/models/test_summary.py |
TotalSummary | All summary fields, discountResult embedding, discountedDeliveryFee computation |
Core Logic Tests¶
| Test File | Tests | What It Covers |
|---|---|---|
test/core/test_calculator.py |
CostCalculator | Full pipeline end-to-end with mock services: product enrichment, shipping calculation, coupon application, voucher validation, total computation, response serialization |
test/core/test_totals.py |
Financial formulas | subTotal (sum of rowTotals), grandTotal formula (subTotal + delivery + express - discounts), totalExcludeControlledProducts, discountedDeliveryFee (max of fee minus discount and 0), totalDiscount aggregation, edge cases (all zeros, negative results clamped to 0) |
test/core/test_weight.py |
Weight calculation | Weight accumulation per scheduleId, totalWeight sum, default weight (0.25 kg) when product weight is missing, weight multiplication by quantity |
test/core/test_shipping_calc.py |
Shipping fee routing | Mode dispatch: PICKUP returns 0, REGULAR calls get_regular_price, EXPRESS calls get_regular_price + adds 50 THB surcharge on first schedule, NATIONWIDE calls get_nationwide_price, BranchNotAvailableException propagation |
test/core/test_coupon.py |
CouponOrder payload | Payload construction from order data, product serialization for coupon service, field mapping (branchId, ownerId, couponCodeList, financial summaries), empty couponCodeList skips call |
Protocol and Adapter Tests¶
| Test File | Tests | What It Covers |
|---|---|---|
test/interfaces/test_protocols.py |
Protocol conformance | isinstance checks for all 6 protocol types, verifies mock implementations satisfy the protocol contracts, catches missing methods at test time |
test/adapters/test_adapters.py |
All 7 adapters | DynamoDBPricingService (mocked PynamoDB query, feather decompression), DynamoDBProductLookup (mocked GSI query), HTTPControlledProducts (mocked requests.get), HTTPRegularPrice (mocked HTTP polygon call), LambdaNationwidePrice (mocked Lambda invoke), LambdaCouponService (mocked Lambda invoke + response parsing), LambdaVoucherService (mocked Lambda invoke + Voucher construction) |
Handler and Integration Tests¶
| Test File | Tests | What It Covers |
|---|---|---|
test/test_handler.py |
Lambda handler | Handler wiring: event parsing, adapter construction, calculator invocation, response serialization, error response format (500 with error field), missing body handling |
test/integration/test_cross_account_e2e.py |
Cross-account E2E | YAML test fixtures submitted to deployed endpoint, response field comparison, tests each shipping mode (REGULAR, EXPRESS, NATIONWIDE, PICKUP) |
test/integration/test_real_orders_e2e.py |
Real order E2E | 10 real paid production orders submitted to both legacy and new endpoints, field-by-field response comparison, covers coupons, vouchers, multiple schedules, controlled products |
Mock Services¶
All mock implementations live in test/conftest.py and are injected into CostCalculator during unit tests. Each mock accepts configuration via its constructor, making test setup explicit and self-documenting.
MockPricingService¶
class MockPricingService:
def __init__(self, prices: dict[tuple[int, int], float]):
self._prices = prices
def get_price(self, cprcode: int, brcode: int) -> float:
key = (cprcode, brcode)
if key not in self._prices:
raise ProductPriceNotFoundException(f"No price for {cprcode}/{brcode}")
return self._prices[key]
def get_original_price(self, cprcode: int, brcode: int) -> float:
return self._prices.get((cprcode, brcode), 0)
Configure with a {(cprcode, brcode): price} dictionary. Missing entries raise ProductPriceNotFoundException.
MockProductLookup¶
class MockProductLookup:
def __init__(self, products: dict[int, dict] | None = None):
self._products = products or {}
def get_product(self, cprcode: int) -> dict:
if cprcode not in self._products:
raise ProductNotFoundError(f"Product {cprcode} not found")
return self._products[cprcode]
Configure with a {cprcode: {"weight": float}} dictionary. Missing entries raise ProductNotFoundError.
MockControlledProducts¶
class MockControlledProducts:
def __init__(self, controlled: list[int] | None = None):
self._controlled = controlled or []
def get_controlled_product_list(self) -> list[int]:
return self._controlled
Provide a list of cprcodes that should be treated as controlled products. Default is empty (no controlled products).
MockShippingPrice¶
class MockShippingPrice:
def __init__(
self,
regular_price: float = 60.0,
nationwide_price: float = 270.0,
unavailable_branches: set[int] | None = None,
):
self._regular = regular_price
self._nationwide = nationwide_price
self._unavailable = unavailable_branches or set()
def get_regular_price(self, lat: float, lon: float, brcode: int) -> float:
if brcode in self._unavailable:
raise BranchNotAvailableException(f"Branch {brcode} not in polygon")
return self._regular
def get_nationwide_price(self, postcode: str, weight: float, mode: str) -> float:
return self._nationwide
Configure with fixed return values for regular and nationwide fees. Optionally specify branch codes that should raise BranchNotAvailableException.
MockCouponService¶
class MockCouponService:
def __init__(self, discounts: list[Discount] | None = None):
self._discounts = discounts or []
def check_coupons(self, order_dict: dict) -> DiscountResult:
return DiscountResult(discounts=self._discounts)
Provide a list of Discount objects to be returned for any coupon check. Default returns no discounts.
MockVoucherService¶
class MockVoucherService:
def __init__(self, vouchers: dict[str, Voucher] | None = None):
self._vouchers = vouchers or {}
def get_voucher(self, voucher_id: str) -> Voucher:
if voucher_id not in self._vouchers:
raise VoucherNotFoundError(f"Voucher {voucher_id} not found")
return self._vouchers[voucher_id]
Configure with a {voucher_id: Voucher} dictionary. Missing entries raise VoucherNotFoundError.
Test Fixtures¶
YAML Fixtures (test/testData/)¶
These fixtures are structured YAML files used by test_cross_account_e2e.py. Each file contains a request payload and the expected response fields.
| Fixture File | Scenario |
|---|---|
simple_order.yaml |
Single product, PICKUP, no coupons, no vouchers |
delivery_regular.yaml |
DELIVERY with REGULAR shipping mode, Bangkok coordinates |
delivery_express.yaml |
DELIVERY with EXPRESS shipping mode, 50 THB surcharge |
delivery_nationwide_dry.yaml |
NATIONWIDE shipping, DRY mode |
delivery_nationwide_fresh.yaml |
NATIONWIDE shipping, FRESH mode |
delivery_nationwide_mix.yaml |
NATIONWIDE shipping, MIX mode |
multi_product.yaml |
Multiple products in a single order |
multi_schedule.yaml |
Multiple delivery schedules with different modes |
with_coupon.yaml |
Order with coupon codes applied |
with_voucher.yaml |
Order with voucher IDs applied |
controlled_products.yaml |
Order containing controlled products (excluded from totalExcludeControlledProducts) |
Production Order Fixtures (test/integration/fixtures/)¶
These are 10 real paid production orders captured from the live system. Each fixture contains the exact request body that was sent by a real customer.
| Fixture File | Scenario |
|---|---|
order_001.json |
Standard Bangkok delivery, single schedule |
order_002.json |
Express delivery with surcharge |
order_003.json |
Nationwide DRY shipping |
order_004.json |
Nationwide FRESH shipping |
order_005.json |
Order with active coupon applied |
order_006.json |
Order with voucher applied |
order_007.json |
Large order (20+ products) |
order_008.json |
Order with controlled products |
order_009.json |
Multi-schedule order (mixed modes) |
order_010.json |
Pickup order |
Writing New Tests¶
Step 1: Create the test file¶
Place your test in the appropriate directory based on what you are testing:
- Pure business logic:
test/core/ - Model behavior:
test/core/models/ - Protocol conformance:
test/interfaces/ - Adapter behavior:
test/adapters/ - End-to-end with real services:
test/integration/
Step 2: Write the failing test (RED)¶
def test_new_behavior():
pricing = MockPricingService({(57822, 1000): 299.0})
product_lookup = MockProductLookup({57822: {"weight": 0.5}})
controlled = MockControlledProducts([])
shipping = MockShippingPrice()
coupon = MockCouponService()
voucher = MockVoucherService()
calculator = CostCalculator(
pricing_service=pricing,
product_lookup=product_lookup,
controlled_products=controlled,
shipping_price=shipping,
coupon_service=coupon,
voucher_service=voucher,
)
order = Order.from_dict({
"productList": [
{"cprcode": 57822, "productName": "Olive Oil", "quantity": 2}
],
"branchId": "1000",
})
result = calculator.calculate(order)
assert result["subTotal"] == 598.0
Step 3: Run and confirm failure¶
The test must fail. If it passes, the test is not testing new behavior.
Step 4: Write the minimum code (GREEN)¶
Implement only enough production code to make the test pass.
Step 5: Run and confirm success¶
Step 6: Check coverage¶
Review uncovered lines. Write additional tests for branches and edge cases.
Step 7: Refactor¶
Clean up the implementation. Run the full unit suite to confirm nothing broke:
Pre-merge Checklist¶
All four gates must pass before merging to main.
Gate 1: All tests pass¶
Zero failures allowed. No xfail markers permitted on core or adapter tests.
Gate 2: Coverage threshold¶
pytest test/interfaces/ test/core/ test/adapters/ test/test_handler.py \
--cov=calculatecost4 \
--cov-branch \
--cov-report=term-missing
Target: 100% line and branch coverage on calculatecost4/core/. Adapters and handler are measured but not gated at 100% (external service interactions may have untestable branches).
Gate 3: Core purity¶
No file in calculatecost4/core/ may import boto3, pynamodb, requests, or any other external I/O library. This is verified by:
Zero matches required.
Gate 4: Output equivalence¶
All 10 production order fixtures must produce identical output when compared against the deployed legacy endpoint.