应届生投测试开发岗,简历最缺的往往不是关键词,是一个能讲透的小项目。
关键词谁都会写:pytest、requests、接口自动化、postman、Jenkins——一串排下来看着挺唬人,面试官一追问「你这些是在什么项目里用的?断言怎么分层?异常路径怎么覆盖?CI 怎么挂?」,很多人当场就卡住了。
原因很简单:简历上写的是名词,脑子里没有项目。
一般经验:面试官看到「熟悉接口自动化」这几个字,通常会追问三个问题——你的用例是怎么分层的?断言只断状态码还是断到业务字段?出错了怎么定位、怎么让别人复现? 这三个问题背后,其实是在筛掉「跟着教程抄一遍就写进简历」的候选人,留下「有一个真正属于自己的项目、能一层层剥开讲清楚」的人。
这篇只钉死一个场景:给一个公开的 REST API(
jsonplaceholder.typicode.com),从零写一套 pytest + requests 的接口自动化,做到能放进简历、能对着屏幕讲透每一步为什么这么写。 分四步走:环境搭建、用例分层、数据驱动与断言、报告与 CI 挂钩。每一步都会给出可直接跑的代码,以及这一步「为什么这么做、不这么做会怎样」的取舍说明。全篇不引用任何就业或薪资数据,只讲工程做法;涉及求职建议的地方,一律标「一般经验」。
一、场景钉死:给 jsonplaceholder 写接口自动化
jsonplaceholder 是一个公开、免费、无需注册的 REST API mock 服务,提供 /posts、/comments、/users 等资源的标准 CRUD 接口,返回结构稳定,非常适合做教学演示。我们把它当作「被测系统」,目标很朴素:
- 覆盖 GET 单资源、GET 列表、POST 创建三类接口;
- 冒烟用例、正常路径、异常路径、边界,各来一份;
- 参数化跑数据驱动,不写重复代码;
- 断言不仅看状态码,还要看响应结构与业务字段;
- 一份 README 讲清怎么跑,一段 GitHub Actions 让 PR 自动跑一遍。
这个规模不大,一个下午能做完,但五脏俱全——「小而完整」远比「大而浅」更能撑住追问(一般经验)。
二、第一步:环境搭建(venv + requirements.txt + 目录约定)
先用虚拟环境把依赖隔离干净,别把包塞进全局 Python:
python -m venv .venv
# Windows
.venv\Scripts\activate
# macOS / Linux
source .venv/bin/activate
pip install pytest requests pytest-html jsonschema
pip freeze > requirements.txt
项目目录按「配置 / fixture / 用例 / 数据 / 报告」五块分:
api-auto-demo/
├── .github/
│ └── workflows/
│ └── pytest.yml # CI 配置
├── config/
│ └── settings.py # BASE_URL、超时等
├── tests/
│ ├── conftest.py # 全局 fixture
│ ├── test_smoke.py # 冒烟:核心接口能通
│ ├── test_posts_happy.py # 正常路径
│ ├── test_posts_error.py # 异常路径
│ └── test_posts_boundary.py # 边界
├── data/
│ └── post_cases.yaml # 参数化数据(可选)
├── reports/ # pytest-html 输出
├── requirements.txt
├── pytest.ini
└── README.md
pytest.ini 只做最小配置:
[pytest]
testpaths = tests
addopts = -v --html=reports/report.html --self-contained-html
config/settings.py:
BASE_URL = "https://jsonplaceholder.typicode.com"
TIMEOUT = 10
这一步看着简单,但很多应届生的项目就是从这一步开始乱的——所有代码塞在一个 test.py 里,跑一次得手动改 URL,别人拿到手不知道怎么复现。目录约定就是可复现性的第一道门槛:一个陌生人 clone 下来,pip install -r requirements.txt 再 pytest,能一次跑通,这个项目才算立住了。
两个细节顺带说一下。第一,requirements.txt 里的版本要不要锁死?一般经验是锁大版本、放开小版本,比如 pytest>=8.0,<9.0,避免半年后 pytest 出了大版本改语法,别人 clone 下来直接跑不起来;如果你追求极致可复现,也可以用 pip freeze 输出精确到小版本号的锁文件。第二,README 至少要写清三件事:这是什么项目、怎么装依赖、怎么跑用例——三行字就够,但没有这三行,面试官打开仓库第一眼就是「这人不会写文档」,印象分先扣一档。
三、第二步:conftest.py 与用例分层
conftest.py 是 pytest 的「共享配置中心」,把 session 对象、BASE_URL、公共断言函数都放进去,避免每条用例重复写:
# tests/conftest.py
import pytest
import requests
from config.settings import BASE_URL, TIMEOUT
@pytest.fixture(scope="session")
def api():
"""整个测试会话共用一个 Session,复用 TCP 连接。"""
s = requests.Session()
s.headers.update({"Content-Type": "application/json"})
yield s
s.close()
@pytest.fixture
def base_url():
return BASE_URL
@pytest.fixture
def timeout():
return TIMEOUT
def assert_fields(payload, required_fields):
"""最简 schema 校验:字段存在 + 类型正确。"""
for name, typ in required_fields.items():
assert name in payload, f"缺少字段 {name}"
assert isinstance(payload[name], typ), f"{name} 类型不对,期望 {typ}"
用例按四层分文件写:
- 冒烟(test_smoke.py):一两条,只验「服务活着、核心接口能通」,每次部署完先跑;
- 正常路径(test_posts_happy.py):主流程,如 GET 单条 post、GET 某用户下所有 post、POST 新建;
- 异常路径(test_posts_error.py):404、非法 id、方法不允许;
- 边界(test_posts_boundary.py):id=0、id 超大、空 body、极长字符串。
分层的价值不是文件好看,是让面试官问「你怎么覆盖异常路径」的时候,你能直接翻出一个文件给他看——用例的物理组织,就是你脑子里测试策略的映射。
四、第三步:parametrize 数据驱动 + 断言三层
数据驱动是应届生项目里最容易被追问、也最容易做假的一环。做假的写法是把 5 条用例复制粘贴改参数;做真的写法是用 @pytest.mark.parametrize 把数据从代码里抽出来:
# tests/test_smoke.py
def test_smoke_service_alive(api, base_url, timeout):
"""冒烟:核心接口可达,服务活着。"""
resp = api.get(f"{base_url}/posts/1", timeout=timeout)
assert resp.status_code == 200
# tests/test_posts_happy.py
import pytest
from tests.conftest import assert_fields
@pytest.mark.parametrize("post_id,expected_user_id", [
(1, 1),
(50, 5),
(100, 10),
])
def test_get_post_by_id(api, base_url, timeout, post_id, expected_user_id):
"""正常路径:按 id 拿 post,三层断言。"""
resp = api.get(f"{base_url}/posts/{post_id}", timeout=timeout)
# 第 1 层:状态码
assert resp.status_code == 200
data = resp.json()
# 第 2 层:响应 schema
assert_fields(data, {"userId": int, "id": int, "title": str, "body": str})
# 第 3 层:业务字段
assert data["id"] == post_id
assert data["userId"] == expected_user_id
assert data["title"].strip(), "title 不应为空字符串"
# tests/test_posts_error.py
import pytest
@pytest.mark.parametrize("bad_id", [0, -1, 99999])
def test_get_post_not_found(api, base_url, timeout, bad_id):
"""异常路径:不存在或非法 id 应返回 404。"""
resp = api.get(f"{base_url}/posts/{bad_id}", timeout=timeout)
assert resp.status_code == 404
# tests/test_posts_boundary.py
def test_create_post_with_empty_body(api, base_url, timeout):
"""边界:POST 空 body,服务应仍能返回 201 并给出资源 id。"""
resp = api.post(f"{base_url}/posts", json={}, timeout=timeout)
assert resp.status_code == 201
assert "id" in resp.json()
def test_create_post_with_long_title(api, base_url, timeout):
"""边界:title 5000 字符,验证服务不因长度拒收。"""
long_title = "a" * 5000
payload = {"title": long_title, "body": "x", "userId": 1}
resp = api.post(f"{base_url}/posts", json=payload, timeout=timeout)
assert resp.status_code == 201
assert resp.json()["title"] == long_title
这四段合起来满足「4—6 条示例用例」的口径:冒烟 1 条 + 正常路径 3 条(参数化展开)+ 异常路径 3 条(参数化展开)+ 边界 2 条,总共 9 个 test item,但代码只有 4 个函数。
断言按「状态码 → schema → 业务字段」三层写,是这个项目里最值得讲的一件事——很多应届生只断言 status_code == 200,一旦服务返回 200 但 body 结构变了、字段值错了,用例就成了摆设。三层断言的意思,是把「接口通不通」「结构对不对」「值准不准」分开验证,任何一层挂了都能立刻定位是哪一类问题。
关于 parametrize 再多说两句。第一,参数化的价值不在「代码短」,而在**「数据变更不动代码」**——想加一组新的 post_id,只在装饰器列表里加一行,用例函数本身不用改,这在真实项目里意味着"新增覆盖"是一次低风险动作。第二,参数化默认会用 post_id0、post_id1 这样的自动生成 id,报告里看不出来是哪组数据挂了;生产实践里通常会加 ids=[...] 参数给每组数据起个可读名字,比如 ids=["first_post", "middle_post", "last_post"],这是"可讲性"里的一个小加分项。第三,assert_fields 抽成公共函数,是为了让"schema 断言"这件事只写一次——真实项目里往往会换成 jsonschema 库做正式 schema 校验,但抽函数这一步的思路是一样的:同一件事不要在 10 条用例里写 10 遍。
五、第四步:报告与 CI 挂钩
本地跑完出一份 HTML 报告:
pytest --html=reports/report.html --self-contained-html
--self-contained-html 会把 CSS/JS 内联进单个 HTML 文件,扔给面试官或部署到 GitHub Pages 都能直接打开,不需要额外资源。
再挂一段 GitHub Actions,让每次 push / PR 自动跑:
# .github/workflows/pytest.yml
name: pytest-api-auto
on:
push:
branches: [ main ]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install deps
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run pytest
run: pytest -v --html=reports/report.html --self-contained-html
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: pytest-report
path: reports/
三个要点值得单独讲:if: always() 保证用例失败也能把报告上传出来,不然红了就啥也看不到;cache: 'pip' 让依赖装得快,节省 CI 分钟数;pull_request 触发意味着这个仓库只要收到 PR,接口用例就会自动跑一遍——这就是「可复现性」从口号变成动作的地方:不是你本地能跑就行,是任何人、任何机器、任何一次提交,都能自动跑一遍并留下证据。
CI 挂上之后,还有三个"顺手就能做"的动作,一般经验值得做进项目里:第一,在 README 顶部挂一个 GitHub Actions 的状态徽章(),任何人打开仓库第一眼就能看到"这个项目是活的、CI 是绿的";第二,故意提一个 PR 让某条用例挂掉,然后把 Actions 的运行日志和上传的 HTML 报告截图放进 README 的"演示失败定位"章节——能演示怎么定位失败,比只演示成功更能撑住追问;第三,面试现场如果允许打开电脑,直接把仓库地址给面试官,让他随机挑一条用例点进去看代码、看 CI 运行记录,这个动作比讲十分钟都管用。
六、只会写脚本 vs 能讲透:五维度对照
同一句「熟悉接口自动化」,落到项目上差别有多大?下面这张表是带应届生做项目时常用的自检表(一般经验,不代表任何统计口径):
| 维度 | 「只会写脚本」的应届生项目 | 「能讲透」的应届生项目 |
|---|---|---|
| 结构 | 一个 test.py 打天下,URL、数据、断言全糊在一起 | config / tests / data / reports分目录,conftest 抽公共 fixture |
| 断言 | 只断 status_code == 200 | 三层断言:状态码 → 响应 schema → 业务字段 |
| 异常处理 | 没有异常路径用例,或只写一条「404」 | 404 / 非法 id / 空 body / 超长字段 分开覆盖,parametrize 列数据 |
| 可复现性 | 依赖靠口口相传,别人 clone 下来跑不起来 | requirements.txt + pytest.ini + README + CI,一条命令跑通 |
| 可讲性 | 面试官问「为什么这么写」,答「网上抄的」 | 每一层能说出取舍:为什么用 Session、为什么参数化、为什么分四层 |
这五维度不是要你逐条打分,是给你一个自检清单。做完项目、推上 GitHub 之前,把这五个格子逐格问自己一遍——能答上来的,才写进简历;答不上来的,回炉补一遍再来。
七、把它讲透,比把它做大更重要
一般经验:应届生做第一个项目,最容易犯的错不是「做得简单」,而是「做得又多又浅」——十几个用例堆在一起,看着挺满,被追问一层就露馅。
真正让面试官记住的,是一个小而完整的项目:目录清爽、用例分层、参数化用得起、断言写得深、CI 挂得上、README 说得清。jsonplaceholder 这种公开 API 就够用,你完全不需要自己搭一套后端。
写完推上 GitHub 之后,练三件事:
- 用三分钟把这个项目讲一遍:背景是什么、分了哪四层、断言策略是什么、CI 怎么挂的;
- 现场改一条用例,让 parametrize 多跑一组数据,讲清「为什么这组数据值得加」;
- 打开 GitHub Actions 的运行记录,指着日志说「这一步为什么失败 / 为什么通过」。
这三件事能顺畅做下来,简历上那句「熟悉 pytest + requests 接口自动化」,才算真正立住。项目不是给招聘方看的展品,是给你自己练「怎么讲清楚一件事」的稿子——稿子练熟了,面试就是照着念。
关键词能替你打开筛选,但只有能讲透的项目,能替你撑过追问。
如果你也在做自己的第一个测试项目,欢迎把仓库地址留在评论区,一起看看彼此的分层与断言怎么写。
关于我们
本文部分内容参考了霍格沃兹测试开发学社整理的相关技术资料,主要涉及软件测试、自动化测试、测试开发及 AI 测试等内容,侧重测试实践、工具应用与工程经验整理。