@Lenciel

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

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

Python in 2020 (1) - 环境搭建

目录

简介

最近两年做工具都在逼自己用刚学不久的 Go 和 Rust ,对 Python/Django 有些疏远了。最近想做一个 OKR 管理和对齐的工具,想着 Python 2 总算正式退役了, virtualenv 也改名 venv 成了标配,不如宠幸一把试试感觉。

结果光是包和依赖管理,就有 pip-tools/pyenv/Anaconda/pipenv/poetry/pipx/…,WTF,贵圈是被前端社区统治了吗…

大概看了一下各路的妖魔鬼怪,我觉得可以选一套自己还比较满意的工具链记录和分享一下。分为下面几个部分:

  1. 环境搭建
  2. 测试框架
  3. 静态检查
  4. 类型检查

环境搭建

我的需求:

  • 为各种不同项目管理和隔离它们的运行时环境(主要是 Python 版本)和依赖
  • 保持 Mac 系统本身的干净,包括自动的 Python 不被覆盖,全局生效的 zsh 配置等尽量不动
  • 部署项目的时候尽量使用 Docker ,但是在使用上它又慢又重又容易撞墙

配置

1. pyenv

选择的原因

pyenv 应该主要是借鉴了 nodenv,让你:

  • 可以安装多个 Python 版本然后根据需要在全局或者为每个项目配置
  • 可以和 tox 结合测试你的应用在不同 Python 版本下的运行情况
  • 可以结合 pyenv-virtualenv 对虚拟环境进行管理(我没有用,因为 Poetry 自带的对 venv 功能的封装足够用了)

安装

可以使用 brew 来安装 pyenv,但是我希望对它更可控所以选择直接:

$ git clone https://github.com/pyenv/pyenv.git 〜/.pyenv

因为我使用 zsh,所以在 ~/.zshrc 里面可以加上:

$ echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.zshrc
$ echo 'export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.zshrc
$ echo -e 'if command -v pyenv 1>/dev/null 2>&1; then\n  eval "$(pyenv init -)"\nfi' >> ~/.bash_profile

然后就可以安装不同版本的 Python 了:

$ pyenv install 3.8.1
$ pyenv install 3.7.2

查看有哪些可用版本然后配置全局优先使用 3.8.2 版本:

$ pyenv versions
  system
  3.7.2
  3.8.1
$ pyenv global 3.8.1

然后可以选择安装 pyenv-virtualenv,但是我打算用 poetry 来创建和激活虚拟环境,所以就没有安装。

2. Poetry

选择的原因

Poetry 使用了类似于前端生态里的 yarn 或者 bundler 的架构来做三件事情:

  • 管理依赖
  • 打包应用
  • 管理虚拟环境

如果你不想被 docker 拖垮可以试试这个。

安装

使用 curl 安装然后配置环境变量:

$ curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python

$ echo 'export PATH="$HOME/.poetry/bin:$PATH"' >> ~/.zshrc

安装后可以把生成虚拟环境的路径指定到项目的根目录:

$ poetry config virtualenvs.in-project true

新建一个测试项目:

$ poetry init --no-interaction

这会创建一个 pyproject.toml 文件:

[tool.poetry]
name = "test"
version = "0.1.0"
description = ""
authors = ["lenciel <lenciel@gmail.com>"]

[tool.poetry.dependencies]
python = "^3.8"

[tool.poetry.dev-dependencies]

