Files
dealerhub/backend/tests/test_query_efficiency.py
T

206 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""查询效率测试(迭代第 4 轮)。
**为什么需要**:单测只验证"结果对不对",不验证"代价多大"。
实测发现列表接口的 N+1:50 张销售单产生 **311 条 SQL**、P95 266ms;
加预取后降到 **5 条 SQL**、P95 50ms(5.3 倍)。
本模块用 `assertNumQueries` 把"查询次数"钉死,防止后续改动悄悄退回 N+1。
"""
import pytest
from decimal import Decimal
from model_bakery import baker
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from apps.catalog.models import Product, Unit
from apps.inventory.models import Stock, Warehouse
from apps.partner.models import Customer
from apps.sales import services as sales_services
class query_counter:
"""统计代码块内执行的 SQL 数量(替代未安装的 pytest-django helper)。"""
def __init__(self):
from django.db import connection
from django.test.utils import CaptureQueriesContext
self._ctx = CaptureQueriesContext(connection)
def __enter__(self):
self._ctx.__enter__()
return self
def __exit__(self, *args):
return self._ctx.__exit__(*args)
@property
def count(self):
return len(self._ctx.captured_queries)
@pytest.fixture
def auth_client(db, user, tenant):
c = APIClient()
refresh = RefreshToken.for_user(user)
c.credentials(
HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}",
HTTP_X_TENANT_ID=tenant.code,
)
return c
@pytest.fixture
def warehouse(db, tenant):
return baker.make(Warehouse, tenant=tenant, code="WH01", name="主仓")
@pytest.fixture
def customer(db, tenant):
return baker.make(Customer, tenant=tenant, code="C001", name="张三",
credit_limit=Decimal("999999"))
@pytest.fixture
def product(db, tenant):
unit = baker.make(Unit, tenant=tenant, code="bottle", name="瓶", is_base=True)
return baker.make(Product, tenant=tenant, code="P001", name="可乐",
sale_price=Decimal("10"), base_unit=unit)
def _seed_bills(tenant, warehouse, customer, product, n=20):
from apps.inventory import services as inv
inv.inbound(tenant=tenant, warehouse=warehouse, product=product,
quantity=Decimal(str(n * 10)), unit_cost=Decimal("5"))
for _ in range(n):
sales_services.create_sales_bill(
tenant=tenant, customer=customer, warehouse=warehouse,
lines=[{"product": product, "quantity": 2, "unit_price": 10}],
)
# ============================================================
# 销售单列表:查询次数不随单据数增长
# ============================================================
def test_sales_bill_list_query_count_is_constant(db, auth_client, tenant,
warehouse, customer, product):
"""20 张单的列表页:SQL 次数必须是常数级(不随行数增长)。
修复前:每张单 6 条(aggregate 税额 + lines + product + source_unit)= 120+ 条。
修复后:预取 + 内存汇总 = 常数条。
"""
_seed_bills(tenant, warehouse, customer, product, n=20)
with query_counter() as qc:
resp = auth_client.get("/api/v1/sales/bills/?page_size=50")
assert resp.status_code == 200
assert resp.json()["count"] == 20
# 20 张单的查询数必须是常数级(修复前 120+)
assert qc.count <= 12, f"查询数过多(疑似 N+1):{qc.count} 条 SQL"
def test_sales_bill_list_scales_flat(db, auth_client, tenant, warehouse,
customer, product):
"""单据翻倍(20 → 40)时查询次数不增加。"""
from django.test.utils import CaptureQueriesContext
from django.db import connection
_seed_bills(tenant, warehouse, customer, product, n=20)
with CaptureQueriesContext(connection) as ctx20:
auth_client.get("/api/v1/sales/bills/?page_size=50")
n20 = len(ctx20.captured_queries)
_seed_bills(tenant, warehouse, customer, product, n=20) # 再来 20 张
with CaptureQueriesContext(connection) as ctx40:
resp = auth_client.get("/api/v1/sales/bills/?page_size=50")
n40 = len(ctx40.captured_queries)
assert resp.json()["count"] == 40
# 40 张单的查询数不应显著多于 20 张(允许 ±2 的波动)
assert n40 <= n20 + 2, f"查询数随数据量增长:{n20} → {n40}(N+1 回归)"
def test_sales_bill_list_payload_correct(db, auth_client, tenant, warehouse,
customer, product):
"""优化后数据必须仍然正确(预取不能丢字段)。"""
_seed_bills(tenant, warehouse, customer, product, n=3)
body = auth_client.get("/api/v1/sales/bills/?page_size=50").json()
assert body["count"] == 3
row = body["results"][0]
# 预取后仍要能拿到客户名、行明细、税额合计
assert row["customer_name"] == "张三"
assert row["lines"], "行明细丢失"
assert "tax_total" in row
assert row["lines"][0]["product_name"] == "可乐"
assert row["lines"][0]["unit_name"] == "瓶"
# ============================================================
# 其他列表接口
# ============================================================
def test_stock_list_no_n_plus_1(db, auth_client, tenant, warehouse, product):
from apps.inventory import services as inv
for i in range(15):
p = baker.make(Product, tenant=tenant, code=f"SP{i:03d}", name=f"库存品{i}",
sale_price=Decimal("5"))
inv.inbound(tenant=tenant, warehouse=warehouse, product=p,
quantity=Decimal("10"), unit_cost=Decimal("1"))
with query_counter() as qc:
resp = auth_client.get("/api/v1/inventory/stocks/?page_size=50")
assert resp.status_code == 200
assert resp.json()["count"] == 15
assert qc.count <= 10, f"库存列表 N+1:{qc.count} 条 SQL"
def test_product_list_no_n_plus_1(db, auth_client, tenant):
for i in range(20):
baker.make(Product, tenant=tenant, code=f"PP{i:03d}", name=f"商品{i}",
sale_price=Decimal("5"))
with query_counter() as qc:
resp = auth_client.get("/api/v1/catalog/products/?page_size=50")
assert resp.status_code == 200
assert resp.json()["count"] == 20
assert qc.count <= 10, f"商品列表 N+1:{qc.count} 条 SQL"
def test_receivable_list_no_n_plus_1(db, auth_client, tenant):
from apps.finance.models import Receivable
c = baker.make(Customer, tenant=tenant, code="CN1", name="客户1")
for i in range(15):
baker.make(Receivable, tenant=tenant, customer=c, bill_no=f"RC{i:03d}",
total_amount=Decimal("100"), status="open")
with query_counter() as qc:
resp = auth_client.get("/api/v1/finance/receivables/?page_size=50")
assert resp.status_code == 200
assert resp.json()["count"] == 15
# 客户名要能取到(说明 select_related 生效)
assert resp.json()["results"][0]["customer_name"] == "客户1"
assert qc.count <= 10, f"应收列表 N+1:{qc.count} 条 SQL"
# ============================================================
# 分页边界:page_size 上限保护
# ============================================================
def test_page_size_capped_at_max(db, auth_client, tenant):
"""page_size 有上限,防止一次拉全表拖垮服务。"""
for i in range(30):
baker.make(Product, tenant=tenant, code=f"CAP{i:03d}", name=f"上限品{i}")
resp = auth_client.get("/api/v1/catalog/products/?page_size=100000")
assert resp.status_code == 200
assert len(resp.json()["results"]) <= 200 # max_page_size