Flask 测试框架

📅 2026/7/24 19:49:52
Flask 测试框架
问题为什么测试需要懂Flask简单来说对于前后端分离的项目如果你的前端已经搭建好了需要调用后端的接口但是后端接口还没写完。这个时候就需要测试开发搭建一个假接口让前端先能调通页面。这个时候测试就需要搭建一个假接口让前端先能调通页面。这个假接口就是Mock Server 。Flask 就是搭Mock Server最轻量的工具一、Flask的本质是什么Flask是python的Web框架。Web框架的底层作用只有一个把浏览器或者requests,postman发过来的HTTP请求翻译成python的函数调用再把python的函数返回值翻译成HTTP响应发回去浏览器/Postman 发 HTTP 请求 → Flask 解析 → 调用 Python 函数 → 返回 HTTP 响应而Flask自己只干一件事维护一张【URL—函数】的对照表这个表就叫做路由表字典{ /: hello_function, # 键URL 路径 值处理函数 /login: login_function, /api/goods: goods_list_function }Flask 的路由表也一样浏览器访问http://127.0.0.1:5000/→ 调用hello_function浏览器访问http://127.0.0.1:5000/login→ 调用login_functionapp.route(/login)这行代码的底层就是在往这张字典里加一条记录。二、代码讲解1.第一个Flask函数form flask import Flask appFlask(__name__) app.route(/) def index(): return Helo,Flask! if __name__main__: app.run(debugTrue)讲解1.app.run(debugTrue)app.run()开始运行启动一个单线程的HTTP服务器。Flask监听5000端口默认启动后访问http://127.0.0.1:5000 。debugTrue 开启调试模式修改代码自动重启报错显示详细堆栈信息2.app.route(/)路由装饰器。把URL路径/下面的hello函数绑定到app的路由表里。Flask 内部维护的路由字典增加了一条{/: hello}3.return hello,Flask!Flask 底层自动把这个字符串包装成 HTTP 响应。响应体就是这个字符串状态码默认200 OKContent-Type 默认text/html完整流程你在浏览器地址栏输入http://127.0.0.1:5000/ ↓ 浏览器发送 HTTP 请求GET / HTTP/1.1 ↓ Flask 开发服务器监听 5000 端口收到请求 ↓ Flask 解析请求提取 URL 路径/ ↓ 查路由表{/: hello_function} → 找到 hello 函数 ↓ 调用 hello() → 返回 Hello, Flask! ↓ Flask 包装成 HTTP 响应 HTTP/1.1 200 OK Content-Type: text/html; charsetutf-8 Content-Length: 14 Hello, Flask! ↓ 浏览器接收响应显示页面Hello, Flask!2.GET请求接收 URL 里的参数场景浏览器访问http://127.0.0.1:5000/search?keyword手机page1URL 拆解http://127.0.0.1:5000→ 服务器地址/search→路径路由匹配这个?keyword手机page1→查询参数Query Stringfrom flask import Flask, request app Flask(__name__) app.route(/search, methods[GET]) def search(): keyword request.args.get(keyword, ) #获取参数 page request.args.get(page, 1) return f搜索{keyword}第{page}页 if __name__ __main__: app.run(debugTrue)注意这里的flask包里面的request对象和requests库不一样flask里面的request核心作用是站用户/浏览器给你发的接口请求时它会把这次请求的所有信息url参数请求头请求体等等打包好供你的业务代码读取使用能力边界只能读取、接收请求不具备主动向外发请求的功能第三方requests库Python 生态里最通用的 HTTP 客户端库专门用来主动向别的网站、接口发起请求。3.POST请求JSON场景前端发 POST 请求创建用户请求体是 JSON{ username: admin, password: 123456 }HTTP请请求大概长这个样子POST /login HTTP/1.1 Host: 127.0.0.1:5000 Content-Type: application/json {username: admin, password: 123456}POST→ HTTP 方法表示「提交数据」/login→ 路径Content-Type: application/json→ 请求头告诉服务器「我发的是 JSON 格式」空行后面 →请求体Body放实际数据代码from flask import Flask, request, jsonify #jsonify将字典转化为json字符串 app Flask(__name__) app.route(/login, methods[POST]) def login(): data request.get_json() #将请求体的JSON字符串转化为字典 if data is None: return jsonify({code: 400, msg: 请求体必须是 JSON}), 400 username data.get(username) password data.get(password) if username admin and password 123456: return jsonify({code: 0, msg: 登录成功}) return jsonify({code: 1, msg: 密码错误}), 401 if __name__ __main__: app.run(debugTrue)为什么不用return {code: 0}直接返回字典Flask 也能转 JSON但响应头会变成text/html前端可能识别错误。jsonify确保头部正确三、HTTP状态码速查表状态码含义什么时候返回测试关注点200OK请求成功正常返回接口正常时的预期状态400Bad Request请求参数格式错误、缺少必填字段边界测试传空值、传错误类型401Unauthorized认证失败Token 无效、密码错误安全测试未登录访问、错误密码403Forbidden权限不足登录了但没权限权限测试普通用户访问管理员接口404Not FoundURL 路径不存在接口测试访问不存在的端点405Method Not AllowedHTTP 方法不对GET 接口发了 POST接口测试方法兼容性500Internal Server Error服务器代码抛异常异常测试传非法参数让后端崩溃四、Mock实战项目——在线图书商城1.项目背景在线图书商城这是一个卖书的网站有 6 个接口接口方法作用测试开发为什么要学登录POST拿 Token几乎所有接口都需要 Token必须会图书列表GET查所有书带分页和关键词搜索测试列表查询图书详情GET查某本书路径参数/books/123URL 里带 id创建订单POST下单请求头里带 Token 鉴权修改订单PUT改订单状态PUT 方法更新已有资源删除订单DELETE删订单DELETE 方法删除资源2.完整的Mock代码# 文件\bookstore_mock\app.py from flask import Flask, request, jsonify app Flask(__name__) # 假数据库内存里存数据不用真的连 MySQL # 图书数据 BOOKS [ {id: 1, title: Python编程从入门到实践, author: 埃里克·马瑟斯, price: 89.00, stock: 100}, {id: 2, title: 深入理解计算机系统, author: Randal Bryant, price: 139.00, stock: 50}, {id: 3, title: 软件测试艺术, author: Glen Myers, price: 59.00, stock: 200}, {id: 4, title: Selenium自动化测试实战, author: 虫师, price: 79.00, stock: 0}, {id: 5, title: HTTP权威指南, author: David Gourley, price: 109.00, stock: 30}, ] # 订单数据初始为空通过接口创建 ORDERS [] NEXT_ORDER_ID 1001 # 订单号从 1001 开始自增 # 用户数据固定账号 USERS { tester: 123456, # 用户名: 密码 admin: admin888 } # 接口 1登录POST app.route(/api/login, methods[POST]) def login(): 请求体{username: tester, password: 123456} 成功返回{code: 0, data: {token: xxx}, msg: 登录成功} data request.get_json() if data is None: return jsonify({code: 400, msg: 请求体必须是 JSON}), 400 username data.get(username) password data.get(password) # 校验用户名是否存在 if username not in USERS: return jsonify({code: 401, msg: 用户名不存在}), 401 # 校验密码 if USERS[username] ! password: return jsonify({code: 401, msg: 密码错误}), 401 # 生成 TokenMock 里直接拼字符串真实项目用 JWT token ftoken_{username}_mock return jsonify({ code: 0, data: {token: token}, msg: 登录成功 }) # 接口 2图书列表GET app.route(/api/books, methods[GET]) def get_books(): 查询参数?page1limit10keywordPython 返回分页后的图书列表 # 从 URL 参数里取分页和搜索条件 page int(request.args.get(page, 1)) # 第几页默认 1 limit int(request.args.get(limit, 10)) # 每页几条默认 10 keyword request.args.get(keyword, ) # 搜索关键词默认空字符串 # 根据关键词过滤书名包含关键词就留下 filtered BOOKS if keyword: filtered [b for b in BOOKS if keyword.lower() in b[title].lower()] # 分页计算 total len(filtered) start (page - 1) * limit # 起始索引 end start limit # 结束索引 page_data filtered[start:end] return jsonify({ code: 0, data: { list: page_data, total: total, page: page, limit: limit }, msg: 查询成功 }) # 接口 3图书详情GET路径参数 app.route(/api/books/int:book_id, methods[GET]) def get_book_detail(book_id): 路径/api/books/1 或 /api/books/3 int:book_id 表示 URL 里的数字会被提取成 book_id 变量 # 在假数据库里找这本书 book None for b in BOOKS: if b[id] book_id: book b break if book is None: return jsonify({code: 404, msg: 图书不存在}), 404 return jsonify({ code: 0, data: book, msg: 查询成功 }) # 接口 4创建订单POST需要 Token app.route(/api/orders, methods[POST]) def create_order(): 请求头必须带Authorization: Bearer token_tester_mock 请求体{book_id: 1, quantity: 2} global NEXT_ORDER_ID # 声明要修改全局变量 # ① 从请求头里取 Token auth_header request.headers.get(Authorization, ) # ② 校验 Token 格式 if not auth_header.startswith(Bearer ): return jsonify({code: 403, msg: Token 格式错误必须以 Bearer 开头}), 403 # ③ 提取 Token 值 token auth_header.replace(Bearer , ).strip() # ④ Mock 校验Token 必须包含 _mock真实项目会用 JWT 解码 if _mock not in token: return jsonify({code: 403, msg: Token 无效}), 403 # ⑤ 解析请求体 data request.get_json() if data is None: return jsonify({code: 400, msg: 请求体必须是 JSON}), 400 book_id data.get(book_id) quantity data.get(quantity, 1) # ⑥ 校验参数 if book_id is None: return jsonify({code: 400, msg: book_id 不能为空}), 400 if not isinstance(quantity, int) or quantity 0: return jsonify({code: 400, msg: quantity 必须是正整数}), 400 # ⑦ 查找图书 book None for b in BOOKS: if b[id] book_id: book b break if book is None: return jsonify({code: 404, msg: 图书不存在}), 404 # ⑧ 库存校验 if book[stock] quantity: return jsonify({code: 400, msg: f库存不足仅剩 {book[stock]} 本}), 400 # ⑨ 创建订单Mock 里直接存在内存里不真的写数据库 order { id: NEXT_ORDER_ID, book_id: book_id, book_title: book[title], quantity: quantity, total_price: round(book[price] * quantity, 2), status: 待支付 # 状态待支付 / 已支付 / 已发货 / 已完成 } ORDERS.append(order) NEXT_ORDER_ID 1 # 订单号自增 return jsonify({ code: 0, data: order, msg: 下单成功 }) # 接口 5修改订单状态PUT路径参数 app.route(/api/orders/int:order_id, methods[PUT]) def update_order(order_id): 路径/api/orders/1001 请求体{status: 已支付} PUT 表示「完整更新」一个已有资源 # ① 鉴权和创建订单一样 auth_header request.headers.get(Authorization, ) if not auth_header.startswith(Bearer ): return jsonify({code: 403, msg: Token 格式错误}), 403 token auth_header.replace(Bearer , ).strip() if _mock not in token: return jsonify({code: 403, msg: Token 无效}), 403 # ② 查找订单 order None for o in ORDERS: if o[id] order_id: order o break if order is None: return jsonify({code: 404, msg: 订单不存在}), 404 # ③ 解析请求体 data request.get_json() if data is None: return jsonify({code: 400, msg: 请求体必须是 JSON}), 400 new_status data.get(status) # ④ 校验状态值 valid_status [待支付, 已支付, 已发货, 已完成, 已取消] if new_status not in valid_status: return jsonify({code: 400, msg: f状态必须是以下之一{valid_status}}), 400 # ⑤ 状态流转校验业务逻辑 # 待支付 → 已支付 / 已取消 # 已支付 → 已发货 # 已发货 → 已完成 current order[status] allowed { 待支付: [已支付, 已取消], 已支付: [已发货], 已发货: [已完成], 已完成: [], 已取消: [] } if new_status not in allowed.get(current, []): return jsonify({ code: 400, msg: f当前状态「{current}」不能变更为「{new_status}」 }), 400 # ⑥ 更新状态 order[status] new_status return jsonify({ code: 0, data: order, msg: 状态更新成功 }) # 接口 6删除订单DELETE路径参数 app.route(/api/orders/int:order_id, methods[DELETE]) def delete_order(order_id): 路径/api/orders/1001 DELETE 表示删除一个资源 # ① 鉴权 auth_header request.headers.get(Authorization, ) if not auth_header.startswith(Bearer ): return jsonify({code: 403, msg: Token 格式错误}), 403 token auth_header.replace(Bearer , ).strip() if _mock not in token: return jsonify({code: 403, msg: Token 无效}), 403 # ② 查找并删除 for i, o in enumerate(ORDERS): if o[id] order_id: deleted ORDERS.pop(i) # pop(i) 从列表里删除第 i 个元素 return jsonify({ code: 0, data: {deleted_id: deleted[id]}, msg: 删除成功 }) return jsonify({code: 404, msg: 订单不存在}), 404 if __name__ __main__: app.run(host0.0.0.0, port5000, debugTrue)测试脚本# 文件\bookstore_mock\test_mock.py import requests BASE_URL http://127.0.0.1:5000 TOKEN None # 全局变量存储登录后拿到的 Token def test_login(): 测试登录接口 global TOKEN url f{BASE_URL}/api/login payload {username: tester, password: 123456} response requests.post(url, jsonpayload) result response.json() print(f【登录】状态码: {response.status_code}) print(f【登录】响应: {result}) assert response.status_code 200 assert result[code] 0 TOKEN result[data][token] # 保存 Token 给后面用 print(f【登录】Token: {TOKEN}\n) def test_login_wrong_password(): 测试密码错误 url f{BASE_URL}/api/login payload {username: tester, password: wrong} response requests.post(url, jsonpayload) print(f【密码错误】状态码: {response.status_code}) print(f【密码错误】响应: {response.json()}\n) assert response.status_code 401 def test_get_books(): 测试图书列表带搜索 url f{BASE_URL}/api/books?page1limit2keywordPython response requests.get(url) result response.json() print(f【图书列表】状态码: {response.status_code}) print(f【图书列表】共 {result[data][total]} 本) print(f【图书列表】第一页数据: {result[data][list]}\n) assert response.status_code 200 assert result[code] 0 def test_get_book_detail(): 测试图书详情路径参数 url f{BASE_URL}/api/books/1 response requests.get(url) result response.json() print(f【图书详情】状态码: {response.status_code}) print(f【图书详情】书名: {result[data][title]}\n) assert response.status_code 200 def test_get_book_not_found(): 测试图书不存在路径参数不存在 url f{BASE_URL}/api/books/999 response requests.get(url) print(f【图书不存在】状态码: {response.status_code}) print(f【图书不存在】响应: {response.json()}\n) assert response.status_code 404 def test_create_order(): 测试创建订单需要 Token global TOKEN url f{BASE_URL}/api/orders payload {book_id: 1, quantity: 2} # 请求头里带 Token headers { Authorization: fBearer {TOKEN}, Content-Type: application/json } response requests.post(url, jsonpayload, headersheaders) result response.json() print(f【创建订单】状态码: {response.status_code}) print(f【创建订单】订单信息: {result[data]}\n) assert response.status_code 200 assert result[code] 0 return result[data][id] # 返回订单号给后面用 def test_create_order_no_token(): 测试不带 Token 创建订单 url f{BASE_URL}/api/orders payload {book_id: 1, quantity: 1} response requests.post(url, jsonpayload) # 不带 headers print(f【无Token】状态码: {response.status_code}) print(f【无Token】响应: {response.json()}\n) assert response.status_code 403 def test_update_order(order_id): 测试修改订单状态PUT 路径参数 global TOKEN url f{BASE_URL}/api/orders/{order_id} payload {status: 已支付} headers {Authorization: fBearer {TOKEN}} response requests.put(url, jsonpayload, headersheaders) result response.json() print(f【修改订单】状态码: {response.status_code}) print(f【修改订单】新状态: {result[data][status]}\n) assert response.status_code 200 def test_update_order_invalid_status(order_id): 测试非法状态跳转 global TOKEN url f{BASE_URL}/api/orders/{order_id} payload {status: 已发货} # 已支付 → 已发货 是允许的但这里测的是 已支付 → 已完成非法 # 先把订单改成已支付 requests.put(url, json{status: 已支付}, headers{Authorization: fBearer {TOKEN}}) # 再尝试改成已完成跳过已发货非法 payload {status: 已完成} response requests.put(url, jsonpayload, headers{Authorization: fBearer {TOKEN}}) print(f【非法状态】状态码: {response.status_code}) print(f【非法状态】响应: {response.json()}\n) assert response.status_code 400 def test_delete_order(order_id): 测试删除订单DELETE 路径参数 global TOKEN url f{BASE_URL}/api/orders/{order_id} headers {Authorization: fBearer {TOKEN}} response requests.delete(url, headersheaders) print(f【删除订单】状态码: {response.status_code}) print(f【删除订单】响应: {response.json()}\n) assert response.status_code 200 # 主执行流程 if __name__ __main__: print( * 50) print(开始测试 Bookstore Mock API) print( * 50 \n) test_login() test_login_wrong_password() test_get_books() test_get_book_detail() test_get_book_not_found() order_id test_create_order() test_create_order_no_token() test_update_order(order_id) test_update_order_invalid_status(order_id) test_delete_order(order_id) print( * 50) print(全部测试通过) print( * 50)3.与Pytest结合# 文件\bookstore_mock\conftest.py import pytest import threading import time import requests from app import app # 导入你的 Flask 应用 pytest.fixture(scopesession) def mock_server(): pytest fixture测试会话级别只启动一次 Mock 服务 scopesession 表示整个测试会话只执行一次 # ① 在一个后台线程里启动 Flask # threading.Thread 创建一个新线程targetapp.run 表示这个线程跑 Flask 服务器 server threading.Thread(targetapp.run, kwargs{ host: 127.0.0.1, port: 5001, # 用 5001 避免和手动启动的 5000 冲突 debug: False # 测试时关闭 debug避免输出太多日志 }) server.daemon True # daemonTrue 表示主线程结束时这个线程也自动结束 server.start() # ② 等待服务器启动轮询检查 # 循环发请求直到服务器响应最多等 5 秒 for _ in range(50): try: requests.get(http://127.0.0.1:5001/api/books) break # 请求成功说明服务器已启动 except requests.exceptions.ConnectionError: time.sleep(0.1) # 等 0.1 秒再试 yield http://127.0.0.1:5001 # ③ 把 URL 传给测试函数用 # ④ yield 后面的代码在测试结束后执行清理工作 # 因为 daemonTrue线程会自动结束这里不需要额外操作 print(\nMock 服务已关闭)# \bookstore_mock\test_with_pytest.py import requests def test_login(mock_server): 参数 mock_server 就是 conftest.py 里 yield 出来的值http://127.0.0.1:5001 pytest 会自动找到同目录下的 conftest.py把 mock_server fixture 注入进来 url f{mock_server}/api/login payload {username: tester, password: 123456} response requests.post(url, jsonpayload) result response.json() # 断言状态码 200业务码 0返回了 token assert response.status_code 200 assert result[code] 0 assert token in result[data] def test_get_books(mock_server): 测试图书列表带搜索关键词 url f{mock_server}/api/books?page1limit2keywordPython response requests.get(url) result response.json() assert response.status_code 200 assert result[code] 0 assert len(result[data][list]) 0 # 至少返回一条数据 def test_book_detail(mock_server): 测试图书详情路径参数 url f{mock_server}/api/books/1 response requests.get(url) result response.json() assert response.status_code 200 assert result[data][id] 1 assert result[data][title] Python编程从入门到实践 def test_create_order(mock_server): 测试创建订单需要 Token # 先登录拿 Token login_url f{mock_server}/api/login login_resp requests.post(login_url, json{username: tester, password: 123456}) token login_resp.json()[data][token] # 再创建订单 url f{mock_server}/api/orders headers {Authorization: fBearer {token}} payload {book_id: 1, quantity: 2} response requests.post(url, jsonpayload, headersheaders) result response.json() assert response.status_code 200 assert result[code] 0 assert result[data][status] 待支付 def test_no_token_rejected(mock_server): 测试不带 Token 创建订单会被拒绝 url f{mock_server}/api/orders payload {book_id: 1, quantity: 1} # 故意不带 headers response requests.post(url, jsonpayload) assert response.status_code 403 # 应该返回 403 Forbidden这里有个需要思考的点就是为什么在conftest.py文件中执行app.run函数需要创建一个线程答案就是app.run一旦启动底层就是一个死循环while True: 监听 5001 端口 等有浏览器/requests 来敲门 处理请求 返回响应 继续监听... # ← 永远不会自己结束就会一直卡在这里后面的函数就运行不了。因为python默认是单线程。想象一下单线程的python代码从上到下运行但是到app.run这里死循环后面就继续不下去了。但是threading .Thread的巧妙之处就在于把app.run这个函数放到后台线程并且设置成守护线程。当你的主线程结束之后这个线程也自动销毁。学习到这里大部分主要常用的Flask的知识你也掌握了接下来可以结合自己的项目去来练手