@Lenciel

Python in 2020 (3) - 静态扫描

静态扫描其实会干很多事情:代码格式的纠错,静态分析等等。它会被很多人忽视,但其实无论是对提高个体的代码质量,还是提高整个团队的沟通质量,静态扫描都非常重要,我写 markdown 都会用静态扫描工具。

Python 静态扫描一般是通过 pylint 配合一些聚合器比如 flake8pylarma 或者 prospector 来完成的。这里主要讲 flake8 的使用。

目录

Flake8

在 Nox 里面配置一个 session 来运行 Flake8:

# noxfile.py
locations = "src", "tests", "noxfile.py"


@nox.session(python=["3.8", "3.7"])
def lint(session):
    args = session.posargs or locations
    session.install("flake8")
    session.run("flake8", *args)

可以看到,我们对代码目录、测试目录和 noxfile.py 进行静态扫描。Flake8 底层使用很多的工具完成整个扫描后,会汇总成一个报告:

  • F的是 pyflakes 扫描出来的 error。
  • WEpycodestyle 扫描出来的违反了 PEP8 的 warning 和 error。
  • C 的是根据配置的复杂度检查开关使用 mccabe 检查出来的 violation。

为了控制这个报告的内容可以自行编辑 .flake8 配置文件,比如:

# .flake8
[flake8]
select = C,E,F,W
max-complexity = 10

这样,就可以使用 nox 来运行静态扫描了:

$ nox -rs lint

Flake8 主要的威力就是它的插件体系,可以花一些时间熟悉并学会配置它们。

格式化代码:Black

在静态扫描过程中还可以用上的是 Black, 这个工具的特点就是没有可配置性:事关格式,非黑即白。

在 Nox 里面添加一个 session:

 # noxfile.py
@nox.session(python="3.8")
def black(session):
    args = session.posargs or locations
    session.install("black")
    session.run("black", *args)

然后运行就好:

$ nox -rs black

nox > black src tests noxfile.py
reformatted noxfile.py
All done! ✨ 🍰 ✨
1 file reformatted.
nox > Session black was successful.

另外,如果想要知道 Black 究竟会怎么改代码的格式,可以使用 flake8-black 插件来提前检测:

# noxfile.py
@nox.session(python=["3.8", "3.7"])
def lint(session):
    args = session.posargs or locations
    session.install("flake8", "flake8-black")
    session.run("flake8", *args)

然后你可以在 .flake8 文件里面去掉你不希望被改正的错误:

# .flake8
[flake8]
select = BLK,C,E,F,W
ignore = E203,W503
max-line-length = 88

检查包的引用:flake8-import-order

在 PEP8 里面明确规定了包应该按照系统自带、第三方和本地包三个优先级来引用。这个可以通过 flake8-import-order 来检查。

在 Flake8 插件声明里面再增加一个插件:

# noxfile.py
@nox.session(python=["3.8", "3.7"])
def lint(session):
    args = session.posargs or locations
    session.install("flake8", "flake8-black", "flake8-import-order")
    session.run("flake8", *args)

然后编辑配置文件:

# .flake8
[flake8]

# 增加 I 类型的告警
select = BLK,C,E,F,I,W

# 声明本地包有哪些
application-import-names = tests

# 声明使用 google 风格
import-order-style = google

其他 Flake8 插件

上面这些只是 Flake8 插件生态中的一部分。值得尝试的还有安全方面的:Safetyflake8-bandit,代码逻辑方面的flake8-bugbear等等。

结合Poetry

测试一个很核心的要点就是可重入。

任何时间、任何人在任何机器上能够重入,先决条件是一个稳定的环境和输入。

比如使用 Nox 的时候,我们做了一些依赖的声明,比如:

session.install("flake8")

因为 Flake8 自己会不断升级,在不同时间运行测试代码完全可能会得到不同的输出结果。

如果写成下面这样会好一些:

session.install("flake8==3.7.9")

但是:

  • 这样只声明了顶层依赖,它的二级依赖仍然有可能是不同版本
  • 这样没有利用 Poetry 在唯一的地方把包和依赖管理起来

我们如果直接用类似处理 Poetry 的方式把 Flake8 声明成外部依赖呢:

session.run("flake8", "install", external=True)

这样一个很明显的问题就是在静态扫描的 session 里面将会引入大量我们不想要的东西,比如包依赖关系,比如一些根本不需要的包(比如测试相关的)。

如果通过 session.install 安装一个包,但是又用 Poetry 来统一管理它们?可以借助 pip 的 requirements.txt 文件配合 poetry 的 export 命令来完成:

# noxfile.py
def install_with_constraints(session, *args, **kwargs):
    with tempfile.NamedTemporaryFile() as requirements:
        session.run(
            "poetry",
            "export",
            "--dev",
            "--format=requirements.txt",
            f"--output={requirements.name}",
            external=True,
        )
        session.install(f"--constraint={requirements.name}", *args, **kwargs)

