在业务系统里,我们经常接触 PDF 相关的功能:导出对账单、生成合同、转换发票、合并文件、加密归档……但很多团队对 PDF 的验证,长期停留在“人工打开看两眼”的阶段。直到某次上线后发现生成的合同少了一页、金额千分位丢失、加密 PDF 在客户端打不开,才意识到 PDF 也需要一套系统化测试。本文围绕 “Tests for a PDF” 这个主题,从 PDF 测试的对象讲起,介绍如何使用 Python 生态中的 pypdf、pdfplumber、PyMuPDF 与 pytest 构建自动化测试用例,并结合实际代码覆盖页数、文本、元数据、图片、表格、加密文件等常见校验点。整篇文章以可运行的代码为主,读完你就知道怎么把“这份 PDF 看起来没问题”变成一条条可靠的测试断言。
1. 为什么要给 PDF 写自动化测试
1.1 PDF 容易成为测试盲区
在常规的接口测试和功能测试中,JSON 返回结构、数据库字段、页面元素都是比较容易断言的。PDF 却不是这样,它呈现给用户的是一张张“固定版式”的页面,底层却是二进制对象、字体子集、内容流、交叉引用表等复杂结构。你无法用一个简单的assert result == expected判断一份 PDF 是否正确,因此它常常成为质量体系里的盲区。
很多项目在上线前,对 PDF 的验证就是“打开一下,翻几页,觉得没问题就过了”。这种方式有两个明显问题。第一,人工检查只能覆盖少量样本,一旦数据量上来,页码错位、文字截断、金额缺位这类问题很难稳定复现。第二,回归能力弱,生成模板改了一行代码,你很难完整核对所有历史文档是否受影响。自动化测试的意义就在于把 PDF 的正确性从“视觉确认”变成“程序断言”。
1.2 一份 PDF 里到底能测什么
站在测试角度,一份 PDF 可以被拆成多个可验证维度,例如页数、尺寸、文本内容、标题作者等元数据、图片资源、表格结构、表单字段、加密状态、文件体积、字体嵌入情况。不同的业务场景关注的点不一样:合同类关注文本和页数,报表类关注表格和数据是否落位,电子发票关注图片和二维码区域,归档类关注加密与权限。
这里需要特别说明:PDF 的“测试”不只是校验能不能解析,还包括生成端的正向测试和逆向测试。正向测试是验证我们生成的 PDF 是否满足格式要求;逆向测试则是验证程序能否正确读取用户上传或第三方系统返回的 PDF。本文实战部分会覆盖正向 + 逆向两条链路,这样无论你是做 PDF 生成工具还是 PDF 解析服务,都能找到可复用的思路。
1.3 自动化测试能带来什么收益
自动化 PDF 测试最大的价值是缩短反馈周期。以前人工检查一份合同模板需要几分钟,自动化测试可以在几百毫秒内完成几十个断言。把测试接入 CI 后,任何开发改动只要破坏了 PDF 格式约束,流水线就会立刻失败,而不是等测试人员发现。这个收益在模板频繁迭代、数据量较大的项目里尤其明显。如果你想做 PDF 转 Word、PDF 压缩、PDF 解密这类工具型项目,先给 PDF 建立测试基线,后续每一步改动才有底气。
2. 环境准备与工具选型
2.1 运行环境说明
本文示例以 Python 3 为基础,操作系统使用 Windows/macOS/Linux 均可。版本需要根据你的项目实际情况调整,下面以常见环境为例,重点展示配置思路。建议使用虚拟环境管理依赖,避免和系统 Python 环境互相干扰。
python -m venv .venv source .venv/bin/activate # Windows 下使用 .venv\Scripts\activate2.2 三个核心库的定位
PDF 测试最常用的是以下四个 Python 库:
| 库 | import 名 | 主要用途 |
|---|---|---|
| pypdf | pypdf | 页数、元数据、文本提取、PDF 加密解密、页面合并拆分 |
| pdfplumber | pdfplumber | 文本、表格、线条、坐标提取,适合版面分析 |
| PyMuPDF | fitz | 高性能解析、图片提取、渲染页面为图片、OCR 前处理 |
| reportlab | reportlab | 生成 PDF,常用于构造被测文档 |
这里有一个容易混淆的点:PyPDF2是老牌的解析库,但它维护节奏较慢,社区已经逐步迁移到pypdf。新项目建议优先使用pypdf,两者 API 非常接近,但pypdf修复了大量兼容性问题。如果你看到旧文章里的PdfFileReader写法,在新版本里往往已改为PdfReader,安装时注意不要同时安装PyPDF2和pypdf,否则可能出现奇怪的冲突。
2.3 安装依赖
在虚拟环境中安装本文所需的依赖:
pip install pypdf pdfplumber PyMuPDF reportlab Pillow pytestpypdf:负责 PDF 的结构读取和文本提取。pdfplumber:负责表格和更精细的版面解析。PyMuPDF:负责图片提取、渲染和速度更快的文本获取。reportlab:用于构造被测 PDF。Pillow:在测试中临时生成一张图片,作为 PDF 内的图像素材。pytest:测试框架。
如果你所在的网络环境安装较慢,可以使用国内镜像源,例如:
pip install pypdf pdfplumber PyMuPDF reportlab Pillow pytest -i https://pypi.tuna.tsinghua.edu.cn/simple2.4 示例项目结构
本文的示例统一放在下面的目录结构里:
pdf-tests-demo/ ├── report_generator.py ├── requirements.txt └── tests/ └── test_pdf_report.pyreport_generator.py是模拟被测系统的 PDF 生成器,tests/test_pdf_report.py存放所有测试用例。运行时进入项目根目录,执行:
python -m pytest tests/ -v如果遇到ImportError: No module named 'report_generator',说明 pytest 没有把项目根目录加入模块搜索路径。推荐使用python -m pytest而不是直接执行pytest,因为前者会把当前目录加入sys.path。如果你使用的是 PyCharm,也可以在tests/目录下添加一个空的conftest.py,或在根目录配置pytest.ini指定测试路径:
[pytest] python_files = test_*.py python_functions = test_* testpaths = tests3. 核心原理:PDF 解析与断言的底层逻辑
3.1 PDF 不是普通文本文件
先用一句话概括:PDF 是一种以“对象”为基础的文档格式。当你打开一个 PDF 时,看到的是一堆被压缩后的内容流(Content Stream),这些流里记录着“在哪个坐标画一条线”“在哪个位置放一段文字”“引用哪个字体资源”。普通文本文件可以直接 grep,PDF 不可以。正因如此,测试 PDF 的第一步通常是“解析”,也就是把二进制内容转换成 Python 对象,再逐项断言。
一个 PDF 文件内部通常包含 4 个部分:文件头、对象体、交叉引用表、尾信息。文件头声明版本,比如%PDF-1.7;对象体是页面、字体、内容流等实体;交叉引用表和尾信息负责告诉解析器“对象在哪里”。大多数时候你不需要直接处理这些底层结构,但理解这一点有助于解释“为什么有的 PDF 能提取出文本,有的却提取不出来”。
提取不出文本最常见的情况是:PDF 页面里的文字不是真正的文本对象,而是被转成了曲线或图片。例如很多设计软件导出的 PDF,视觉效果是文字,底层却是一张张光栅图片。这类 PDF 无法用文本提取库直接拿到字符内容,只能通过 OCR 或其他方式处理。测试前先确认被测 PDF 的生成方式,可以少走很多弯路。
3.2 三个库的读取思路差异
同样是解析一个 PDF,pypdf、pdfplumber 和 PyMuPDF 的侧重点完全不同。
pypdf 擅长结构层面的解析。它读取 PDF 的页数、元数据、书签、加密信息,适合做“文档属性校验”。它的extract_text()方法能提取页面文本,但遇到复杂排版时不保证顺序完全正确。
pdfplumber 更擅长版面和表格。它可以返回每个字符的坐标、字体大小、宽度,也可以调用extract_tables()把表格线范围内的内容整理成二维数组。适合测试对账单、发票、报表这类带表格结构的 PDF。
PyMuPDF 是性能较强的解析库,导入名是fitz。它既能快速获取文本,也能提取页面内的图片对象,还能把页面渲染成 PNG 图片。如果测试场景需要判断“PDF 里有没有包含 logo 图”或“首页渲染后是否是空白页”,PyMuPDF 会非常合适。
实际项目中,三个库经常组合使用。比如用 pypdf 验证页数和元数据,用 pdfplumber 验证表格数据,用 PyMuPDF 验证图片资源和渲染效果。下面这份依赖矩阵可以作为选型参考:
| 业务需求 | 推荐库 |
|---|---|
| 页数、作者、标题、是否加密 | pypdf |
| 提取页面文本并校验关键词 | pdfplumber / pypdf |
| 提取表格数据 | pdfplumber |
| 提取 PDF 内嵌图片 | PyMuPDF |
| 页面渲染成图片 | PyMuPDF |
| 构造测试用 PDF | reportlab |
3.3 断言思路:从“能不能打开”到“内容对不对”
测试一个 PDF,最基础的断言是“能被解析且页数正确”,但这远远不够。一份 PDF 即使能打开,也有可能出现文本顺序混乱、元数据丢失、图片缺失等问题。所以更完整的断言链条应该是:
- 文件级:文件存在、大小合理。
- 结构级:能解析、页数等于预期、页面尺寸符合模板。
- 内容级:关键文本存在、金额正确、表格行列符合预期。
- 资源级:需要的图片已嵌入、选定的字体已嵌入。
- 安全级:加密文档能用正确密码解密,无密码文档不能被错误操作破坏。
把这 5 层拆解成测试用例,你的 PDF 测试就不再是“能打开就行”,而是逐步逼近真实的业务要求。下面进入实战环节,我们把这套思路写成代码。
4. 完整实战:用 pytest 测试一份 PDF 报告
4.1 生成被测 PDF
为了让测试完全可运行、可复现,我们先用 reportlab 写一个简单的 PDF 生成器。这个生成器模拟业务系统导出报告的行为,支持自定义标题、页数和是否加密。注意:实际项目里这个生成器可能换成你们自己的接口,你只需要把测试断言用到自己的文件上即可。
# 文件路径:report_generator.py from reportlab.pdfgen import canvas from reportlab.lib.pagesizes import A4 def generate_report(path, title="QA Report", pages=1): """ 生成一份简单的 PDF 报告。 :param path: 输出 PDF 路径 :param title: 文档标题 :param pages: 页数 """ c = canvas.Canvas(str(path), pagesize=A4) c.setTitle(title) c.setAuthor("QA Team") for i in range(pages): c.drawString(72, 780, f"Hello PDF Test - Page {i + 1}") c.drawString(72, 760, f"Report title: {title}") c.showPage() c.save() return pathcanvas.Canvas是 reportlab 最基础的画布对象,drawString(x, y, text)会在指定坐标绘制文本。这里我们固定用 A4 纸,每页写两行文字,然后通过showPage()翻页。setTitle和setAuthor会把信息写入 PDF 文档元数据,后面测试可以直接读取。
4.2 文件级测试
先从最基础的文件检查开始。pytest 的tmp_pathfixture 会在每次测试运行时创建一个临时目录,测试结束后自动清理,不需要我们手动删除文件。测试代码如下:
# 文件路径:tests/test_pdf_report.py import os from pypdf import PdfReader from report_generator import generate_report def test_pdf_file_exists_and_not_empty(tmp_path): output = tmp_path / "report.pdf" generate_report(str(output)) assert output.exists() assert output.stat().st_size > 0这段代码验证了两件事:文件是否真生成了、文件是否为空。一个空文件或仅写入 0 字节的文件在业务场景中属于严重事故,能在一开始就拦住。output.stat().st_size是文件系统直接返回的大小,单位为字节,不需要手动 open 文件再去数,成本几乎为零。
4.3 页数与文本内容测试
页数测试是 PDF 测试中最常见、也最容易理解的一类。比如合同生成时,正文每满一页追加一页,那么页数必须符合约定;报表导出时声明了 10 页数据,最终 PDF 也必须是 10 页。下面我们生成一份 3 页的 PDF,并断言PdfReader.pages的长度。
def test_pdf_page_count(tmp_path): output = tmp_path / "multi_page.pdf" generate_report(str(output), pages=3) reader = PdfReader(str(output)) assert len(reader.pages) == 3接着测试文本内容。这里我们使用page.extract_text()获取当前页的文本,再判断关键信息是否包含在内。注意,文本提取结果可能受字体编码和文件生成方式影响,英文内容通常比较可靠,中文内容我们放到第 5 节单独讨论。
def test_pdf_text_content(tmp_path): output = tmp_path / "text.pdf" generate_report(str(output), title="Deploy Report", pages=1) reader = PdfReader(str(output)) page = reader.pages[0] text = page.extract_text() assert "Hello PDF Test" in text assert "Deploy Report" in text这段代码的关键是理解extract_text()返回值是一个字符串。你可以把它当成普通字符串做断言,甚至可以用正则表达式去匹配金额、日期、订单号。这对业务测试很有意义,比如“对账单里不能出现负数”“购物小票的总金额必须大于 0”都可以用类似方式验证。
4.4 元数据测试
PDF 元数据包括标题、作者、创建时间、修改时间等字段。有些业务系统会通过元数据传递文档版本号或来源信息,此时元数据测试就很重要。pypdf 读取元数据的方式是访问reader.metadata,如果 PDF 没有设置元数据,这个值可能是None,所以在断言前要加一层保护。
def test_pdf_metadata(tmp_path): output = tmp_path / "meta.pdf" generate_report(str(output), title="Deploy Report", pages=1) reader = PdfReader(str(output)) assert reader.metadata is not None assert reader.metadata.title == "Deploy Report" assert reader.metadata.author == "QA Team"如果测试失败,可以先检查生成端有没有真正调用setTitle和setAuthor。有些 PDF 生成库虽然设置了属性,但可能在保存时没写进标准元数据字段。这类问题隐蔽性高,自动化测试反而是最有效的发现途径。
4.5 加密 PDF 测试
再来看一个经常被忽略的测试点:加密 PDF。很多系统会先用 pypdf 给 PDF 加密,再提供给用户下载。但实际操作中,最容易出现的问题是“你以为加密了,别人却不需要密码就能打开”。我们用PdfWriter写一个加密函数,然后测试两条路径:一是有密码能否正常解密,二是没有密码时是否正确表现为加密状态。
from pypdf import PdfReader, PdfWriter def encrypt_pdf(src, dst, password): reader = PdfReader(str(src)) writer = PdfWriter() for page in reader.pages: writer.add_page(page) writer.encrypt(password) with open(str(dst), "wb") as f: writer.write(f) def test_encrypted_pdf_requires_password(tmp_path): plain = tmp_path / "plain.pdf" encrypted = tmp_path / "encrypted.pdf" generate_report(str(plain), title="Secret Report", pages=1) encrypt_pdf(str(plain), str(encrypted), "test-pass-123") reader = PdfReader(str(encrypted)) assert reader.is_encrypted result = reader.decrypt("test-pass-123") assert result == PdfReader.PASSWORD # 或者直接判断后续能提取文本 text = reader.pages[0].extract_text() assert "Hello PDF Test" in text这段代码里,is_encrypted用来判断文档是否加密,decrypt(password)用于注入密码。pypdf 解密成功后会返回一个枚举值,不同版本可能有差异,最稳妥的判断方式就是解密后继续读取文本,能读到就说明密码正确。这里只演示合法密码解密,不要尝试绕过或破解自己无权访问的文档。
4.6 运行测试
在项目根目录执行:
python -m pytest tests/ -v预期你会看到类似下面的输出:
tests/test_pdf_report.py::test_pdf_file_exists_and_not_empty PASSED tests/test_pdf_report.py::test_pdf_page_count PASSED tests/test_pdf_report.py::test_pdf_text_content PASSED tests/test_pdf_report.py::test_pdf_metadata PASSED tests/test_pdf_report.py::test_encrypted_pdf_requires_password PASSED如果全部通过,说明你已经拥有一套可以自动校验 PDF 文件的 pytest 用例。接下来我们从“文件存在”和“文本包含”跃升到更复杂的图片、表格和中文场景。
5. 进阶测试:图片、表格与中文场景
5.1 图片包含测试
很多业务 PDF 需要嵌入公司 logo、签名或二维码。测试这类文件的常见做法不是用文本提取,而是使用 PyMuPDF 获取页面图片对象。为了构造被测文件,我们先使用 Pillow 生成一张临时 PNG 图片,再用 reportlab 把它画进 PDF。
import fitz from PIL import Image from reportlab.pdfgen import canvas from reportlab.lib.pagesizes import A4 def generate_pdf_with_image(path, image_path): c = canvas.Canvas(str(path), pagesize=A4) c.drawImage(str(image_path), 100, 600, 120, 120) c.save() def test_pdf_contains_image(tmp_path): # 先生成一张红色小图 img_path = tmp_path / "demo.png" image = Image.new("RGB", (64, 64), "#ff0000") image.save(str(img_path)) # 生成包含图片的 PDF pdf_path = tmp_path / "with_image.pdf" generate_pdf_with_image(str(pdf_path), str(img_path)) # 用 PyMuPDF 检查页面图片数量 doc = fitz.open(str(pdf_path)) page = doc[0] image_list = page.get_images(full=True) assert len(image_list) > 0 doc.close()page.get_images(full=True)返回页面引用的图片对象列表,列表为空说明页面里没有嵌入图片。如果业务上要求“每个 PDF 封面必须包含 logo”,只需要把逻辑改为assert len(image_list) >= 1。更进一步,还可以用doc.extract_image(xref)把图片导出成二进制,再校验图片尺寸或字节数,判断是否使用了正确的资源文件。
5.2 表格提取测试
表格是报表类 PDF 的核心内容。pdfplumber 的extract_tables()能把表格区域转换成二维列表,这是测试“表格内容是否正确”的利器。我们先用 reportlab 的Table对象生成一张带边框的表格 PDF:
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle from reportlab.lib import colors def generate_table_pdf(path): doc = SimpleDocTemplate(str(path), pagesize=A4) data = [ ["Name", "Role", "Status"], ["Alice", "QA", "Pass"], ["Bob", "Dev", "Pass"], ] table = Table(data) table.setStyle(TableStyle([ ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey), ])) doc.build([table]) return path然后编写测试:
import pdfplumber def test_pdf_table_content(tmp_path): pdf_path = tmp_path / "table.pdf" generate_table_pdf(str(pdf_path)) with pdfplumber.open(str(pdf_path)) as pdf: page = pdf.pages[0] tables = page.extract_tables() assert len(tables) >= 1 first_table = tables[0] assert first_table[0][0] == "Name" assert first_table[1][1] == "QA"这段代码的核心是tables[0]返回一个二维列表,tables[0][0]是第一行,tables[0][0][0]是第一行第一列的单元格。实际项目中,表格解析结果高度依赖 PDF 里的表格线结构:有清晰横线和竖线的表格提取效果最好,无边框表格可能被解析成多块区域。遇到提取结果不理想时,建议先打开 pdfplumber 的调试视图,观察它识别的字符和线条分布,再调整断言逻辑。
5.3 中文 PDF 的提取与乱码处理
中文 PDF 的自动化测试会比纯英文麻烦一些。pdfplumber和pypdf提取中文文本时,如果 PDF 的字体没有正确包含ToUnicode映射,返回的字符串就有可能是乱码,甚至完全为空。处理思路按顺序排查:
第一,先确认 PDF 是不是“扫描版”。如果页面本身是图片,任何文本提取库都无法直接取出字符,需要走 OCR 方案,例如 Tesseract 或 PaddleOCR。
第二,确认字体嵌入方式。建议先用 PyMuPDF 的page.get_text()做一个快速验证,如果 PyMuPDF 能拿到文本而 pdfplumber 拿不到,说明是解析库差异,可以围绕 PyMuPDF 建立文本断言。
第三,如果 PDF 生成端是你们自己控制,最稳妥的方案是生成时指定带中文字体的 TTF/TTC 文件,并确保生成库将字体嵌入文档。reportlab 注册中文字体可以使用如下思路,注意字体文件路径需要替换为系统实际存在的字体:
from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont # Windows 示例路径,具体以系统为准 pdfmetrics.registerFont(TTFont("SimHei", "C:/Windows/Fonts/simhei.ttf"))注册后,drawString或Paragraph指定该字体名,中文文本才会被正确编码进 PDF,后续提取测试才会稳定。如果字体文件格式是.ttc,部分版本需要对TTFont传入 subfont index 参数,依赖兼容性较复杂,建议优先准备独立的.ttf文件。
6. 常见问题与排查思路
在实际编写和运行时,你会遇到不少报错。这里整理几个高频问题的排查清单,我已经按“现象 → 原因 → 解决思路”列好,方便直接对照。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
pytest 提示no tests found for given includes: | 测试文件或函数命名不符合test_*规则,或 IDE 指定的测试目录不存在 | 检查test_pdf_*.py文件和test_开头的方法名;在项目根目录重新运行python -m pytest tests/ -v |
| 中文文本提取出来是乱码 | PDF 字体缺少 ToUnicode 映射,或文本是图片 | 先用 PyMuPDFget_text()交叉验证;如果不是标准文本对象,改用 OCR |
PdfReader读取加密 PDF 报错 | 文件已加密,未执行decrypt() | 判断is_encrypted,传入合法密码后再读取页面 |
ImportError: No module named 'report_generator' | pytest 未把项目根目录加入模块搜索路径 | 使用python -m pytest,或在项目根目录配置pytest.ini/conftest.py |
| 安装时同时出现 PyPDF2 和 pypdf | 依赖混乱,两个包都装上了 | 卸载两者后只保留 pypdf,使用pip uninstall PyPDF2 pypdf后重新安装 |
pdfplumberextract_tables()返回空列表 | 表格没有清晰的表格线,或表格区域过大/过小 | 调整table_settings参数,例如设置vertical_strategy和horizontal_strategy |
| 页面能打开但文本全部为空 | 字体子集化导致提取失败,或内容流被压缩 | 检查 PDF 生成端是否嵌入了完整字体,使用 PyMuPDF 尝试提取 |
其中no tests found for given includes:这个报错在 PyCharm 中特别常见,通常是 IDE 里配置的测试路径和实际文件位置不一致。可以先把运行方式切换到命令行,确认命令行能跑通后再回到 IDE。如果一定要用 IDE,打开 Settings → Tools → Python Integrated Tools → Testing,把默认测试运行器设置为 pytest,并保证 working directory 是项目根目录。
7. 最佳实践与工程建议
7.1 测试数据合理管理
PDF 测试往往需要一批固定不变的历史文件作为基线,例如一份“标准合同.PDF”、一份“带签名的发票.PDF”。建议把这些文件放到独立的tests/fixtures/目录,不随意修改。在测试代码中通过Path(__file__).parent / "fixtures"定位文件,这样无论工作目录怎么变,都能稳定找到资源。
对于内容敏感的 PDF,不要直接提交到 Git 仓库。如果测试必须要用,先做脱敏处理:把客户电话、身份证号、真实金额替换成测试数据,再用生成脚本重新造一份测试 PDF。这样既保证测试有效,也避免隐私泄露。
7.2 让测试更快、更稳定
PDF 解析是高 IO 操作,测试用例过多时可以引入 pytest fixture 的scope="session",让同一份 PDF 只生成一次、多个测试共用。例如:
import pytest @pytest.fixture(scope="session") def sample_pdf(tmp_path_factory): path = tmp_path_factory.mktemp("data") / "sample.pdf" generate_report(str(path), pages=5) return path这样文件只在会话开始时生成一次,后续用例直接复用,能有效缩短测试时间。但要注意,如果测试会修改这个 PDF,就不要用 session 级共享,否则用例之间会互相污染。
7.3 CI 集成要点
把 PDF 测试接入 CI 时,优先在构建脚本中固定依赖版本,避免某个库升级后解析结果变化导致“昨天还绿,今天突然失败”。建议在requirements.txt里写清楚主版本约束,例如:
pypdf>=4.0,<5.0 pdfplumber>=0.11,<1.0 PyMuPDF>=1.24,<2.0 reportlab>=4.0,<5.0 pytest>=8.0,<9.0CI 流水线里执行pytest tests/ -v --junitxml=report.xml,再把报告导入测试平台展示。如果 PDF 测试对字体文件有依赖,还需要在 CI 环境里提前安装对应字体,例如 Linux 环境下安装fonts-noto-cjk,否则中文断言会因为缺少字体而不稳定。
7.4 敏感数据与安全边界
涉及 PDF 加密、解密、权限校验的测试,必须遵守最小权限原则。测试代码只应对自己生成的、明确授权的文档执行解密操作,不要提供绕过密码或破解加密内容的方案。生产环境里如果遇到用户上传的加密 PDF,业务逻辑应当返回“请输入密码”,而不是尝试穷举或绕过;自动化测试要覆盖的也是这条正常的授权链路。
另外,处理 PDF 中的图片和文本时,注意不要引入不必要的日志输出。高危场景下,测试日志中不应打印完整客户信息、合同金额等敏感字段。建议只输出“第几页缺少关键词”“表格行数不足”这类结构性描述,降低敏感信息泄露风险。
7.5 封装专属断言工具
当测试用例不断增加,你会发现很多断言逻辑是重复的,例如“某个 PDF 的第 1 页必须包含某个关键词”。这时候可以封装一个断言工具模块,统一维护文本提取策略。一旦某个库的 API 变化,只需要改一处,而不必改动几十个测试文件。
# 文件路径:tests/pdf_assert_helpers.py from pathlib import Path from pypdf import PdfReader def assert_pdf_contains_text(pdf_path: Path, keyword: str, page_index: int = 0): reader = PdfReader(str(pdf_path)) text = reader.pages[page_index].extract_text() assert text is not None, f"Page {page_index} has no extractable text" assert keyword in text, f"Keyword '{keyword}' not found in page {page_index}"这样业务测试代码就会更加简洁,例如:
from pdf_assert_helpers import assert_pdf_contains_text def test_contract_contains_amount(tmp_path): contract = tmp_path / "contract.pdf" generate_contract(str(contract), amount="19800.00") assert_pdf_contains_text(contract, "19800.00", page_index=0)断言工具的封装越早越好。当你为第一个 PDF 测试写断言时,就考虑哪些部分是“和业务无关的解析细节”,把它们抽出来,后续扩展会顺畅很多。
8. 总结与下一步
通过这篇文章,你已经掌握了一套针对 PDF 文件的自动化测试方法:从文件级、结构级、内容级、资源级到安全级逐层设计用例,用 pypdf 校验页数、文本和元数据,用 pdfplumber 分析表格,用 PyMuPDF 提取图片,再用 pytest 把这些检查变成可持续回归的测试套件。实战部分提到的no tests found for given includes:这类 IDE 配置问题,以及中文乱码、加密 PDF 读取失败等高频故障,也都给出了完整的排查路径。
下一步,你可以根据自己项目的业务形态继续深入:如果你们做的是合同生成工具,可以进一步研究文本坐标断言,确保“甲方姓名”和“乙方姓名”不会出现在同一行;如果你们做的是电子发票解析,可以研究用 PyMuPDF 渲染页面后结合 OCR 识别无文本层 PDF;如果你们只是把 PDF 作为导出功能之一,那么先把我上面的基础用例加进 CI,已经能拦住大部分回归问题。
PDF 测试的门槛不在于工具,而在于你是否把“人工检查一遍”的隐性经验,转化成了可断言、可追踪、可自动运行的代码。打开你的项目,找一份真实 PDF,开始写第一条测试用例吧。