ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用Django从零搭建轻量级接口自动化测试工具

用Django从零搭建轻量级接口自动化测试工具 有段时间我负责维护一个老项目的接口层前端调用方式五花八门后端稍微改个返回结构我就要拿 Postman 把几十条用例一条条点过去点完还要手动核对返回字段。时间长了确实烦后来我干脆用 Python 和 Django 写了一个轻量级的接口测试工具把用例管起来、断言逻辑写清楚浏览器里点个按钮就能批量执行结果和失败原因直接落在页面上。整个过程从动手到能用大概花了两天。这篇文章就把这套东西的核心代码、设计思路和实际踩过的坑完整分享一下适合已经会 Python 基础、想用 Django 做点实战项目的人参考也适合团队里没有专业测试平台、想快速搭一个内部工具的人。1. 先说清楚为什么我要用 Django 造一个接口测试工具1.1 Postman 和 JMeter 解决不了的问题很多团队的接口测试停留在“Postman 里存了一堆请求手动点”的阶段。说实话对于临时调试Postman 非常顺手但一旦用例数量上来你会发现几个尴尬的地方用例和断言散落在每个人的本地没法统一管理和归档同事离职了用例也一起没了。断言能力有限稍微复杂一点的“判断某个 JSON 字段值在某个范围内”就得很别扭地写脚本。没有历史执行记录今天跑没跑过、上次是不是也挂了完全没有留存。想接入 CI比如每次发版后自动跑一遍回归Postman 需要额外配置 NewmanJMeter 又要维护庞大的 jmx 文件。这些问题用一句话概括就是临时调试工具干不了持续回归的活。我当时需要的是一个可以反复执行、能自动比对结果、能看到失败历史、还能让团队其他人打开浏览器就能用的东西。1.2 为什么选 Python Django 而不是 Node/Vue 前后端分离说白了就是两点一是团队技术栈本来就是 PythonDjango 自带 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.10Django 用的是 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.txtdjango4.2.11 requests2.31.0这里有个小坑requests如果不固定版本过段时间升级了可能对某些 HTTPS 证书校验更严格导致原来好好的用例突然报 SSL 错误。内部工具没时间天天跟进第三方库变更锁定版本是最省心的方式。2.2 创建项目和 appDjango 的项目结构对很多新手来说有点绕我简单说清楚一个 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_APPSINSTALLED_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_length200) method models.CharField(请求方法, max_length10, choicesMethodChoices.choices, defaultMethodChoices.GET) url models.CharField(请求URL, max_length500) headers models.TextField(请求头(JSON), blankTrue, default) body models.TextField(请求体, blankTrue, default) expect_status models.IntegerField(期望状态码, default200) expect_keyword models.CharField(期望包含关键字, max_length500, blankTrue, default) desc models.TextField(用例描述, blankTrue, default) created_at models.DateTimeField(创建时间, auto_now_addTrue) updated_at models.DateTimeField(更新时间, auto_nowTrue) 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_deletemodels.CASCADE, related_nameresults, verbose_name关联用例) status_code models.IntegerField(HTTP状态码, nullTrue, blankTrue) response_body models.TextField(响应内容, blankTrue, default) passed models.BooleanField(是否通过, defaultFalse) error_message models.TextField(异常信息, blankTrue, default) duration_ms models.IntegerField(耗时(毫秒), nullTrue, blankTrue) run_at models.DateTimeField(执行时间, auto_now_addTrue) 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_deletemodels.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(casecase) start time.time() try: headers parse_headers(case.headers) body case.body if case.body.strip() else None # allow_redirectsFalse 避免请求被静默重定向导致断言失真 resp requests.request( methodcase.method, urlcase.url, headersheaders, databody, timeout10, allow_redirectsFalse ) result.status_code resp.status_code result.response_body resp.text checks check_result( status_coderesp.status_code, response_textresp.text, expect_statuscase.expect_status, expect_keywordcase.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 fSSL证书错误: {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_redirectsFalse。这是我在真实项目里遇到的一个大坑。有个登录接口请求发过去后应该正常返回 200但实际返回了 302 重定向。requests 默认会跟随重定向最终状态码变成了 200断言看起来过了实际上接口行为是错的——它压根不该跳转。设置allow_redirectsFalse后接口到底返回 302 还是 200 就原形毕露了。对接口测试来说看到原始状态码远比“跟随到底”有意义。第二个细节请求体传值。我用的是databody也就是按普通文本/表单方式发送。现在很多接口走 JSON那 requests 应该用jsonbody。如果你要测 JSON 接口我建议在 TestCase 模型里加一个字段body_type分json和formservice 里判断一下if case.body_type json and body: resp requests.request(..., jsonjson.loads(body), ...) else: resp requests.request(..., databody, ...)这个改造很简单后面我会提到。当前示例为了篇幅先统一用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, namecase_list), path(case/int:case_id/run/, views.run_case, namerun_case), path(result/int:result_id/, views.result_detail, nameresult_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, pkcase_id) result execute_case(case) return redirect(result_detail, result_idresult.id) def result_detail(request, result_id): result get_object_or_404(TestResult, pkresult_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 langzh-hans head meta charsetUTF-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 thURL/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 classpassed通过/span {% else %} span classfailed失败/span {% endif %} small({{ case.latest_result.run_at|date:m-d H:i }})/small {% else %} span未执行/span {% endif %} /td tda classbtn href{% url run_case case.id %}执行/a/td /tr {% empty %} trtd colspan5还没有用例去 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 classpassed通过/span{% else %}span classfailed失败/span{% endif %} /p pHTTP状态码{{ 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 %} pstrong失败原因/strong/p pre{{ result.error_message }}/pre {% endif %} h3响应内容前2000字符/h3 pre{{ result.response_body|slice::2000 }}/pre pa 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是默认的UTCUSE_TZ是默认的True。结果页面上执行时间永远比北京时间慢 8 小时。我一开始以为是模板渲染的问题查了半天才发现是 Django 的机制USE_TZ True时数据库里存的是 UTC 时间模板渲染时会根据TIME_ZONE自动转成当地时区。正确的配置就是 2.3 节那两行TIME_ZONE Asia/Shanghai USE_TZ True这样数据库里存 UTC展示时自动转北京没毛病。如果你就是不想搞时区转换这档子事可以把USE_TZ FalseDjango 就会存本地时间但这种做法在跨时区的项目里是反模式建议别学。7.2 重定向陷阱状态码 302 被静默吞掉我在 4.4 节提过allow_redirectsFalse这里把当时排查的过程完整说一下。有一次测试一个登录接口用例明明写的expect_status200执行却总是成功。我打开响应体一看返回的是登录后的首页 HTML状态码显示 200。按说没问题但我直觉不对因为登录接口的返回应该是 JSON。于是我用 curl 手动请求了一下发现响应头里赫然是302 Found。问题就出在 requests 默认行为遇到 302 会自动跟随最终表现出来的状态码是重定向后的 200。接口测试的断言对 302 这种跳转是无感的导致我没发现登录行为其实是错的。设置allow_redirectsFalse后返回值立刻变成了 302断言也按预期失败了。接口测试工具必须把每一次跳转暴露出来如果你确实希望跟随重定向再在用例里加一个字段控制。7.3 SQLite 并发写锁批量执行突然报 database is locked当我把ThreadPoolExecutor改成并行执行用例后跑了一会儿就收到报错sqlite3.OperationalError: database is locked。原因是 SQLite 同一时刻只允许一个进程写数据库多个线程同时保存 TestResult 时就冲突了。我的解决办法是回到串行执行——对当前用例量串行十几秒完全可以接受。如果你一定要并发有几个备选方案把数据库换成 PostgreSQL 或 MySQLDjango 的 ORM 切换成本很低改下 settings 里 DATABASES 配置就行。保持 SQLite但把result.save()改为插入前获取一个内存锁比如 threading.Lock保证同一时间只有一个线程写。更彻底的方案执行测试用线程池但结果不直接落库先收集到内存列表最后统一用bulk_create批量插入。这能大幅减少写库次数。我自己最后选了第一个方案在测试环境里换成了 PostgreSQL。如果你只是个人用、用例量小SQLite 串行其实够用别给自己找麻烦。7.4 JSON 中文乱码ensure_ascii的坑有段时间我的请求头里带着中文参数执行后服务端收到的全是\uXXXX转义符。排查后发现是json.dumps默认ensure_asciiTrue会把中文转成 Unicode 转义序列。在 service 里如果你要json.dumps请求体务必加上body_str json.dumps(body, ensure_asciiFalse, indent2)当然如果用requests的json参数直接传 dictrequests 自己处理序列化时不会有这个问题。但如果你先在代码里序列化成字符串再放到data里就一定要记得ensure_asciiFalse。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 每天跑一遍你可以在这些方向上继续扩展试试。
返回列表