这样就可以在声明 session 的时候使用 install with _constraints 了:

@nox.session(python="3.8")
def black(session):
    args = session.posargs or locations
    install_with_constraints(session, "black")
    session.run("black", *args)


@nox.session(python=["3.8", "3.7"])
def lint(session):
    args = session.posargs or locations
    install_with_constraints(
        session,
        "flake8",
        "flake8-bandit",
        "flake8-black",
        "flake8-bugbear",
        "flake8-import-order",
    )
    session.run("flake8", *args)


@nox.session(python="3.8")
def safety(session):
    with tempfile.NamedTemporaryFile() as requirements:
        session.run(
            "poetry",
            "export",
            "--dev",
            "--format=requirements.txt",
            "--without-hashes",
            f"--output={requirements.name}",
            external=True,
        )
        install_with_constraints(session, "safety")
        session.run("safety", "check", f"--file={requirements.name}", "--full-report")

然后对于测试部分则可以只使用测试相关的包:

@nox.session(python=["3.8", "3.7"])
def tests(session):
    args = session.posargs or ["--cov", "-m", "not e2e"]
    session.run("poetry", "install", "--no-dev", external=True)
    install_with_constraints(
        session, "coverage[toml]", "pytest", "pytest-cov", "pytest-mock"
    )
    session.run("pytest", *args)

这样,主要在项目组内分享这些配置文件,每个人的环境就是完全一致的了。

什么时候运行:pre-commit

配置了静态扫描,什么时间运行它们?

公司的 CI/CD 服务器通常会干这件事情,但是当你把代码提交上去才看到这些信息然后进行修改肯定是太晚了。比较好的时间利用 Git 的提供的 hooks

使用 pipx 安装 pre-commit:

$ pipx install pre-commit

然后在你的 repo 的根目录编辑 .pre-commit-config.yaml 配置文件。需要注意的是,因为你是先在本地环境运行扫描,所以需要使用本地的 hook

# .pre-commit-config.yaml
repos:
-   repo: local
    hooks:
    -   id: black
        name: black
        entry: poetry run black
        language: system
        types: [python]
    -   id: flake8
        name: flake8
        entry: poetry run flake8
        language: system
        types: [python]

这样运行速度也会比直接跑 nox 命令快,因为只会扫描改动了的文件。

Python in 2020 (2) - 测试框架

目录

单元测试:pytest

虽然 Python 自带了 unittest 框架,但是 pytest 基本上是事实标准。

使用 Poetry 来安装 pytest:

$ poetry add --dev pytest

pytest 的具体用法这里不说了,只是在使用了 poetry 的情况下,记得用正确的方法来运行:

$ poetry run pytest

单元测试覆盖率:Coverage.py

在进行单元测试的时候,经常需要统计覆盖率,在 Python 开发中通常使用 Coverage.py 。当选择了 pytest 作为测试框架的时候,可以安装 pytest-cov 这个插件,它的底层就是 Coverage.py:

$ poetry add --dev coverage -E toml pytest-cov

这里 toml 的参数主要是为了后面让 Coverage.py 可以从 pyproject.toml 里面去读取一些配置。因为如果这个时候运行会发现它会统计所有项目路径下的测试覆盖率,包括虚拟环境里的:

$ poetry run pytest --cov
=========================================== test session starts ===========================================
platform darwin -- Python 3.8.1, pytest-5.4.3, py-1.9.0, pluggy-0.13.1
rootdir: /Users/lenciel/Projects/engineering/test
plugins: cov-2.10.0
collected 0 items


---------- coverage: platform darwin, python 3.8.1-final-0 -----------
Name                                                                Stmts   Miss  Cover
---------------------------------------------------------------------------------------
.venv/lib/python3.8/site-packages/_pytest/_argcomplete.py              37     36     3%
.venv/lib/python3.8/site-packages/_pytest/assertion/__init__.py        80     76     7%
---------------------------------------------------------------------------------------
TOTAL                                                               13060  11628    11%

这个时候就可以进行一些配置:

[tool.coverage.paths]
source = ["src", "*/site-packages"]

[tool.coverage.report]
show_missing = true

这里的路径就指定了扫描哪些目录下的测试覆盖率。另一个常用的配置是指定覆盖率到多少才不会 fail:

# pyproject.toml
[tool.coverage.report]
fail_under = 90

测试自动化:Nox

Nox 应该可以算是 tox 的后继,它很好的处理了各种依赖,可以在隔离的环境里完成各种任务。

使用 pipx 来安装 Nox:

$ pipx install nox

它使用声明式的语法来定义自动化测试:

# noxfile.py
import nox


@nox.session(python=["3.8", "3.7"])
def tests(session):
    session.run("poetry", "install", external=True)
    session.run("pytest", "--cov")

