206 lines
7.6 KiB
Python
206 lines
7.6 KiB
Python
"""查询效率测试(迭代第 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
|