Skip to content

Testing Guide

Test Philosophy

All code in villaCalculateCost4 is written test-first following the RED-GREEN-REFACTOR cycle:

  1. RED -- Write a failing test that describes the desired behavior.
  2. GREEN -- Write the minimum code to make the test pass.
  3. 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/ imports boto3, 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)

pytest test/interfaces/ test/core/ test/adapters/ test/test_handler.py -v

Integration tests (needs AWS credentials + network)

pytest test/integration/ -v -m integration

All tests

pytest test/ -v

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)

pytest test/core/models/ -v --tb=short

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

pytest test/core/test_new_feature.py -v

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

pytest test/core/test_new_feature.py -v

Step 6: Check coverage

pytest test/core/test_new_feature.py --cov=calculatecost4 --cov-branch --cov-report=term-missing

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:

pytest test/interfaces/ test/core/ test/adapters/ test/test_handler.py -v

Pre-merge Checklist

All four gates must pass before merging to main.

Gate 1: All tests pass

pytest test/ -v

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:

grep -r "import boto3\|import pynamodb\|import requests" calculatecost4/core/

Zero matches required.

Gate 4: Output equivalence

pytest test/integration/test_real_orders_e2e.py -v -m integration

All 10 production order fixtures must produce identical output when compared against the deployed legacy endpoint.