测试插件

我们建议使用 pytest 来为你的插件编写自动化测试。

如果你使用 使用 cookiecutter 启动可安装插件 中描述的模板,你的插件将在 tests/ 目录中包含一个测试文件,其内容如下

from datasette.app import Datasette
import pytest


@pytest.mark.asyncio
async def test_plugin_is_installed():
    datasette = Datasette(memory=True)
    response = await datasette.client.get("/-/plugins.json")
    assert response.status_code == 200
    installed_plugins = {p["name"] for p in response.json()}
    assert (
        "datasette-plugin-template-demo"
        in installed_plugins
    )

这个测试使用 datasette.client 对象来测试 Datasette 的测试实例。datasette.clientHTTPX Python 库的一个封装,它可以使用 ASGI 来模拟 HTTP 请求。这是针对 Datasette 实例编写测试的推荐方法。

这个测试还使用了 pytest-asyncio 包来支持在 pytest 下运行的 async def 测试函数。

你可以这样安装这些包

pip install pytest pytest-asyncio

如果你正在构建一个可安装的包,你可以将它们作为测试依赖添加到你的 setup.py 模块中,像这样

setup(
    name="datasette-my-plugin",
    # ...
    extras_require={"test": ["pytest", "pytest-asyncio"]},
    tests_require=["datasette-my-plugin[test]"],
)

然后你可以这样安装测试依赖

pip install -e '.[test]'

然后像这样使用 pytest 运行测试

pytest

设置 Datasette 测试实例

上面的例子展示了针对 Datasette 实例开始编写测试的最简单方法

from datasette.app import Datasette
import pytest


@pytest.mark.asyncio
async def test_plugin_is_installed():
    datasette = Datasette(memory=True)
    response = await datasette.client.get("/-/plugins.json")
    assert response.status_code == 200

像这样创建一个 Datasette() 实例在测试中是一个有用的捷径,但你需要注意一个细节。确保在该实例上调用了异步方法 .invoke_startup() 非常重要。你可以这样做

datasette = Datasette(memory=True)
await datasette.invoke_startup()

这个方法会注册任何可能需要进行异步调用的 startup(datasette)prepare_jinja2_environment(env, datasette) 插件。

如果你正在使用 await datasette.client.get() 和类似的方法,那么你无需担心这个问题 - Datasette 在第一次处理请求时会自动调用 invoke_startup()

使用 pdb 调试 Datasette 内部抛出的错误

如果在测试期间 Datasette 内部发生异常,返回给你的插件的响应将具有一个 response.status_code 值 500。

你可以在 Datasette 构造函数中添加 pdb=True,以便在你的测试运行中进入 Python 调试器会话,而不是收到 500 响应码。这相当于使用 --pdb 选项运行 datasette 命令行工具。

在测试函数中看起来像这样

def test_that_opens_the_debugger_or_errors():
    ds = Datasette([db_path], pdb=True)
    response = await ds.client.get("/")

如果你使用这种模式,你需要在运行 pytest 时带上 -s 选项,以避免捕获 stdin/stdout,从而能够与调试器提示符进行交互。

使用 pytest fixtures

Pytest fixtures 可用于创建初始可测试对象,这些对象随后可由多个测试使用。

Datasette 插件的一个常见模式是创建一个 fixture,该 fixture 设置一个临时测试数据库并将其封装在 Datasette 实例中。

这是一个使用 sqlite-utils 库来填充临时测试数据库的示例。它还使用模拟的 metadata.json 配置来设置该表的标题

from datasette.app import Datasette
import pytest
import sqlite_utils


@pytest.fixture(scope="session")
def datasette(tmp_path_factory):
    db_directory = tmp_path_factory.mktemp("dbs")
    db_path = db_directory / "test.db"
    db = sqlite_utils.Database(db_path)
    db["dogs"].insert_all(
        [
            {"id": 1, "name": "Cleo", "age": 5},
            {"id": 2, "name": "Pancakes", "age": 4},
        ],
        pk="id",
    )
    datasette = Datasette(
        [db_path],
        metadata={
            "databases": {
                "test": {
                    "tables": {
                        "dogs": {"title": "Some dogs"}
                    }
                }
            }
        },
    )
    return datasette


