Python 项目目录结构与 pytest

Python 项目目录结构与 pytest

最近在用 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 packagefrom 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 配置文件。并由配置文件中的 testpathspython_files, python_classespython_functions 配置指定要查找的目录,模块文件,类,函数。

如果没由 ./pytest.ini 文件或者文件中没有指定配置,就在当前工作目录下查找。

② 查找时会递归子目录。(可以在 pytest.ini 文件中使用 norecursedirs 配置项指定不递归的目录)

③ 在这些目录下,查找 test_*.py 和 *_test.py 文件。(可以在 pytest.ini 文件中使用 python_files 配置项修改匹配模式)

④ 在这些文件中,查找如下两种代码:

以 test 开头的函数(不在类内部)
以 Test 开头的类名下的,以 test 开头的类方法

2.1 在命令行调用 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 在整次测试过程中,不论有多少个测试用例用到它,也只生成一次。

 

Leave a Reply

Your email address will not be published. Required fields are marked *

TOC