最近在用 Python + Qt 做一个客户端项目,于是便重新梳理了一下项目目录结构,以及认真地用 pytest 写了一些单元测试。
Python 项目的目录结构参考的是 stackoverflow 上的一位哥们的答案:how-are-python-projects-structured。
Python 项目目录结构
以下图为例:

① 项目(project)根目录叫做 TransferDog,项目的源码包(package)就叫做 transfer_dog。
② 所有不应该暴露给用户的文件都要放在包目录里面。其他允许用户修改查看的文件则放在根目录下,比如配置目录 conf/,日志目录 log/,README 文件。
③ designer/ 目录存放的是 Qt Desinger 生成的 .ui 文件,它并不是程序源码必须的(必须的是由 .ui 文件编译生成 .py 文件),所以可以将 .ui 文件放在根目录下。而真正有用的 .py 界面文件则应该放在 package/ui/ 目录下。
④ 在 package/ 目录下,
- package/model/ 目录存放数据模型的源码。
- package/resource/ 目录存放静态资源文件,img,qss 这些。
- package/ui/ 目录存放由 .ui 文件编译生成的 .py 界面代码。designer/main_windows.ui ⇒ package/ui/ui_main_window.py,它只包含 GUI 的界面初始化,不包括数据绑定与用户操作这些高级逻辑。
- package/utility/ 目录存放通用代码。
- package/view/ 目录存放视图(窗口)类的源码。package/view/main_window.py 通常是 package/ui/ui_main_window.py 的派生,由它来负责数据绑定,界面更新,用户操作等逻辑。
⑤ 程序入口 main.py 必须放在项目根目录下,与 package/ 目录同一级。这样在 main.py 中就可以直接调用 import package 或者 from package import module。因为根据 Python 的包引入逻辑:
对于
import abc这种语法,python 是如何查找这个 abc 模块的呢?首先,python 会先去 sys.modules 中查找 abc 模块,sys.modules 保存着已经导入过的模块;
然后,如果找不到的话,python 就会去查找内置的 标准库;
最后,如果上述两个位置都找不到 abc 模块,python 就会去 sys.path 变量中的所有路径下查找。(当前工作目录 通常在 sys.path 的第一位,第三方库 site-packages/ 通常排在 sys.path 中最后一位。当前工作目录是指
python main.py入口文件所在的目录。)上面三个位置都找不到的话,就抛出异常 ModuleNotFoundError。
这样做的最大好处是,对于 package 中任意源码文件,也都可以直接 import package 或 from package import module 了!不再需要使用相对路径引入。
⑥ 入口文件 main.py 的代码应该尽量精简。比如像这样直接调用 package 中的运行方法:
import sys
from package import app
if __name__ == '__main__':
sys.exit(app.run())
然后由这个 app 模块去执行真正的程序运行代码,比如初始化窗口等。在 package/app.py (在我上面的截图中是 transfer_dog/transfer_dog.py)中就可以直接 from package.view.main_window import MainWindow 来引入窗口类了。
⑦ 与 main.py + package/ 的道理一样,测试代码的总入口 test.py 与测试案例目录 pytests/ 也要放在根目录下。这样在根目录下运行 python test.py 的时候,就会自动将项目根目录加入到 Python 的 sys.path 环境变量中。那么在 pytests/ 目录下的测试模块中,也就可以直接 import package 或者 from package import module 了!
测试入口 test.py 的代码也很简单:
import pytest
if __name__ == "__main__":
# 执行 pytests 目录下的所有测试代码,并输出详细信息
pytest.main(['pytests', '-v'])
在内部代码写单元测试
按照上面👆的目录结构,如果你想在 TransferDog/transfer_dog/view/dialog_regex.py 文件中编写测试代码,如下面所示:
import xxx
from transfer_dog.ui.ui_dialog_regular_express import Ui_Dialog
class DialogRegularExpress(QDialog, Ui_Dialog):
# ...
def test(arg=None):
# 测试代码 ...
if __name__ == "__main__":
test()
那么这时候直接在 IDE 中运行该文件,或者在命令行中运行 $python3 transfer_dog/view/dialog_regex.py,肯定都会报错,因为此时的 sys.path 中是找不到 transfer_dog 模块的。
有一个 trick 的办法就是,在 import transfer_dog 模块之前,先设置好 sys.path。将如下代码添加到文件头部,在 import 语句之前:
if __name__ == "__main__":
import sys
from pathlib import Path
sys.path.append( str(Path(__file__).parent.parent.parent) )
pass
但更合理的方法,对于这种写在内部文件中的测试代码,应该使用 Module run as a script 语法,即在项目根目录下运行 $python3 -m transfer_dog.view.dialog_regex.
pytest 基本用法
pytest 如何找到要执行的测试用例
① 根据命令行参数:
pytest tests/指定测试目录pytest tests/test_sample.py指定测试文件pytest tests/test_sample.py::test_func指定测试函数
如果没有指定命令行参数,则查找 ./pytest.ini 配置文件。并由配置文件中的 testpaths, pythonpath, python_files, python_classes,python_functions 配置指定要查找的目录,模块文件,类,函数。
如果没由 ./pytest.ini 文件或者文件中没有指定配置,就在当前工作目录下查找。
② 查找时会递归子目录。(可以在 pytest.ini 文件中使用 norecursedirs 配置项指定不递归的目录)
③ 在这些目录下,查找 test_*.py 和 *_test.py 文件。(可以在 pytest.ini 文件中使用 python_files 配置项修改匹配模式)
④ 在这些文件中,查找如下两种代码:
- 以 test 开头的函数(不在类内部)
- 以 Test 开头的类名下的,以 test 开头的类方法
在命令行调用 pytest
参考官方文档即可。主要会用到几个参数:
-v打印更详细的结果-q打印更简要的结果-s打印测试结果时不吞掉测试代码中的输出。-k EXPRESSION根据 EXPRESSION 对测试用例名进行筛选,并且只执行符合 EXPRESSION 的测试用例。在 EXPRESSION 中可以使用 not,and,or 来组合关键字。-m MARKEXPR与 -k 类似,用来筛选要执行的测试用例,需要与@pytest.mark.xxx装饰器联合使用。(详见下文)-x第一次遇到错误后就直接退出,不再进行后续测试。--lf, --last-failed只执行上次失败的测试。
在代码中调用 pytest
在 test.py 文件中调用 pytest.main() 函数,然后运行 python test.py 即可执行 pytest 测试。
pytest.main(args=None, plugins=None) 函数接受两个参数,args 和 plugins,两个参数都是列表类型。
args 参数是等同于 pytest 命令行参数的列表。
# 运行 pytests 目录下的测试用例,并打印详细结果 pytest.main(['pytests', '-v']) # 运行 pytests/test_constants.py 模块的测试用例,只打印精简结果 pytest.main(['pytests/test_constants.py', '-q']) # 运行 pytests/test_sample.py::test_fun1_equal 测试函数,打印详细结果,并且不吞掉源代码本来的输出 pytest.main(['pytests/test_sample.py::test_fun1_equal', '-v', '-s']) ## 注意 ## # 不建议在同一个文件中,连续多次调用 pytest.main(),最好只使用一次。
plugins 参数是插件列表。(我还没用过 (\”▔□▔))
pytest 进阶
pytest.ini 配置文件
不论是在命令行中执行 pytest 还是在 test.py 中执行 pytest.main(),pytest 默认都会去加载当前目录的 ./pytest.ini 文件。(当然,如果没有该文件就算了。)
pytest.ini 文件中配置的优先级要低于命令行参数与 pytest.main() 函数参数。
pytest.ini 文件需要以 [pytest] 这一行开头。
pytest.ini 常用的配置有:
- pythonpath 将路径加入到 Python 的 sys.path 环境变量中。这个很有用!因为对于上文中描述的项目目录结构,如果不是运行
python test.py而是在根目录下运行pytest命令,那么系统并不会将当前路径加入到 Python 的 sys.path 环境变量中。这时候就需要在配置文件中指定pythonpath = . - testpaths 指定测试用例的目录
- python_files 指定测试用例的模块文件
- python_classes
- python_functions
- markers 添加自定义的 mark_name,用来标记测试用例。与命令行参数
pytest -m mark_name协作,用来筛选并只执行被@pytest.mark.mark_name修饰器标记过的测试函数
下图是一个 pytest.ini 文件的例子:

使用 mark
pytest 提供了装饰器 @pytest.mark.xxx 用来在测试代码中标记测试用例。然后通这些标记并联合 pytest -m 命令行参数来指定哪些测试用例需要被执行,哪些不用执行。
打印现有的 mark 装饰器
可以通过 pytest --makers 打印出现有的 mark 装饰器,包括 pytest 内建的以及在 pytest.ini 中自定义的。
内建 mark
pytest 提供了几种内建 mark。常用的有:
@pytest.mark.no_cover: disable coverage for this test.@pytest.mark.skip(reason=None): skip the given test function with an optional reason. Example:skip(reason="no way of currently testing this")skips the test.@pytest.mark.skipif(condition, ..., *, reason=...): skip the given test function if any of the conditions evaluate to True. Example:skipif(sys.platform == 'win32')skips the test if we are on the win32 platform.
例子:


自定义 mark
可以在 pytest.ini 中使用 markers 配置项添加自定义的 mark。
# 添加自定义的 marker
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
loginTest: Run login test cases
- 上面这几行配置,就添加了两个自定义 mark: slow 和 loginTest。(冒号后面的是描述文字)
- 然后就可以在测试代码中使用
@pytest.mark.slow与@pytest.mark.loginTest来标记测试用例。 - 最后在执行测试的时候,可以使用
pytest -v -m slow来筛选出被 slow 标记的测试用例。
mark 组合
pytest -m MARKEXPR这个参数 MARKEXPR 其实是个表达式来着,可以使用 Python 的 not、and、or 语法来组合多个 mark。pytest -v -m "not slow"可以用来筛选没有被 slow 标记的测试用例。pytest -v -m "slow or loginTest"可以用来筛选被 slow 或者 loginTest 标记的测试用例。
给测试用例传参
fixture 生成固定实参
使用 @pytest.fixture 装饰器修饰的非测试函数可以返回一个变量(变量名就是函数名),作为当前测试上下文的一个变量。这样在测试用例中就可以使用该变量作为函数实参(有点 Spring 的依赖注入的感觉了!)。参考:what-fixtures-are
要注意的是,默认情况下,fixture 是每次一有 test_func() 要用到它,都会重新生成一次的。可以通过 scope 参数来调整 fixture 的这个生命周期。
- @pytest.fixture(scope=’module’) 表示该 fixture 在整个模块文件中,不论有多少个测试用例用到它,也只生成一次。
- @pytest.fixture(scope=’session’) 表示该 fixture 在整次测试过程中,不论有多少个测试用例用到它,也只生成一次。
- @pytest.fixture(scope=’function’) 默认值
mark.parametrize 生成实参组合
如果你要测试一个 register 表单, 写了一个 test_register_form(name, age) 函数进行测试。然后你准备了许多组测试数据,比如:
test_data = [
{"name": "funway", "age": 18},
{"name": "cynnie", "age": 119},
{"name": "zoe", "age": 0},
]
那么这时候就要用到 @pytest.mark.parametrize 装饰器来将这些测试数据一组一组“喂”给测试函数进行测试,一个测试函数跑完所有的数据组合,不用写一堆重复代码了。
import pytest
@pytest.mark.parametrize("test_input,expected", [("3+5", 8), ("2+4", 6), ("6*9", 42)])
def test_eval(test_input, expected):
# test_eval 会跑三次,测试完 mark.parametrize 给的三组数据
assert eval(test_input) == expected
测试用例的生命周期
pytest 测试用例(runtest)的生命周期包括 setup, call, teardown 三个阶段。
import pytest
@pytest.fixture
def resource():
print("fixture setup")
yield "xxx"
print("fixture teardown")
def test_demo(resource):
print("test call")
assert 1 == 2
在执行这个 test_demo 的时候,其内部流程是这样的:
- setup 阶段:
- 执行 fixture 的前置部分 (实际上 fixture 利用了
yield生成器的功能实现前后分离)- 先是执行被 fixture 修饰的
resource()函数 → 得到生成器 gen - 然后执行
next(gen)→ 相当于执行 resource 函数体,并到yield那一句为止暂停,返回资源
- 先是执行被 fixture 修饰的
- 如果 setup 报错,就不会执行 call 阶段
- 执行 fixture 的前置部分 (实际上 fixture 利用了
- call 阶段:
- 执行
test_demo()函数(print("test call")) - 这里面做断言判断
- 执行
- teardown 阶段:
- 执行 fixture 的后置部分 (
yield之后的代码, 底层逻辑就是继续next(gen),跑完生成器函数的后续代码)
- 执行 fixture 的后置部分 (
与测试用例生命周期相对应的,还有三个钩子函数 pytest_runtest_setup, pytest_runtest_call, pytest_runtest_teardown.
Hooks & Plugins
pytest 还提供了很多钩子函数允许你在整个测试流程的生命周期插入自定义逻辑,修改测试行为或者生产自定义报告。参考: 官方文档