[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"

然后你就可以用 TOML 语法来编辑这个文件了。

创建虚拟环境

virtualenv 被收编后改名为 venv,在我们声明了一个项目之后,通过下面的命令就会自动创建一个跟项目关联的虚拟环境:


$ poetry install
Creating virtualenv test in /Users/lenciel/Projects/engineering/test/.venv
Updating dependencies
Resolving dependencies... (0.1s)

Writing lock file

No dependencies to install or update

在这个虚拟环境下面运行程序可以使用:

$ poetry run python test.py

另外,可以通过配置让虚拟环境的目录就生成在项目的根目录来方便查看:

$ poetry config virtualenvs.in-project true

管理依赖

可以通过 add 命令来添加依赖:

$ poetry add django

Using version ^3.0.8 for django

Updating dependencies
Resolving dependencies... (1.1s)

Writing lock file


Package operations: 4 installs, 0 updates, 0 removals

  - Installing asgiref (3.2.10)
  - Installing pytz (2020.1)
  - Installing sqlparse (0.3.1)
  - Installing django (3.0.8)

这个操作其实会:

  • 下载并安装所有的依赖包到虚拟环境
  • 安装好后依赖的详细信息会被注册到 poetry.lock 文件
  • 依赖的版本信息会被注册到 pyproject.toml

如果你熟悉前端的工具链这个就很像对 gem 的管理。包括 pyproject.toml 的依赖版本描述语法,比如 ^1.3.0 表示不低于 1.3.0 的版本。而 poetry.lock 文件里的版本则是具体被安装的版本,它可以用来保持整个团队的版本一致性,以及生产环境和开发环境的版本一致性

当需要更新某个依赖的时候,你即可以用 update 命令,也可以用类似 poetry add django^3.0.1 来指定更新到具体的版本。

3. pipx

选择的原因

pipx 和 pip 不一样的是它不仅仅是一个包管理工具,它会创建一个虚拟环境,然后让你很容易的运行某个制定的程序而不用担心影响到其他地方。基本上你可以把它理解成 pipsi 的续篇就好

安装使用

保障干净(YMMV)的安装方法:

$ python -m pip install pipx

基本使用

$ pipx install sphinx

inject 可以让你在 REPL 里面安装额外的包并直接 import:

$ pipx inject sphinx sphinx_rtd_theme

4. Docker

Docker 其实跟 Python 的环境搭建和依赖管理没有任何直接关系,但是大家经常在这种讨论中谈到它,因为容器技术的一大用途就是进行开发环境的搭建。

前面讨论的以及后面会讨论的所有东西,当然都可以安装到容器里面。而且说实话,你要为每个 Python 的应用建一个容器,那虚拟环境可能都不需要了。但我个人觉得,Docker 也有下面的缺点:

  • 比较重,从安装到使用到运行需要的资源
  • 调试起来需要大量的配置和对工具链额外的投入
  • 安装很多跟系统底层相关的依赖时比较麻烦
  • 实际上还是需要你至少使用 pip 等来管理依赖,换句话说,你只是在容器里面去执行前面说的那些命令而已

所以如果不是公司在容器化上有足够投入,并且你开发的应用也不涉及到 Python 以外的组件和依赖(例如,你开发的是 Django 的应用,还涉及 数据库,Nginx ,Gunicorn 等等别的依赖,Docker 可能就挺好用的),我是不推荐使用 Docker 的,可以做一个具体的对比:

  安装 Python 包 安装非 Python 包 管理多个 Python 版本 管理虚拟环境 环境重建便利
pip *1      
venv        
piptools        
pyenv        
Conda 2  
pipenv + pyenv
Poetry + pyenv
Docker         3

5. 不再使用的

下面两个是在之前工具链里面被直接去掉的:

  • virtualenv(Python 3.0 自带 venv)
  • pipsi (pipx 比它好用)

还有一个就是 virtualenvwrapper ,但我比较怀念在目录切换的时候自动 activate/deactivate 相应的虚拟环境,并且在 zsh 的 prompt 上有一个提示,这个可以用脚本:

#!/usr/bin/env zsh
ZSH_POETRY_AUTO_ACTIVATE=${ZSH_POETRY_AUTO_ACTIVATE:-1}
ZSH_POETRY_AUTO_DEACTIVATE=${ZSH_POETRY_AUTO_DEACTIVATE:-1}
ZSH_POETRY_OVERRIDE_SHELL=${ZSH_POETRY_OVERRIDE_SHELL:-1}

autoload -U add-zsh-hook

_zp_current_project=

_zp_check_poetry_venv() {
  local venv
  if [[ -z $VIRTUAL_ENV ]] && [[ -n "${_zp_current_project}" ]]; then
    _zp_current_project=
  fi
  if [[ -f pyproject.toml ]] \
      && [[ "${PWD}" != "${_zp_current_project}" ]]; then
    venv="$(command poetry debug 2>/dev/null | sed -n "s/Path:\ *\(.*\)/\1/p")"
    if [[ -d "$venv" ]] && [[ "$venv" != "$VIRTUAL_ENV" ]]; then
      source "$venv"/bin/activate || return $?
      _zp_current_project="${PWD}"
      return 0
    fi
  elif [[ -n $VIRTUAL_ENV ]] \
      && [[ -n $ZSH_POETRY_AUTO_DEACTIVATE ]] \
      && [[ "${PWD}" != "${_zp_current_project}" ]] \
      && [[ "${PWD}" != "${_zp_current_project}"/* ]]; then
    deactivate
    _zp_current_project=
    return $?
  fi
  return 1
}
add-zsh-hook chpwd _zp_check_poetry_venv

poetry-shell() {
  _zp_check_poetry_venv
}

if [[ -n $ZSH_POETRY_OVERRIDE_SHELL ]]; then
  poetry() {
    if [[ $1 == "shell" ]]; then
      _zp_check_poetry_venv || (
        echo 'pyproject.toml file not found' >&2;
        exit 1
      )
      return $?
    fi
    command poetry "$@"
  }
fi

[[ -n $ZSH_POETRY_AUTO_ACTIVATE ]] && _zp_check_poetry_venv

环境搭建就是这样,接下来是测试框架的选择和配置。

  1. pip 虽然搞不定,但是 pip wheels 可以安装大部分非 Python 的依赖。 

  2. Conda 并不能代替系统的包管理软件,如 yum 或者 apt-get,所以在不同的平台你可能需要做很多额外的工作。 

  3. 如前所述,Docker 对里面的 Python 怎么运行怎么进行包管理是无感的,所以你需要在 Docker 里面安装上面那些东西。