有段时间我负责维护一个老项目的接口层,前端调用方式五花八门,后端稍微改个返回结构,我就要拿 Postman 把几十条用例一条条点过去,点完还要手动核对返回字段。时间长了确实烦,后来我干脆用 Python 和 Django 写了一个轻量级的接口测试工具,把用例管起来、断言逻辑写清楚,浏览器里点个按钮就能批量执行,结果和失败原因直接落在页面上。整个过程从动手到能用,大概花了两天。这篇文章就把这套东西的核心代码、设计思路和实际踩过的坑完整分享一下,适合已经会 Python 基础、想用 Django 做点实战项目的人参考,也适合团队里没有专业测试平台、想快速搭一个内部工具的人。
1. 先说清楚:为什么我要用 Django 造一个接口测试工具
1.1 Postman 和 JMeter 解决不了的问题
很多团队的接口测试停留在“Postman 里存了一堆请求,手动点”的阶段。说实话,对于临时调试,Postman 非常顺手,但一旦用例数量上来,你会发现几个尴尬的地方:
- 用例和断言散落在每个人的本地,没法统一管理和归档,同事离职了用例也一起没了。
- 断言能力有限,稍微复杂一点的“判断某个 JSON 字段值在某个范围内”就得很别扭地写脚本。
- 没有历史执行记录,今天跑没跑过、上次是不是也挂了,完全没有留存。
- 想接入 CI(比如每次发版后自动跑一遍回归),Postman 需要额外配置 Newman,JMeter 又要维护庞大的 jmx 文件。
这些问题用一句话概括就是:临时调试工具干不了持续回归的活。我当时需要的是一个可以反复执行、能自动比对结果、能看到失败历史、还能让团队其他人打开浏览器就能用的东西。
1.2 为什么选 Python + Django 而不是 Node/Vue 前后端分离
说白了就是两点:一是团队技术栈本来就是 Python,Django 自带 ORM、Admin 后台、模板渲染,一个单人项目完全不需要自己搭前后端;二是requests库做 HTTP 请求调用已经是事实标准,写起来比 Node 的 fetch 还要顺手,直接拿来当执行引擎毫无压力。
我见过有人为了一个内部测试工具硬上 Vue + FastAPI + Docker 部署,最后光环境就折腾了两天。对这种内部工具,“能跑、好改、同事能上手”比“架构先进”重要得多。Django 的 MTV 模式里,models 管数据、views 写逻辑、templates 渲染页面,正好和接口测试工具的“用例管理 + 执行逻辑 + 结果展示”完全对应,没有多余的复杂度。
1.3 这个工具到底要做到什么程度
我给自己定的目标非常务实:
- 能登记接口用例:请求方法、URL、请求头、请求体、期望状态码、期望关键字。
- 能执行用例:发真实 HTTP 请求,自动比对状态码和返回内容。
- 能记录历史结果:每次执行都落库,能查看某条用例最近几次是过了还是挂了。
- 界面够用即可:用例列表、执行按钮、结果页,不需要用户系统,因为是内网工具(团队自己用,先不做权限控制,后面要接入再说)。
这篇文章的代码就是围绕这四点展开的。整套东西没有用 Celery、没有用 Redis,单机 SQLite 就够跑,后面我会说为什么初期这样设计就够了。
2. 项目初始化:版本选型与目录结构的一次性交代
2.1 环境版本和依赖安装
我用的 Python 版本是 3.10,Django 用的是 4.2 LTS。别小看版本选择这件事,我见过有人一上来就装了 Django 5.0 的开发版,后来某些第三方库不兼容,排查半天。内部工具选 LTS 版本永远不会错,功能稳定、资料多、遇到问题好搜。
# 创建虚拟环境 python -m venv venv # 激活(Windows 是 venv\Scripts\activate) source venv/bin/activate # 安装依赖 pip install django requests # 验证版本 python -m django --version建议把依赖写进 requirements.txt:
django==4.2.11 requests==2.31.0这里有个小坑:requests如果不固定版本,过段时间升级了可能对某些 HTTPS 证书校验更严格,导致原来好好的用例突然报 SSL 错误。内部工具没时间天天跟进第三方库变更,锁定版本是最省心的方式。
2.2 创建项目和 app
Django 的项目结构对很多新手来说有点绕,我简单说清楚:一个 project 相当于一个工程,一个 app 相当于工程里的一个功能模块。接口测试工具整体就是一个功能模块,所以用一个 app 完全够。
# 创建项目,目录名用 api_test_tool django-admin startproject api_test_tool # 进入项目根目录 cd api_test_tool # 创建 app,取名 tester python manage.py startapp tester创建完之后,整个目录结构是:
api_test_tool/ ├── manage.py ├── api_test_tool/ # 项目配置目录 │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py └── tester/ # 我们的 app ├── models.py ├── views.py ├── urls.py # 需要自己新建 ├── templates/ # 需要自己新建 └── admin.py2.3 settings 里必须改的三个地方
打开api_test_tool/settings.py,有几个地方必须要动,不然后面有得受:
第一,把tester加进INSTALLED_APPS:
INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'tester', # 注册我们的 app ]第二,把数据库时区改成Asia/Shanghai,同时把USE_TZ设为True。原因我在踩坑那节会详细讲,这里先直接操作:
LANGUAGE_CODE = 'zh-hans' TIME_ZONE = 'Asia/Shanghai' USE_TZ = True第三,确认ALLOWED_HOSTS里至少包含本机 IP,方便局域网同事访问:
ALLOWED_HOSTS = ['*']这是我反复强调的一次性配置。ALLOWED_HOSTS = ['*']内网用没问题,如果想严谨一点,可以写成['192.168.1.100', 'localhost', '127.0.0.1'],效果一样。
2.4 为什么要分 service 层
很多 Django 新手会把所有逻辑塞进 views.py,比如“发请求”这种动作直接在视图函数里写。当时很好,但过两天加一个“批量执行”功能,又要复制粘贴一遍请求代码。我的习惯是加一层 service,把请求发送、断言比对、结果落库这些核心逻辑全部拆到 service 模块里,views.py 只负责调用和页面跳转。后面的代码我会一直遵循这个结构,这也是整个工具后续能快速加功能的关键。
3. 数据模型先行:接口用例和测试结果怎么存
3.1 TestCase 模型的字段设计
这个模型存的是“接口用例的定义”,也就是你要测什么。先看完整代码:
# tester/models.py from django.db import models class TestCase(models.Model): class MethodChoices(models.TextChoices): GET = 'GET', 'GET' POST = 'POST', 'POST' PUT = 'PUT', 'PUT' DELETE = 'DELETE', 'DELETE' PATCH = 'PATCH', 'PATCH' name = models.CharField('用例名称', max_length=200) method = models.CharField('请求方法', max_length=10, choices=MethodChoices.choices, default=MethodChoices.GET) url = models.CharField('请求URL', max_length=500) headers = models.TextField('请求头(JSON)', blank=True, default='') body = models.TextField('请求体', blank=True, default='') expect_status = models.IntegerField('期望状态码', default=200) expect_keyword = models.CharField('期望包含关键字', max_length=500, blank=True, default='') desc = models.TextField('用例描述', blank=True, default='') created_at = models.DateTimeField('创建时间', auto_now_add=True) updated_at = models.DateTimeField('更新时间', auto_now=True) def __str__(self): return self.name class Meta: ordering = ['-created_at'] verbose_name = '接口用例' verbose_name_plural = '接口用例'字段设计的思路我逐个说明一下,这些决定后面代码好不好写。
name字段是给人看的,最好写清楚“首页轮播图接口-正常返回”这种语义化名称。method用 Django 内置的 TextChoices,页面下拉框、校验都省事,比自己在页面写死强。url我没用 URLField 而是 CharField,因为实际开发中有些接口地址是 IP+端口+路径,URLField 的校验规则会拦下来,烦人。headers我存的是 JSON 字符串,不是 Django 的 JSONField,原因有两个:一是低版本数据库兼容性更好,二是后面要支持占位符替换时,字符串处理反而灵活。body存文本,执行时再决定是 JSON 还是表单。期望值这块我设计了两个维度:expect_status判断状态码,expect_keyword判断返回内容里是否包含某个关键字。这两个是最常用的断言,刚好能覆盖八成场景。
3.2 TestResult 模型:每次执行的结果快照
接口测试有一个特点:结果必须留痕。这次跑挂了,你得知道上次是不是也挂,挂在哪一步。所以每次执行我都存一条完整记录:
# tester/models.py class TestResult(models.Model): case = models.ForeignKey(TestCase, on_delete=models.CASCADE, related_name='results', verbose_name='关联用例') status_code = models.IntegerField('HTTP状态码', null=True, blank=True) response_body = models.TextField('响应内容', blank=True, default='') passed = models.BooleanField('是否通过', default=False) error_message = models.TextField('异常信息', blank=True, default='') duration_ms = models.IntegerField('耗时(毫秒)', null=True, blank=True) run_at = models.DateTimeField('执行时间', auto_now_add=True) def __str__(self): return f'{self.case.name} - {"通过" if self.passed else "失败"} - {self.run_at}' class Meta: ordering = ['-run_at'] verbose_name = '测试结果' verbose_name_plural = '测试结果'这里最关键的决策是case用了外键。关联外键就能在用例详情页直接列出“最近 10 次执行记录”,也可以按用例统计历史通过率。on_delete=models.CASCADE意思是删除用例时,它对应的所有结果一起删掉,避免数据库里堆垃圾数据。
我特意把response_body也存下来了。Debug 的时候这玩意儿能救命——用例断言失败了,你打开结果一看返回体,立刻能判断是接口 bug 还是断言写错了。当然,如果接口返回的是大文件或超大报文,可以截断存储,后面我会提这个优化。
3.3 迁移:让数据库跟上模型
模型写完后,执行两条命令:
python manage.py makemigrations tester python manage.py migratemakemigrations是根据模型的变化生成迁移文件,migrate是真正把表建出来。建议每条命令都留意输出信息,如果提示“No changes detected”,说明tester没正确注册进INSTALLED_APPS,回头检查 2.3 节的第一步。
为了让后台方便管理,顺手在tester/admin.py注册一下:
# tester/admin.py from django.contrib import admin from .models import TestCase, TestResult @admin.register(TestCase) class TestCaseAdmin(admin.ModelAdmin): list_display = ('name', 'method', 'url', 'expect_status', 'updated_at') search_fields = ('name', 'url') @admin.register(TestResult) class TestResultAdmin(admin.ModelAdmin): list_display = ('case', 'passed', 'status_code', 'duration_ms', 'run_at') list_filter = ('passed', 'run_at')有了这个,哪怕前端页面没做完,你也可以直接在 Django Admin 里维护用例,用起来跟后台管理系统一样。
4. 执行引擎核心:请求发送、多维度断言、结果落库
4.1 service 模块:把执行逻辑单独抽出来
在tester下新建services.py,这块是整个工具的心脏,包含两个核心函数:execute_case(执行单条用例)和check_result(断言比对)。
# tester/services.py import json import time import requests from .models import TestCase, TestResult def parse_headers(headers_str: str) -> dict: """把 JSON 字符串形式的请求头解析成字典,解析失败就返回空字典。""" if not headers_str.strip(): return {} try: return json.loads(headers_str) except json.JSONDecodeError: return {} def check_result(status_code, response_text, expect_status, expect_keyword) -> list: """ 执行断言,返回所有检查项的列表。 每一项是一个 dict:{'name': 检查项名称, 'passed': bool, 'detail': 详情} """ checks = [] # 检查点 1:状态码 checks.append({ 'name': '状态码', 'passed': status_code == expect_status, 'detail': f'期望 {expect_status},实际 {status_code}' }) # 检查点 2:关键字 if expect_keyword: passed = expect_keyword in (response_text or '') checks.append({ 'name': f'包含关键字 {expect_keyword}', 'passed': passed, 'detail': f'响应长度 {len(response_text or "")}' }) return checks def execute_case(case: TestCase) -> TestResult: """执行单条用例,记录结果并落库。""" result = TestResult(case=case) start = time.time() try: headers = parse_headers(case.headers) body = case.body if case.body.strip() else None # allow_redirects=False 避免请求被静默重定向,导致断言失真 resp = requests.request( method=case.method, url=case.url, headers=headers, data=body, timeout=10, allow_redirects=False ) result.status_code = resp.status_code result.response_body = resp.text checks = check_result( status_code=resp.status_code, response_text=resp.text, expect_status=case.expect_status, expect_keyword=case.expect_keyword, ) result.passed = all(c['passed'] for c in checks) if not result.passed: failed_checks = [c for c in checks if not c['passed']] result.error_message = ';'.join([f"{c['name']}: {c['detail']}" for c in failed_checks]) except requests.exceptions.Timeout: result.error_message = '请求超时(10秒)' result.passed = False except requests.exceptions.SSLError as e: result.error_message = f'SSL证书错误: {e}' result.passed = False except Exception as e: result.error_message = f'未知异常: {e}' result.passed = False finally: result.duration_ms = int((time.time() - start) * 1000) result.save() return result4.2 为什么断言要返回“检查项列表”而不是直接返回“过没过”
这是我在实际使用中踩出来的经验。最初我的check_result返回一个布尔值,页面只显示“通过/失败”,结果失败了也不知道挂在哪个断言上,还得翻响应体猜。改成返回检查项列表后,页面可以展示一个表格:
| 检查项 | 结果 | 详情 |
|---|---|---|
| 状态码 | 失败 | 期望 200,实际 500 |
| 包含关键字 success | 通过 | 响应长度 234 |
一眼就知道哪里出了问题。后面你要扩展断言体系(比如 JSON 字段断言、响应时间断言),往checks列表里 append 一项就完了,对现有逻辑零侵入。
4.3 异常处理要分粒度
很多人写 requests 调用就一个 try-except,把所有异常吞了。我后来发现这样没法快速定位问题,所以把异常拆分成了三类:
- 超时:请求发出去 10 秒没响应,这可能是接口真的慢,也可能是网络不通,需要单独标记。
- SSL 错误:很多内网接口用的自签名证书,requests 默认会校验,需要单独提示,避免和普通错误混在一起。
- 其他异常:比如 DNS 解析失败、连接被拒绝等。
每类异常输出的error_message都是给页面展示用的,所以要写人话。比如“SSL证书错误”比“SSLError”直观得多。
4.4 三个必须注意的细节
第一个细节:allow_redirects=False。这是我在真实项目里遇到的一个大坑。有个登录接口,请求发过去后应该正常返回 200,但实际返回了 302 重定向。requests 默认会跟随重定向,最终状态码变成了 200,断言看起来过了,实际上接口行为是错的——它压根不该跳转。设置allow_redirects=False后,接口到底返回 302 还是 200 就原形毕露了。对接口测试来说,看到原始状态码远比“跟随到底”有意义。
第二个细节:请求体传值。我用的是data=body,也就是按普通文本/表单方式发送。现在很多接口走 JSON,那 requests 应该用json=body。如果你要测 JSON 接口,我建议在 TestCase 模型里加一个字段body_type,分json和form,service 里判断一下:
if case.body_type == 'json' and body: resp = requests.request(..., json=json.loads(body), ...) else: resp = requests.request(..., data=body, ...)这个改造很简单,后面我会提到。当前示例为了篇幅,先统一用data发送,你自己用的时候按接口需求调整。
第三个细节:响应体可能不是文本。resp.text是 requests 根据响应头推断编码后解码出来的字符串。如果接口返回的是图片、文件流,resp.text会是乱码,此时应该用resp.content(字节流)。判断方式很简单:看一眼响应头的Content-Type,以text或application/json开头就用resp.text,否则用resp.content。内部测试工具面对的绝大多数是 JSON 接口,先用resp.text完全够用。
5. Web 管理界面:用例列表、单条执行与报告呈现
5.1 路由设计:三条必要的 URL
我设计的页面很简单,三条路由就够:
/用例列表页,展示所有用例,每条后面带“执行”按钮。/case/<int:case_id>/run/执行单条用例,执行完跳转到结果页。/result/<int:result_id>/展示单次执行结果,包括断言表格和响应体。
tester/urls.py内容如下:
# tester/urls.py from django.urls import path from . import views urlpatterns = [ path('', views.case_list, name='case_list'), path('case/<int:case_id>/run/', views.run_case, name='run_case'), path('result/<int:result_id>/', views.result_detail, name='result_detail'), ]然后在项目总路由api_test_tool/urls.py里挂载:
# api_test_tool/urls.py from django.contrib import admin from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), path('', include('tester.urls')), ]这里有个新手很容易踩的坑:在总路由里用include时,子路由统一不带前缀的情况下,访问根路径/就能直接进用例列表,不用非得套一个/tester/前缀。内部工具越短越好记。
5.2 视图函数:只做调度,不做业务
views.py 里的逻辑非常薄,大部分工作都委托给 service 层:
# tester/views.py from django.shortcuts import render, get_object_or_404 from .models import TestCase, TestResult from .services import execute_case def case_list(request): cases = TestCase.objects.all() # 每个用例附带最近一次执行结果,用于列表页展示状态 for case in cases: latest_result = case.results.first() case.latest_result = latest_result return render(request, 'tester/case_list.html', {'cases': cases}) def run_case(request, case_id): case = get_object_or_404(TestCase, pk=case_id) result = execute_case(case) return redirect('result_detail', result_id=result.id) def result_detail(request, result_id): result = get_object_or_404(TestResult, pk=result_id) # 因为 response_body 可能很长,取前 2000 个字符展示 return render(request, 'tester/result_detail.html', {'result': result})注意case_id这种路径参数名要和 urls.py 里的<int:case_id>完全一致,这是 Django 新手最容易写错的地方。
5.3 模板:页面要能看,但不用复杂
先创建一个基础模板tester/templates/tester/base.html,做好一个简单的导航条:
<!DOCTYPE html> <html lang="zh-hans"> <head> <meta charset="UTF-8"> <title>{% block title %}接口测试工具{% endblock %}</title> <style> body { font-family: Arial, sans-serif; margin: 20px; } .passed { color: green; font-weight: bold; } .failed { color: red; font-weight: bold; } table { border-collapse: collapse; width: 100%; margin: 10px 0; } th, td { border: 1px solid #ddd; padding: 8px; text-align: left; } .btn { padding: 6px 12px; background: #2c3e50; color: #fff; text-decoration: none; border-radius: 3px; } pre { background: #f4f4f4; padding: 10px; overflow: auto; } </style> </head> <body> <h1>接口测试工具</h1> <hr> {% block content %}{% endblock %} </body> </html>用例列表页case_list.html:
{% extends 'tester/base.html' %} {% block content %} <table> <tr> <th>用例名称</th> <th>方法</th> <th>URL</th> <th>最近状态</th> <th>操作</th> </tr> {% for case in cases %} <tr> <td>{{ case.name }}</td> <td>{{ case.method }}</td> <td>{{ case.url }}</td> <td> {% if case.latest_result %} {% if case.latest_result.passed %} <span class="passed">通过</span> {% else %} <span class="failed">失败</span> {% endif %} <small>({{ case.latest_result.run_at|date:"m-d H:i" }})</small> {% else %} <span>未执行</span> {% endif %} </td> <td><a class="btn" href="{% url 'run_case' case.id %}">执行</a></td> </tr> {% empty %} <tr><td colspan="5">还没有用例,去 Django Admin 添加吧。</td></tr> {% endfor %} </table> {% endblock %}执行结果页result_detail.html稍微复杂一点,要把断言检查项展示出来。但是我的execute_case里把检查项存在了error_message里,页面读取时再解析:
{% extends 'tester/base.html' %} {% block content %} <h2>{{ result.case.name }}</h2> <p> 状态:{% if result.passed %}<span class="passed">通过</span>{% else %}<span class="failed">失败</span>{% endif %} </p> <p>HTTP状态码:{{ result.status_code|default:"无" }}</p> <p>耗时:{{ result.duration_ms }} ms</p> <p>执行时间:{{ result.run_at|date:"Y-m-d H:i:s" }}</p> {% if result.error_message %} <p><strong>失败原因:</strong></p> <pre>{{ result.error_message }}</pre> {% endif %} <h3>响应内容(前2000字符)</h3> <pre>{{ result.response_body|slice:":2000" }}</pre> <p><a href="{% url 'case_list' %}">返回列表</a></p> {% endblock %}整个界面虽然简陋,但信息密度够了:状态、耗时、失败原因、响应体全都有。对一个内部工具来说,清晰比美观重要。
5.4 执行用例时一定要重定向刷新
run_case的视图里我用了redirect而不是直接渲染结果页,这不是多此一举。如果直接在run_case里渲染模板,用户刷新结果页时浏览器会重复提交执行请求,用例就会被执行两遍。先跳转到 result_detail,刷新时就只会重新查询结果,不会重复执行。这是一个非常经典的 Web 开发模式,叫Post/Redirect/Get,防重复提交,接口测试工具里尤其重要——因为你每次执行都有副作用(发真实请求)。
6. 从“能跑”到“好用”:批量执行与数据初始化
6.1 批量执行:用一个简单方式跑完全部用例
单条执行是最基本的能力,但接口测试的真正价值在回归。发布新版本后,我想一键把所有用例跑一遍。最初我考虑过 Celery 异步任务队列,后来想想还是算了——就几十条用例,每条平均耗时几百毫秒,同步执行也就十几秒,没必要为了这个引入一套消息队列。
我选择写一个视图函数循环执行所有用例:
def run_all(request): cases = TestCase.objects.all() results = [] for case in cases: result = execute_case(case) results.append(result.passed) passed_count = sum(results) total_count = len(results) return render(request, 'tester/run_all_result.html', { 'passed_count': passed_count, 'total_count': total_count, })对应的 URL 和模板都不复杂,模板里展示“通过 X / 共 Y,耗时 Z 秒”就够。这个实现有个显眼的缺点:用户要白等十几秒。但考虑到用的人不多、用例量小,完全可以接受。
如果你想稍微优化一下,可以用threading.Thread配合concurrent.futures.ThreadPoolExecutor把用例并行执行,每条用例跑在独立线程里。注意 Django 的 ORM 在 SQLite 下并发写入时会遇到 “database is locked” 的报错,解决办法我在踩坑那节详细说。这里先提一句,如果你要上并发,最好先把数据库换成 PostgreSQL 或 MySQL,或者串行保存结果。
6.2 快速初始化数据:写一个 Django 管理命令
界面还没做“新增用例”表单之前,我们总得往库里塞数据。最笨的方法是启动 Django shell 一条条 add,但效率太低。我写了一个 Django 自定义命令,用代码批量造数据:
python manage.py seed_cases具体代码放在tester/management/commands/seed_cases.py,目录结构:
tester/ ├── management/ │ ├── __init__.py │ └── commands/ │ ├── __init__.py │ └── seed_cases.py# tester/management/commands/seed_cases.py from django.core.management.base import BaseCommand from tester.models import TestCase class Command(BaseCommand): help = '初始化示例接口用例' def handle(self, *args, **options): demo_cases = [ { 'name': '百度首页-正常访问', 'method': 'GET', 'url': 'https://www.baidu.com', 'expect_status': 200, 'expect_keyword': '百度', }, { 'name': '示例JSON接口-期望字段success', 'method': 'GET', 'url': 'https://httpbin.org/json', 'expect_status': 200, 'expect_keyword': 'slideshow', }, { 'name': '故意失败用例-期望404', 'method': 'GET', 'url': 'https://httpbin.org/status/500', 'expect_status': 404, 'expect_keyword': '', }, ] for item in demo_cases: TestCase.objects.create(**item) self.stdout.write(f'创建用例: {item["name"]}')自定义命令的原理不复杂,Django 会扫描 app 下 management/commands 目录里的 Python 文件,把类名Command加载成可执行命令。这种方式非常适合做数据初始化、定时清理等后台操作。顺带说一句,如果你后续要给工具加“批量导入用例”功能,也可以写在这个管理命令里,或者单独做一个导入页面。
6.3 报告页的统计信息
批量执行完成后,我希望有一个更直观的汇总页面,而不是一行“X/Y”。于是我在run_all视图里多带了一些统计数据:
from django.db.models import Avg def run_all_summary(request): cases = TestCase.objects.all() results = [] for case in cases: result = execute_case(case) results.append(result) passed_count = sum(1 for r in results if r.passed) total_count = len(results) avg_duration = sum((r.duration_ms or 0) for r in results) / total_count if total_count else 0 return render(request, 'tester/run_all_result.html', { 'passed_count': passed_count, 'total_count': total_count, 'avg_duration': round(avg_duration, 1), 'results': results, })模板里用表格展示每条用例的执行结果,排在前面的是未通过的用例,方便第一时间抓到问题。小型内部工具做到这一步,就已经比 Postman 手动点几十遍强太多了。
7. 开发中踩过的坑:时区、重定向、SQLite 锁和编码
7.1 时区问题:USE_TZ和数据库存储
这个坑基本每个 Django 新手都会踩。我刚开始写工具时,TIME_ZONE是默认的UTC,USE_TZ是默认的True。结果页面上执行时间永远比北京时间慢 8 小时。我一开始以为是模板渲染的问题,查了半天才发现是 Django 的机制:USE_TZ = True时,数据库里存的是 UTC 时间,模板渲染时会根据TIME_ZONE自动转成当地时区。
正确的配置就是 2.3 节那两行:
TIME_ZONE = 'Asia/Shanghai' USE_TZ = True这样数据库里存 UTC,展示时自动转北京,没毛病。如果你就是不想搞时区转换这档子事,可以把USE_TZ = False,Django 就会存本地时间,但这种做法在跨时区的项目里是反模式,建议别学。
7.2 重定向陷阱:状态码 302 被静默吞掉
我在 4.4 节提过allow_redirects=False,这里把当时排查的过程完整说一下。有一次测试一个登录接口,用例明明写的expect_status=200,执行却总是成功。我打开响应体一看,返回的是登录后的首页 HTML,状态码显示 200。按说没问题,但我直觉不对,因为登录接口的返回应该是 JSON。于是我用 curl 手动请求了一下,发现响应头里赫然是302 Found。
问题就出在 requests 默认行为:遇到 302 会自动跟随,最终表现出来的状态码是重定向后的 200。接口测试的断言对 302 这种跳转是无感的,导致我没发现登录行为其实是错的。设置allow_redirects=False后,返回值立刻变成了 302,断言也按预期失败了。接口测试工具必须把每一次跳转暴露出来,如果你确实希望跟随重定向,再在用例里加一个字段控制。
7.3 SQLite 并发写锁:批量执行突然报 database is locked
当我把ThreadPoolExecutor改成并行执行用例后,跑了一会儿就收到报错:sqlite3.OperationalError: database is locked。原因是 SQLite 同一时刻只允许一个进程写数据库,多个线程同时保存 TestResult 时就冲突了。
我的解决办法是回到串行执行——对当前用例量,串行十几秒完全可以接受。如果你一定要并发,有几个备选方案:
- 把数据库换成 PostgreSQL 或 MySQL,Django 的 ORM 切换成本很低,改下 settings 里 DATABASES 配置就行。
- 保持 SQLite,但把
result.save()改为插入前获取一个内存锁(比如 threading.Lock),保证同一时间只有一个线程写。 - 更彻底的方案:执行测试用线程池,但结果不直接落库,先收集到内存列表,最后统一用
bulk_create批量插入。这能大幅减少写库次数。
我自己最后选了第一个方案,在测试环境里换成了 PostgreSQL。如果你只是个人用、用例量小,SQLite 串行其实够用,别给自己找麻烦。
7.4 JSON 中文乱码:ensure_ascii的坑
有段时间我的请求头里带着中文参数,执行后服务端收到的全是\uXXXX转义符。排查后发现是json.dumps默认ensure_ascii=True,会把中文转成 Unicode 转义序列。在 service 里如果你要json.dumps请求体,务必加上:
body_str = json.dumps(body, ensure_ascii=False, indent=2)当然,如果用requests的json参数直接传 dict,requests 自己处理序列化时不会有这个问题。但如果你先在代码里序列化成字符串再放到data里,就一定要记得ensure_ascii=False。
7.5 响应体超大:页面卡死和数据库膨胀
最初我把整个resp.text都存进response_body,结果有个接口返回了带大量日志的 HTML,足足几 MB,数据库一下变得巨大,页面打开都卡。我在保存前做了截断处理:
RESPONSE_MAX_LENGTH = 10000 result.response_body = resp.text[:RESPONSE_MAX_LENGTH]同时在模板展示时再slice:":2000"进一步限制显示长度。数据库存 1 万字符足够判断接口是否正常,真要完整内容,你可以再设计一个“下载完整响应”的功能,没必要一上来全存。
7.6 实际操作的再一句提醒
把以上坑都解决后,这个工具在团队里跑了几个月,最大的使用体验是:用例多了以后,“执行”按钮的成功率反而比“新增用例”重要。我后来给用例列表加了一个“复制用例”功能,一个页面都懒得填,直接在 Admin 后台复制改 URL 就好了。这也算是一个从实际使用中来的小建议:内部工具的维护便利性和执行可靠性,比功能丰富度优先级高得多。
回看这次开发,用 Django 搭接口测试工具的最大收益其实不是“省了手动点 Postman 的时间”,而是让接口回归变成了一个可以反复执行、有历史记录、能多人协作的团队资产。代码不复杂,工程上却非常实用。如果你正被一堆接口回归搞得焦头烂额,照着这套结构搭一个,成本很低,回报立竿见影。下一步我打算给它加上简单的环境切换(测试环境/生产环境 URL 前缀一键切换)和定时执行(用系统的 cron 每天跑一遍),你可以在这些方向上继续扩展试试。