@pytest.mark.asyncio
async def test_example_table_json(datasette):
    response = await datasette.client.get(
        "/test/dogs.json?_shape=array"
    )
    assert response.status_code == 200
    assert response.json() == [
        {"id": 1, "name": "Cleo", "age": 5},
        {"id": 2, "name": "Pancakes", "age": 4},
    ]


@pytest.mark.asyncio
async def test_example_table_html(datasette):
    response = await datasette.client.get("/test/dogs")
    assert ">Some dogs</h1>" in response.text

这里的 datasette() 函数定义了 fixture,然后根据 pytest 自动匹配其 datasette 函数参数,将该 fixture 自动传递给两个测试函数。

这里的 @pytest.fixture(scope="session") 行确保了该 fixture 在整个 pytest 执行会话中被复用。这意味着临时数据库文件将创建一次并用于每个测试。

如果你想为每个单独的测试函数重复创建该测试数据库,可以像这样编写 fixture 函数。如果你的插件以某种方式修改了数据库内容,你可能希望这样做

@pytest.fixture
def datasette(tmp_path_factory):
    # This fixture will be executed repeatedly for every test
    ...

使用 pytest-httpx 测试出站 HTTP 调用

如果你的插件进行了出站 HTTP 调用 - 例如 datasette-auth-github 或 datasette-import-table - 你可能需要在测试中模拟这些 HTTP 请求。

pytest-httpx 包是一个用于模拟调用的有用库。但它与 Datasette 一起使用时可能会很棘手,因为它模拟了所有 HTTPX 请求,而 Datasette 自身的测试机制内部使用了 HTTPX。

为了避免破坏你的测试,你可以从 non_mocked_hosts() fixture 中返回 ["localhost"]

举个例子,这是一个非常简单的插件,它执行一个 HTTP 响应并返回结果内容

from datasette import hookimpl
from datasette.utils.asgi import Response
import httpx


@hookimpl
def register_routes():
    return [
        (r"^/-/fetch-url$", fetch_url),
    ]


async def fetch_url(datasette, request):
    if request.method == "GET":
        return Response.html(
            """
            <form action="/-/fetch-url" method="post">
            <input type="hidden" name="csrftoken" value="{}">
            <input name="url"><input type="submit">
        </form>""".format(
                request.scope["csrftoken"]()
            )
        )
    vars = await request.post_vars()
    url = vars["url"]
    return Response.text(httpx.get(url).text)

这是一个针对该插件的测试,它模拟了 HTTPX 出站请求

from datasette.app import Datasette
import pytest


@pytest.fixture
def non_mocked_hosts():
    # This ensures httpx-mock will not affect Datasette's own
    # httpx calls made in the tests by datasette.client:
    return ["localhost"]


async def test_outbound_http_call(httpx_mock):
    httpx_mock.add_response(
        url="https://www.example.com/",
        text="Hello world",
    )
    datasette = Datasette([], memory=True)
    response = await datasette.client.post(
        "/-/fetch-url",
        data={"url": "https://www.example.com/"},
    )
    assert response.text == "Hello world"

    outbound_request = httpx_mock.get_request()
    assert (
        outbound_request.url == "https://www.example.com/"
    )

在测试期间注册插件

在编写插件测试时,你可能会发现只在单个测试期间注册一个测试插件很有用。你可以使用 pm.register()pm.unregister() 来实现,像这样

from datasette import hookimpl
from datasette.app import Datasette
from datasette.plugins import pm
import pytest


@pytest.mark.asyncio
async def test_using_test_plugin():
    class TestPlugin:
        __name__ = "TestPlugin"

        # Use hookimpl and method names to register hooks
        @hookimpl
        def register_routes(self):
            return [
                (r"^/error$", lambda: 1 / 0),
            ]

    pm.register(TestPlugin(), name="undo")
    try:
        # The test implementation goes here
        datasette = Datasette()
        response = await datasette.client.get("/error")
        assert response.status_code == 500
    finally:
        pm.unregister(name="undo")