零基础入门python43:Django接口测试与库存边界

一、上一篇课后练习讲解
管理员查看全部订单应使用独立的权限类和 ViewSet,不能修改普通用户的 get_queryset。权限越宽的接口越应该单独命名、单独测试。
上一篇课后练习完整答案
上一篇练习的要求已落实到下面完整文件;先运行项目测试,再用 curl 对照状态码和数据库持久化结果
答案要点:OrderViewSet 的 get_queryset 按 staff 或 user 分支,详情和 action 均调用 get_object,普通用户看不到他人订单。
文件:shop/views.py
完整参考答案文件
完整文件:shop/views.py
python
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from .models import Order
class OrderViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = [IsAuthenticated]
def get_queryset(self):
qs = Order.objects.all()
if not self.request.user.is_staff: qs = qs.filter(user=self.request.user)
return qs.order_by("-created_at")
完整参考答案文件
本篇对应的交付源码完整文件:django-shop/shop/views.py
python
from rest_framework import generics, permissions, status, viewsets
from rest_framework.response import Response
from .models import Category, Order, Product
from .serializers import CategorySerializer, CheckoutSerializer, OrderSerializer, ProductSerializer
from .services import create_order
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Category.objects.order_by('name')
serializer_class = CategorySerializer
class ProductViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = ProductSerializer
def get_queryset(self):
query = Product.objects.filter(is_active=True).select_related('category')
if category := self.request.query_params.get('category'): query = query.filter(category_id=category)
if keyword := self.request.query_params.get('q'): query = query.filter(name__icontains=keyword)
return query.order_by('-created_at')
class CheckoutView(generics.GenericAPIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = CheckoutSerializer
def post(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
order = create_order(request.user, serializer.validated_data['items'])
return Response(OrderSerializer(order).data, status=201)
class MyOrderViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = OrderSerializer
def get_queryset(self):
return Order.objects.filter(user=self.request.user).prefetch_related('items').order_by('-id')
class CartView(generics.GenericAPIView):
def get(self, request):
return Response(request.session.get('cart', {}))
def post(self, request):
product = generics.get_object_or_404(Product, id=request.data.get('product_id'), is_active=True)
quantity = int(request.data.get('quantity', 1))
if quantity < 1 or quantity > product.stock:
return Response({'message': '数量必须大于0且不能超过库存'}, status=status.HTTP_400_BAD_REQUEST)
cart = request.session.setdefault('cart', {})
cart[str(product.id)] = quantity
request.session.modified = True
return Response(cart, status=status.HTTP_201_CREATED)
def delete(self, request):
cart = request.session.get('cart', {})
cart.pop(str(request.data.get('product_id')), None)
request.session.modified = True
return Response(cart)
验收命令:python -m pytest -q(Django 项目使用 python manage.py test)。预期测试通过;若失败先检查迁移、配置和事务回滚。
二、本篇测试什么
用 APITestCase 验收公开商品、未登录结算、成功下单、库存不足回滚、购物车库存边界和用户订单隔离。

三、测试示例
python
def test_insufficient_stock_rolls_back_order(self):
self.client.force_authenticate(self.user)
response = self.client.post('/api/checkout/', {
'items': [{'product_id': self.product.id, 'quantity': 9}]
}, format='json')
self.assertEqual(response.status_code, 400)
self.assertEqual(Order.objects.count(), 0)
self.product.refresh_from_db()
self.assertEqual(self.product.stock, 5)
refresh_from_db() 很重要:测试内存中的对象可能仍是旧值,必须重新从数据库读取才能验证事务结果。
四、验收
powershell
python manage.py test -v 0
python manage.py check
预期 5 个测试全部通过、system check 无问题。课后练习:增加重复点赞或重复下单边界测试,并记录预期状态码。
项目增量:接口测试和库存边界
测试不能只验证 200。库存为 0、数量为 0、重复下单、越权订单和事务中途异常都要覆盖,尤其要查询数据库确认库存没有负数。
python
response = client.post('/api/orders/', {'items': [{'product_id': product.id, 'quantity': 2}]})
assert response.status_code == 409
product.refresh_from_db()
assert product.stock == 1
使用 TransactionTestCase 才能测试真实锁行为;普通 TestCase 的事务包装可能掩盖并发问题。课后练习模拟两个请求抢最后一件商品。
五、上一篇练习讲解:权限测试要和状态机测试分开
staff 发货测试验证 paid -> shipped,普通用户应得到 403;订单所有权测试验证用户 B 得不到 A 的对象。分开写可以在失败时快速判断是 queryset 还是状态转换出错。
六、库存边界的接口测试
python
class OrderApiTests(APITestCase):
def test_out_of_stock_rolls_back(self):
product = Product.objects.create(name="鼠标", price="20.00", stock=1,
category=self.category)
response = self.client.post("/api/orders/", {
"idempotency_key": str(uuid4()),
"items": [{"product_id": product.id, "quantity": 2}],
}, format="json")
self.assertEqual(response.status_code, 409)
product.refresh_from_db()
self.assertEqual(product.stock, 1)
self.assertEqual(Order.objects.count(), 0)
测试不仅看状态码,还要查询数据库确认没有半张订单、库存没有负数。refresh_from_db() 很重要,否则测试内存中的 product.stock 可能仍是旧值,导致假通过。
七、并发测试为什么要 TransactionTestCase
TestCase 会在外层包一层事务并回滚,锁可能不会像生产一样释放;并发场景请用 TransactionTestCase,通过两个线程或两个数据库连接发起请求。SQLite 无法模拟完整行锁,建议在 PostgreSQL CI 服务上执行:
python
class ConcurrentOrderTests(TransactionTestCase):
reset_sequences = True
# 使用 barrier 让两个事务同时尝试读取库存,再观察只有一个成功。
并发测试偶尔超时不应简单增大等待时间,要检查锁顺序是否一致;多个商品下单时按 id 排序后再锁行,能够降低死锁概率。
八、上一篇练习验收、常见错误与本篇练习
运行:
powershell
python manage.py check
python manage.py test shop.tests -v 2
预期所有测试通过。IntegrityError 未被转为 409 时,检查 DRF exception handler;出现库存负数,检查是否使用了 F("stock") - quantity 并在锁内再次判断;订单重复,检查幂等键唯一约束是否已迁移。
本篇练习:模拟两个请求抢最后一件商品,增加重复点赞/重复下单边界测试,并把测试命令写入 README。下一篇将从空目录启动整个 Django 商城,做最终验收。
十、让测试失败信息可读
为每个边界断言写清上下文,例如 assertEqual(product.stock, 1, "库存不足时不应被扣减");使用 subTest(quantity=quantity) 批量覆盖 0、-1、100。测试失败时先保存请求体、用户角色和数据库快照,再修改代码,避免"改到绿但不知道为什么"。CI 中使用 -v 2 保留测试名称,毕业报告附上命令和完整输出。
接口测试还应检查响应头(缓存策略、request id、Content-Type)和错误结构。对于事务失败场景,除了返回 409,还要重新查询订单、订单项和库存;只有持久化状态正确,测试才真正证明回滚生效。
本篇结束:完整模块文件
下面是交付项目中真实存在的完整文件 django-shop/shop/tests.py。它覆盖本篇新增逻辑以及前文已经完成的依赖代码;复制单个函数会丢失上下文,因此这里提供整份文件。
python
from django.contrib.auth import get_user_model
from rest_framework.test import APITestCase
from .models import Category, Order, Product
class ShopApiTest(APITestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username='alice', password='password123')
category = Category.objects.create(name='图书', slug='books')
self.product = Product.objects.create(category=category, name='Python入门', slug='python', price='59.00', stock=5)
def test_products_are_public(self):
response = self.client.get('/api/products/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data[0]['name'], 'Python入门')
def test_checkout_requires_login(self):
response = self.client.post('/api/checkout/', {'items': [{'product_id': self.product.id, 'quantity': 1}]}, format='json')
self.assertEqual(response.status_code, 403)
def test_session_cart_checks_stock(self):
response = self.client.post('/api/cart/', {'product_id': self.product.id, 'quantity': 2}, format='json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data[str(self.product.id)], 2)
response = self.client.post('/api/cart/', {'product_id': self.product.id, 'quantity': 9}, format='json')
self.assertEqual(response.status_code, 400)
def test_checkout_creates_order_and_reduces_stock(self):
self.client.force_authenticate(self.user)
response = self.client.post('/api/checkout/', {'items': [{'product_id': self.product.id, 'quantity': 2}]}, format='json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['total_amount'], '118.00')
self.product.refresh_from_db()
self.assertEqual(self.product.stock, 3)
self.assertEqual(Order.objects.get().user, self.user)
def test_insufficient_stock_rolls_back_order(self):
self.client.force_authenticate(self.user)
response = self.client.post('/api/checkout/', {'items': [{'product_id': self.product.id, 'quantity': 9}]}, format='json')
self.assertEqual(response.status_code, 400)
self.assertEqual(Order.objects.count(), 0)