最近在用 Python + Qt 做一个客户端项目,于是便重新梳理了一下项目目录结构,以及尝试用了下 pytest 写单元测试。
Python 项目的目录结构参考的是 stackoverflow 上的一位哥们的答案:how-are-python-projects-structured。
1 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'])
1.1 在内部代码写单元测试
按照上面👆的目录结构,如果你想在 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()
那么这时候直接运行肯定会报错,因为此时的 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
2 pytest 基本用法
2.1 pytest 如何找到要执行的测试用例
① 根据命令行参数:
pytest tests/
指定测试目录pytest tests/test_sample.py
指定测试文件pytest tests/test_sample.py::test_func
指定测试函数如果没有指定命令行参数,则查找 ./pytest.ini 配置文件。并由配置文件中的 testpaths,python_files, python_classes,python_functions 配置指定要查找的目录,模块文件,类,函数。
如果没由 ./pytest.ini 文件或者文件中没有指定配置,就在当前工作目录下查找。
② 查找时会递归子目录。(可以在 pytest.ini 文件中使用 norecursedirs 配置项指定不递归的目录)
③ 在这些目录下,查找 test_*.py 和 *_test.py 文件。(可以在 pytest.ini 文件中使用 python_files 配置项修改匹配模式)
④ 在这些文件中,查找如下两种代码:
2.2 在命令行调用 pytest
参考官方文档即可。主要会用到几个参数:
-v
打印更详细的结果
-q
打印更简要的结果
-s
打印测试结果时不吞掉测试代码中的输出。
-k EXPRESSION
根据 EXPRESSION 对测试用例名进行筛选,并且只执行符合 EXPRESSION 的测试用例。在 EXPRESSION 中可以使用 not,and,or 来组合关键字。
-m MARKEXPR
与 -k 类似,用来筛选要执行的测试用例,需要与 @pytest.mark.xxx
装饰器联合使用。(详见下文)
-x
第一次遇到错误后就直接退出,不再进行后续测试。
--lf, --last-failed
只执行上次失败的测试。
2.3 在代码中调用 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 参数是插件列表。(我还没用过 (\”▔□▔))
3 pytest 进阶
3.1 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 文件的例子:
3.2 使用 mark
pytest 提供了装饰器 @pytest.mark.xxx
用来在测试代码中标记测试用例。然后通这些标记并联合 pytest -m
命令行参数来指定哪些测试用例需要被执行,哪些不用执行。
3.2.1 打印现有的 mark 装饰器
可以通过 pytest --makers
打印出现有的 mark 装饰器,包括 pytest 内建的以及在 pytest.ini 中自定义的。
3.2.2 内建 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.
例子:
3.2.3 自定义 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 标记的测试用例。
3.2.4 mark 组合
pytest -m MARKEXPR
这个参数 MARKEXPR 其实是个表达式来着,可以使用 Python 的 not、and、or 语法来组合多个 mark。
pytest -v -m "not slow"
可以用来筛选没有被 slow 标记的测试用例。
pytest -v -m "slow or loginTest"
可以用来筛选被 slow 或者 loginTest 标记的测试用例。
3.3 给测试用例传参
使用 @pytest.fixture
装饰器修饰的非测试函数可以返回一个变量(变量名就是函数名),作为当前测试上下文的一个变量。这样在测试用例中就可以使用该变量作为函数实参。参考:what-fixtures-are
要注意的是,默认情况下,fixture 是每次一有 test_func() 要用到它,都会重新生成一次的。可以通过 scope 参数来调整 fixture 的这个生命周期。
@pytest.fixture(scope=’module’) 表示该 fixture 在整个模块文件中,不论有多少个测试用例用到它,也只生成一次。
@pytest.fixture(scope=’session’) 表示该 fixture 在整次测试过程中,不论有多少个测试用例用到它,也只生成一次。