这个文件声明了一个在两个不同版本( 3.8 和 3.7 )的 Python 中运行的测试 session。Nox 在处理这个文件的时候会做几件事情:

  • 首先生成不同版本 Python 的虚拟环境
  • 然后按照 session 声明运行命令:首先是安装依赖的包(注意 Poetry 是在 Nox 生成的虚拟环境外的,所以这里声明的时候带了个 exteranl 的参数)
  • 接下来就是运行 pytest 并生成覆盖率报告

最终运行的结果会类似于:

$ nox

nox > Running session tests-3.8
nox > Creating virtual environment (virtualenv) using python3.8 in .nox/tests-3-8
nox > poetry install
Installing dependencies from lock file


Package operations: 16 installs, 0 updates, 0 removals

  - Installing pyparsing (2.4.7)
  - Installing six (1.15.0)

nox > pytest --cov
=========================================== test session starts ===========================================
platform darwin -- Python 3.8.5, pytest-5.4.3, py-1.9.0, pluggy-0.13.1
rootdir: /Users/lenciel/Projects/engineering/test/tests
plugins: cov-2.10.0
…
nox > Running session tests-3.7
nox > Creating virtual environment (virtualenv) using python3.7 in .nox/tests-3-7
nox > poetry install
Installing dependencies from lock file
…

Nox 创建虚拟环境还是需要点儿时间的,所以后面可以使用 -r 参数来反复使用这个虚拟环境:

$ nox -r

测试打桩:pytest-mock

打桩的目的主要是隔离依赖和提高执行速度,毕竟这是单元测试。

比如你的单元测试需要访问一些 API,那么如果是直接通过 requests 之类的包去访问这些 API,那么这些 API 自己的稳定性,网络等各种因素可能都会导致你的单元测试失败或者是运行时间很长。这个时候就需要打桩。

Python 自带的 unittest.mock 同样不灵,使用 pytest 的时候一般会用 pytest-mock 这个插件:

$ poetry add --dev pytest-mock

这个插件其实主要是提供一个叫 mockerfixture,来作为整个 mocking 库的 wrapper。比如,我们刚才举的访问 API 的例子,可以写一个 如下的 fixture:

# tests/test_req.py
@pytest.fixture
def mock_requests_get(mocker):
    mock.return_value.__enter__.return_value.json.return_value = {
        // the k-v pairs
    }
    return mock

那么这个 fixture 就可以直接在用例里面使用了:

def test_main_succeeds(runner, mock_requests_get):
    ...

更详细的 pytest-mock 的用法可以看看文档。但核心需要理解的,就是通过打桩,可以让所有的依赖变得稳定、可控并且可重入,这正好服务于我们对于优质的单元测试的要求:快速、隔离、可重入

数据生成:fakes/factory_boy

mock 并不是 doubles 的唯一方法。当然,很多人会弄不清 fakes, mocks 和 stubs 的区别。

当你的测试需要理解一个数据库的时候,对每个输入输出打桩不太现实,你可能更需要一个内存里的数据库,来「假装」真正的数据库。

对于简单的场景可以直接自己实现,对于复杂的场景可以考虑类似 factory_boy 这样的工具。但具体怎么实现不是这里的重点,我们假设通过 fake 的方法来实现了一个 API :


class FakeAPI:
    url = "http://localhost:5000/"

    @classmethod
    def create(cls):
        ...

    def shutdown(self):
        ...

直接在 fixture 里面使用是不行的:

@pytest.fixture
def fake_api():
    return FakeAPI.create()

因为这个 API 在被 create 了之后,没有很好的地方可以 shutdown。这种情况下,你可以把它实现为一个 generator,并且通过 scope 的声明,让这个 fixture 对整个 session 可见:

@pytest.fixture
def fake_api():
    api = FakeAPI.create()
    yield api
    api.shutdown()

端到端测试:pytest

pytest 并不仅仅是用来做单元测试的,可以通过扩展它的 markers 来进行端到端的测试。

# tests/test_console.py
@pytest.mark.e2e
def test_main_succeeds_in_production_env(runner):
    result = runner.invoke(console.main)
    assert result.exit_code == 0

然后可以在 conftest.py 这个 hook 文件里面去注册这个 marker:

# tests/conftest.py
def pytest_configure(config):
    config.addinivalue_line("markers", "e2e: mark as end-to-end test.")

然后就可以直接在 nox 里面用了:

# noxfile.py
import nox


@nox.session(python=["3.8", "3.7"])
def tests(session):
    args = session.posargs or ["--cov", "-m", "not e2e"]
    session.run("poetry", "install", external=True)
    session.run("pytest", *args)

运行生产环境端到端的测试只需要:

$ nox -rs tests-3.8 -- -m e2e

测试框架大概就是这些,接下里说一下静态扫描