后端第一步开发完成,第一版回归测试完成

This commit is contained in:
Your Name
2026-01-31 19:09:48 +08:00
parent 11cef531bd
commit c9e75030af
26 changed files with 1686 additions and 184 deletions
+27 -7
View File
@@ -2,23 +2,43 @@
依据《产品文档》与《API 文档》,用 Python + pytest 编写。**先写失败用例(RED),再实现路由(GREEN)。**
后端源码在 `backend/src/` 下,pytest 通过 `pytest.ini``pythonpath = src` 自动加入 `src` 目录,无需手动设置 PYTHONPATH。
## 测试与开发环境严格隔离
- **不加载应用代码、不 mock 认证**:测试通过 HTTP 请求**已启动的后端服务**,与开发环境完全隔离。
- **运行方式**:先启动后端服务(如 `uvicorn` 监听 `http://0.0.0.0:8000`),再在另一终端执行 `pytest tests/ -v`
- **服务地址**:默认 `http://127.0.0.1:8000`,可通过环境变量 `TEST_BASE_URL` 覆盖。
## 登录与带 Token 调用
- **登录**`POST /api/auth/login`,请求体 `{"username": "admin", "password": "123456"}`(或 `market` / `engineer` / `tech` / `finance` / `material`,密码均为 `123456`)。
- **带 Token 调接口**:在请求头中加 `Authorization: Bearer <登录返回的 token>`,再调用项目列表、详情、新建、更新、操作日志等接口。
- **Fixture**`conftest.py` 提供 `auth_token`admin)、`market_token`(市场部)、`engineer_token`(工程部),用例中直接注入使用。
## 运行测试
```bash
# 1. 先启动后端(在 backend 目录或项目根目录)
uvicorn src.app:app --host 0.0.0.0 --port 8000
# 2. 在另一终端运行测试
cd backend
source .venv/bin/activate # 或 pip install -r requirements.txt
pytest tests/ -v
```
如需指定服务地址:
```bash
TEST_BASE_URL=http://localhost:8000 pytest tests/ -v
```
## 用例覆盖
| 接口 | 用例 |
|------|------|
| POST /api/auth/login | 成功返回 token/user、错误返回 401、缺 username/password 返回 400 |
| POST /api/projects/list | 无 token 401有 token 返回 list/total/page/pageSize无数据 list 空且 total=0 |
| POST /api/projects/detail | 无 token 401、存在项目返回完整字段、不存在 404 |
| POST /api/projects/create | 无 token 401、非市场部 403、市场部且必填齐全 201、缺合同编号/项目名称 400 |
| POST /api/projects/update | 无 token 401、存在项目 200 保存成功、不存在 404 |
| POST /api/projects/logs | 无 token 401、有 token 返回 list/total/page/pageSize、不存在项目 404 |
| POST /api/auth/login | 成功返回 token/user、错误凭证 401、缺 username/password 返回 400 |
| POST /api/projects/list | 无 token 401有 token 返回 list/total/page/pageSize无数据 list 空且 total=0;支持 searchType+keyword(name/code)、progress、cost、dateFilterType+dateFrom/dateTo、dateAbnormal;列表项含 id/projectName/contractCode/progress/cost/updatedAtpage/pageSize 回显 |
| POST /api/projects/detail | 无 token 401、缺 id 400/422、存在项目返回完整五类字段、不存在 404 |
| POST /api/projects/create | 无 token 401、非市场部 403、市场部且必填齐全 201、缺合同编号/项目名称 400、校验失败可含 errors 数组 |
| POST /api/projects/update | 无 token 401、缺 id 400/422、存在项目 200 保存成功、不存在 404 |
| POST /api/projects/logs | 无 token 401、缺 id 400/422、有 token 返回 list/total/page/pageSize、不存在项目 404、更新后有一条含 summary 的日志、列表项含 id/operatorId/operatorName/operatedAt/summary |
+53 -12
View File
@@ -1,18 +1,59 @@
"""pytest 配置与公共 fixture(依据 API 文档)"""
"""pytest 配置与公共 fixture(依据 API 文档)
测试与开发环境严格隔离:测试通过 HTTP 请求已启动的后端服务,不加载应用代码、不 mock 认证。
运行测试前请先启动后端:http://127.0.0.1:8000
"""
import os
import httpx
import pytest
from fastapi.testclient import TestClient
from app import app, PROJECTS, OPERATION_LOGS
# 已启动的后端服务地址(可通过环境变量 TEST_BASE_URL 覆盖)
BASE_URL = os.environ.get("TEST_BASE_URL", "http://127.0.0.1:8000")
@pytest.fixture(autouse=True)
def reset_store():
"""每个测试前清空内存存储,保证隔离"""
PROJECTS.clear()
OPERATION_LOGS.clear()
yield
@pytest.fixture(scope="session")
def http_client():
"""请求已启动的后端服务的 HTTP 客户端(session 级,复用连接)"""
with httpx.Client(base_url=BASE_URL, timeout=30.0) as c:
yield c
@pytest.fixture
def client():
"""同步测试用:FastAPI TestClient"""
return TestClient(app)
def client(http_client):
"""请求已启动的后端服务的 HTTP 客户端(测试用别名)"""
return http_client
def _login(client: httpx.Client, username: str, password: str) -> str:
"""登录并返回 token,失败则抛出异常。"""
r = client.post(
"/api/auth/login",
json={"username": username, "password": password},
)
if r.status_code != 200:
raise RuntimeError(
f"登录失败 {r.status_code}: {r.text}. 请确认后端已启动({BASE_URL})且存在用户 {username} / 密码 123456。"
)
body = r.json()
if "token" not in body:
raise RuntimeError(f"登录响应缺少 token: {body}")
return body["token"]
@pytest.fixture(scope="session")
def auth_token(http_client):
"""已登录的任意角色 token(admin,用于列表/详情/更新/日志等)"""
return _login(http_client, "admin", "123456")
@pytest.fixture(scope="session")
def market_token(http_client):
"""市场部账号 token(仅市场部可新建项目)"""
return _login(http_client, "market", "123456")
@pytest.fixture(scope="session")
def engineer_token(http_client):
"""工程部账号 token(非市场部,用于 403 等用例)"""
return _login(http_client, "engineer", "123456")
+4 -2
View File
@@ -1,6 +1,8 @@
"""
认证 API 测试(依据 API 文档 2.1 登录)
TDD:先写失败用例,再实现路由
测试与开发环境严格隔离:通过 HTTP 请求已启动的后端服务,真实调用登录接口。
运行前请先启动后端:http://127.0.0.1:8000
"""
@@ -8,7 +10,7 @@ def test_login_success_returns_200_with_token_and_user(client):
"""正确账号密码返回 200 且包含 token 和 user"""
r = client.post(
"/api/auth/login",
json={"username": "market", "password": "valid-password"},
json={"username": "admin", "password": "123456"},
)
assert r.status_code == 200
body = r.json()
+263 -31
View File
@@ -1,6 +1,8 @@
"""
项目 API 测试(依据 API 文档 3–7 节)
TDD:先写失败用例,再实现路由
测试与开发环境严格隔离:通过 HTTP 请求已启动的后端服务,带 Token 调用项目列表/详情/新建/更新/操作日志。
运行前请先启动后端:http://127.0.0.1:8000;登录账号见 conftestadmin / market / engineer 等,密码 123456)。
"""
@@ -17,11 +19,11 @@ def test_list_without_token_returns_401(client):
assert r.status_code == 401
def test_list_with_token_returns_200_with_list_total_page_page_size(client):
def test_list_with_token_returns_200_with_list_total_page_page_size(client, auth_token):
"""有 token 时返回 200 且包含 list、total、page、pageSize"""
r = client.post(
"/api/projects/list",
headers=_auth_header("valid-token"),
headers=_auth_header(auth_token),
json={"page": 1, "pageSize": 20},
)
assert r.status_code == 200
@@ -33,16 +35,139 @@ def test_list_with_token_returns_200_with_list_total_page_page_size(client):
assert "pageSize" in body
def test_list_empty_result_has_empty_list_and_total_zero(client):
"""无数据时 list 为空数组且 total 为 0"""
def test_list_empty_result_has_empty_list_and_total_zero(client, auth_token):
"""列表返回 200无数据时 list 为空且 total 为 0(有数据时仅校验结构)"""
r = client.post(
"/api/projects/list",
headers=_auth_header("valid-token"),
headers=_auth_header(auth_token),
json={},
)
assert r.status_code == 200
assert r.json()["list"] == []
assert r.json()["total"] == 0
body = r.json()
assert "list" in body and isinstance(body["list"], list)
assert "total" in body and isinstance(body["total"], int)
if len(body["list"]) == 0:
assert body["total"] == 0
def test_list_accepts_search_type_name_and_keyword(client, auth_token):
"""支持 searchType=name 与 keyword 搜索项目名称"""
r = client.post(
"/api/projects/list",
headers=_auth_header(auth_token),
json={"searchType": "name", "keyword": "测试"},
)
assert r.status_code == 200
body = r.json()
assert "list" in body
assert "total" in body
def test_list_accepts_search_type_code_and_keyword(client, auth_token):
"""支持 searchType=code 与 keyword 搜索项目编号"""
r = client.post(
"/api/projects/list",
headers=_auth_header(auth_token),
json={"searchType": "code", "keyword": "HT001"},
)
assert r.status_code == 200
body = r.json()
assert "list" in body
assert "total" in body
def test_list_accepts_progress_filter(client, auth_token):
"""支持按 progress 筛选项目进度"""
r = client.post(
"/api/projects/list",
headers=_auth_header(auth_token),
json={"progress": "进行中"},
)
assert r.status_code == 200
body = r.json()
assert "list" in body
assert "total" in body
def test_list_accepts_cost_filter(client, auth_token):
"""支持按 cost 筛选项目费用"""
r = client.post(
"/api/projects/list",
headers=_auth_header(auth_token),
json={"cost": "100万以下"},
)
assert r.status_code == 200
body = r.json()
assert "list" in body
assert "total" in body
def test_list_accepts_date_filter_and_date_range(client, auth_token):
"""支持 dateFilterType 与 dateFrom、dateTo 时间筛选"""
r = client.post(
"/api/projects/list",
headers=_auth_header(auth_token),
json={
"dateFilterType": "signDate",
"dateFrom": "2024-01-01",
"dateTo": "2024-12-31",
},
)
assert r.status_code == 200
body = r.json()
assert "list" in body
assert "total" in body
def test_list_accepts_date_abnormal_filter(client, auth_token):
"""支持 dateAbnormal=true 仅返回日期异常项目"""
r = client.post(
"/api/projects/list",
headers=_auth_header(auth_token),
json={"dateAbnormal": True},
)
assert r.status_code == 200
body = r.json()
assert "list" in body
assert isinstance(body["list"], list)
assert "total" in body
def test_list_items_contain_required_fields(client, auth_token, market_token):
"""列表项包含 id、projectName、contractCode、progress、cost、updatedAt"""
client.post(
"/api/projects/create",
headers=_auth_header(market_token),
json={"contract": {"contractCode": "HT-LIST", "projectName": "列表项字段测试"}},
)
r = client.post(
"/api/projects/list",
headers=_auth_header(auth_token),
json={"page": 1, "pageSize": 20},
)
assert r.status_code == 200
body = r.json()
assert len(body["list"]) >= 1
item = body["list"][0]
assert "id" in item
assert "projectName" in item
assert "contractCode" in item
assert "progress" in item
assert "cost" in item
assert "updatedAt" in item
def test_list_respects_page_and_page_size(client, auth_token):
"""请求 page、pageSize 时响应中 page、pageSize 与请求一致"""
r = client.post(
"/api/projects/list",
headers=_auth_header(auth_token),
json={"page": 2, "pageSize": 5},
)
assert r.status_code == 200
body = r.json()
assert body.get("page") == 2
assert body.get("pageSize") == 5
# ---------- POST /api/projects/detail ----------
@@ -54,18 +179,18 @@ def test_detail_without_token_returns_401(client):
assert r.status_code == 401
def test_detail_existing_project_returns_200_with_full_fields(client):
def test_detail_existing_project_returns_200_with_full_fields(client, auth_token, market_token):
"""存在项目时返回 200 且包含 id、contract、costControl、receivable、payable、other、createdAt、updatedAt"""
create_r = client.post(
"/api/projects/create",
headers=_auth_header("market-token"),
headers=_auth_header(market_token),
json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}},
)
assert create_r.status_code == 201
pid = create_r.json()["id"]
r = client.post(
"/api/projects/detail",
headers=_auth_header("valid-token"),
headers=_auth_header(auth_token),
json={"id": pid},
)
assert r.status_code == 200
@@ -80,11 +205,11 @@ def test_detail_existing_project_returns_200_with_full_fields(client):
assert "updatedAt" in body
def test_detail_nonexistent_project_returns_404_with_code_and_message(client):
def test_detail_nonexistent_project_returns_404_with_code_and_message(client, auth_token):
"""不存在项目时返回 404 且包含 code 和 message"""
r = client.post(
"/api/projects/detail",
headers=_auth_header("valid-token"),
headers=_auth_header(auth_token),
json={"id": "non-existent-id"},
)
assert r.status_code == 404
@@ -93,6 +218,18 @@ def test_detail_nonexistent_project_returns_404_with_code_and_message(client):
assert "message" in body
def test_detail_missing_id_returns_400(client, auth_token):
"""缺少 id 时返回 400 或 422(参数错误)"""
r = client.post(
"/api/projects/detail",
headers=_auth_header(auth_token),
json={},
)
assert r.status_code in (400, 422)
body = r.json()
assert body.get("code") in (400, 422) or "detail" in body
# ---------- POST /api/projects/create ----------
@@ -105,22 +242,22 @@ def test_create_without_token_returns_401(client):
assert r.status_code == 401
def test_create_non_market_role_returns_403(client):
def test_create_non_market_role_returns_403(client, engineer_token):
"""非市场部角色返回 403"""
r = client.post(
"/api/projects/create",
headers=_auth_header("engineer-token"),
headers=_auth_header(engineer_token),
json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}},
)
assert r.status_code == 403
assert r.json().get("code") == 403
def test_create_market_with_contract_code_and_name_returns_201_with_id_and_message(client):
def test_create_market_with_contract_code_and_name_returns_201_with_id_and_message(client, market_token):
"""市场部且合同编号与项目名称齐全时返回 201 且包含 id 和 message"""
r = client.post(
"/api/projects/create",
headers=_auth_header("market-token"),
headers=_auth_header(market_token),
json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}},
)
assert r.status_code == 201
@@ -129,28 +266,43 @@ def test_create_market_with_contract_code_and_name_returns_201_with_id_and_messa
assert body.get("message") == "创建成功"
def test_create_missing_contract_code_returns_400(client):
def test_create_missing_contract_code_returns_400(client, market_token):
"""缺少合同编号时返回 400"""
r = client.post(
"/api/projects/create",
headers=_auth_header("market-token"),
headers=_auth_header(market_token),
json={"contract": {"projectName": "测试项目"}},
)
assert r.status_code == 400
assert r.json().get("code") == 400
def test_create_missing_project_name_returns_400(client):
def test_create_missing_project_name_returns_400(client, market_token):
"""缺少项目名称时返回 400"""
r = client.post(
"/api/projects/create",
headers=_auth_header("market-token"),
headers=_auth_header(market_token),
json={"contract": {"contractCode": "HT001"}},
)
assert r.status_code == 400
assert r.json().get("code") == 400
def test_create_validation_error_may_include_errors_array(client, market_token):
"""参数校验失败时响应可包含 errors 数组(field、message"""
r = client.post(
"/api/projects/create",
headers=_auth_header(market_token),
json={"contract": {}},
)
assert r.status_code == 400
body = r.json()
assert body.get("code") == 400
if "errors" in body and body["errors"]:
for e in body["errors"]:
assert "field" in e or "message" in e
# ---------- POST /api/projects/update ----------
@@ -160,18 +312,30 @@ def test_update_without_token_returns_401(client):
assert r.status_code == 401
def test_update_existing_project_returns_200_with_id_and_save_success_message(client):
def test_update_missing_id_returns_400(client, auth_token):
"""更新项目缺少 id 时返回 400 或 422(参数错误)"""
r = client.post(
"/api/projects/update",
headers=_auth_header(auth_token),
json={"contract": {"projectName": "任意"}},
)
assert r.status_code in (400, 422)
body = r.json()
assert body.get("code") in (400, 422) or "detail" in body
def test_update_existing_project_returns_200_with_id_and_save_success_message(client, auth_token, market_token):
"""存在项目时返回 200 且包含 id 和 message「保存成功」"""
create_r = client.post(
"/api/projects/create",
headers=_auth_header("market-token"),
headers=_auth_header(market_token),
json={"contract": {"contractCode": "HT002", "projectName": "测试项目2"}},
)
assert create_r.status_code == 201
pid = create_r.json()["id"]
r = client.post(
"/api/projects/update",
headers=_auth_header("valid-token"),
headers=_auth_header(auth_token),
json={"id": pid, "contract": {"projectName": "新名称"}},
)
assert r.status_code == 200
@@ -180,11 +344,11 @@ def test_update_existing_project_returns_200_with_id_and_save_success_message(cl
assert body.get("message") == "保存成功"
def test_update_nonexistent_project_returns_404(client):
def test_update_nonexistent_project_returns_404(client, auth_token):
"""不存在项目时返回 404"""
r = client.post(
"/api/projects/update",
headers=_auth_header("valid-token"),
headers=_auth_header(auth_token),
json={"id": "non-existent-id"},
)
assert r.status_code == 404
@@ -200,18 +364,30 @@ def test_logs_without_token_returns_401(client):
assert r.status_code == 401
def test_logs_with_token_returns_200_with_list_total_page_page_size(client):
def test_logs_missing_id_returns_400(client, auth_token):
"""操作日志缺少 id 时返回 400 或 422(参数错误)"""
r = client.post(
"/api/projects/logs",
headers=_auth_header(auth_token),
json={},
)
assert r.status_code in (400, 422)
body = r.json()
assert body.get("code") in (400, 422) or "detail" in body
def test_logs_with_token_returns_200_with_list_total_page_page_size(client, auth_token, market_token):
"""有 token 时返回 200 且包含 list、total、page、pageSize"""
create_r = client.post(
"/api/projects/create",
headers=_auth_header("market-token"),
headers=_auth_header(market_token),
json={"contract": {"contractCode": "HT003", "projectName": "测试项目3"}},
)
assert create_r.status_code == 201
pid = create_r.json()["id"]
r = client.post(
"/api/projects/logs",
headers=_auth_header("valid-token"),
headers=_auth_header(auth_token),
json={"id": pid},
)
assert r.status_code == 200
@@ -223,12 +399,68 @@ def test_logs_with_token_returns_200_with_list_total_page_page_size(client):
assert "pageSize" in body
def test_logs_nonexistent_project_returns_404(client):
def test_logs_nonexistent_project_returns_404(client, auth_token):
"""不存在项目时返回 404"""
r = client.post(
"/api/projects/logs",
headers=_auth_header("valid-token"),
headers=_auth_header(auth_token),
json={"id": "non-existent-id"},
)
assert r.status_code == 404
assert r.json().get("code") == 404
def test_logs_after_update_returns_record_with_summary(client, auth_token, market_token):
"""更新项目后调用操作日志接口,至少返回一条记录且含 summary"""
create_r = client.post(
"/api/projects/create",
headers=_auth_header(market_token),
json={"contract": {"contractCode": "HT-LOG", "projectName": "日志测试"}},
)
assert create_r.status_code == 201
pid = create_r.json()["id"]
client.post(
"/api/projects/update",
headers=_auth_header(auth_token),
json={"id": pid, "contract": {"projectName": "日志测试已更新"}},
)
r = client.post(
"/api/projects/logs",
headers=_auth_header(auth_token),
json={"id": pid},
)
assert r.status_code == 200
body = r.json()
assert "list" in body
assert len(body["list"]) >= 1
first = body["list"][0]
assert "summary" in first
def test_logs_list_items_contain_required_fields(client, auth_token, market_token):
"""操作日志列表项包含 id、operatorId、operatorName、operatedAt、summary"""
create_r = client.post(
"/api/projects/create",
headers=_auth_header(market_token),
json={"contract": {"contractCode": "HT-LOG2", "projectName": "日志字段测试"}},
)
pid = create_r.json()["id"]
client.post(
"/api/projects/update",
headers=_auth_header(auth_token),
json={"id": pid, "contract": {"projectName": "更新"}},
)
r = client.post(
"/api/projects/logs",
headers=_auth_header(auth_token),
json={"id": pid},
)
assert r.status_code == 200
body = r.json()
assert len(body["list"]) >= 1
item = body["list"][0]
assert "id" in item
assert "operatorId" in item
assert "operatorName" in item
assert "operatedAt" in item
assert "summary" in item
+83
View File
@@ -0,0 +1,83 @@
# 后端 API 测试结果与反馈
**最近一次执行**:回归测试(后端工程师修复登录 500 后)
**测试环境**:对已启动服务 `http://127.0.0.1:8000` 发起 HTTP 请求(测试与开发环境隔离)
---
## 回归测试结果汇总
| 结果 | 数量 |
|------|------|
| **通过** | **35** |
| **失败** | 0 |
| **错误** | 0 |
| **合计** | 35 |
**结论:全部用例通过,回归测试通过。**
---
## 用例明细(35 个)
### 认证(4
- test_login_success_returns_200_with_token_and_user ✓
- test_login_wrong_credentials_returns_401_with_code_and_message ✓
- test_login_missing_username_returns_400 ✓
- test_login_missing_password_returns_400 ✓
### 项目列表(10
- test_list_without_token_returns_401 ✓
- test_list_with_token_returns_200_with_list_total_page_page_size ✓
- test_list_empty_result_has_empty_list_and_total_zero ✓
- test_list_accepts_search_type_name_and_keyword ✓
- test_list_accepts_search_type_code_and_keyword ✓
- test_list_accepts_progress_filter ✓
- test_list_accepts_cost_filter ✓
- test_list_accepts_date_filter_and_date_range ✓
- test_list_accepts_date_abnormal_filter ✓
- test_list_items_contain_required_fields ✓
- test_list_respects_page_and_page_size ✓
### 项目详情(4
- test_detail_without_token_returns_401 ✓
- test_detail_existing_project_returns_200_with_full_fields ✓
- test_detail_nonexistent_project_returns_404_with_code_and_message ✓
- test_detail_missing_id_returns_400 ✓
### 新建项目(5
- test_create_without_token_returns_401 ✓
- test_create_non_market_role_returns_403 ✓
- test_create_market_with_contract_code_and_name_returns_201_with_id_and_message ✓
- test_create_missing_contract_code_returns_400 ✓
- test_create_missing_project_name_returns_400 ✓
- test_create_validation_error_may_include_errors_array ✓
### 更新项目(4
- test_update_without_token_returns_401 ✓
- test_update_missing_id_returns_400 ✓
- test_update_existing_project_returns_200_with_id_and_save_success_message ✓
- test_update_nonexistent_project_returns_404 ✓
### 操作日志(6
- test_logs_without_token_returns_401 ✓
- test_logs_missing_id_returns_400 ✓
- test_logs_with_token_returns_200_with_list_total_page_page_size ✓
- test_logs_nonexistent_project_returns_404 ✓
- test_logs_after_update_returns_record_with_summary ✓
- test_logs_list_items_contain_required_fields ✓
---
## 复现命令
```bash
cd backend
pytest tests/ -v
```