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

零基础入门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)
相关推荐
Patrick在香港18 分钟前
Python 拉取 C&SD 官方 API:香港 2022 年已跨过“超老龄线“,而抚养比正在爬回 1961
android·c语言·python·数据分析·时序数据库·数据可视化·香港
2601_9623824320 分钟前
Python零基础入门,看完直接上手写代码
python·机器学习·编程语言·数据科学·入门教程
信誓旦旦的程序猿21 分钟前
【零依赖量化数据实战 #25】北交所技术指标与基本面
java·人工智能·python·股票数据api·股票数据·股票数据api接口·股票api数据接口
2601_9669496523 分钟前
Python 量化开发为什么适合使用金融数据 SDK?从数据获取到策略研究的工程化实践
开发语言·python·数据分析·量化交易·股票数据·quantdash
科技小E23 分钟前
训完怎么带走?AI模型私有化部署平台DLTM模型导出ONNX/PyTorch与离线部署跑遍产线边缘
人工智能·pytorch·python
晓窗科技24 分钟前
专业的AI基座公司
人工智能·python
花间相见25 分钟前
【LangChain组件02】—— create_agent函数详解
python
程序员良辰36 分钟前
服务器 JDK 环境变量配置:一键安装和手动配置有什么区别?
开发语言·python