跳转至

单元测试支持

目前,Pait通过TestHelper提供一个了简单的单元测试支持,TestHelper在执行时会通过路由函数自动补充URL,HTTP方法等参数运行测试, 并在获得结果时会从response_modle_list中获取与测试结果最匹配的响应模型进行简单的校验,从而减少开发者编写测试用例的代码量。

1.TestHelper使用方法

本次使用的示例代码是以首页的示例代码进行拓展,主要变动是在路由函数添加了一个名为return_error_resp的参数,当return_error_respTrue时会返回不符合响应模型的响应,代码如下:

docs_source_code/docs_source_code/unit_test_helper/flask_test_helper_demo.py
# flake8: noqa: E402
from typing import Type

from flask import Flask, Response, jsonify
from pydantic import BaseModel, Field

from pait.app.flask import pait
from pait.field import Json
from pait.model.response import JsonResponseModel


class DemoResponseModel(JsonResponseModel):
    class ResponseModel(BaseModel):
        uid: int = Field()
        user_name: str = Field()

    description: str = "demo response"
    response_data: Type[BaseModel] = ResponseModel


@pait(response_model_list=[DemoResponseModel])
def demo_post(
    uid: int = Json.t(description="user id", gt=10, lt=1000),
    username: str = Json.t(description="user name", min_length=2, max_length=4),
    return_error_resp: bool = Json.i(description="return error resp", default=False),
) -> Response:
    if return_error_resp:
        return jsonify()
    return jsonify({"uid": uid, "user_name": username, "a": 123})


app.run(port=8000)
docs_source_code/docs_source_code/unit_test_helper/starlette_test_helper_demo.py
# flake8: noqa: E402
from typing import Type

from pydantic import BaseModel, Field
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route

from pait.app.starlette import pait
from pait.field import Json
from pait.model.response import JsonResponseModel


class DemoResponseModel(JsonResponseModel):
    class ResponseModel(BaseModel):
        uid: int = Field()
        user_name: str = Field()

    description: str = "demo response"
    response_data: Type[BaseModel] = ResponseModel


@pait(response_model_list=[DemoResponseModel])
async def demo_post(
    uid: int = Json.t(description="user id", gt=10, lt=1000),
    username: str = Json.t(description="user name", min_length=2, max_length=4),
    return_error_resp: bool = Json.i(description="return error resp", default=False),
) -> JSONResponse:
    if return_error_resp:
        return JSONResponse({})
    return JSONResponse({"uid": uid, "user_name": username})


import uvicorn
uvicorn.run(app)
docs_source_code/docs_source_code/unit_test_helper/sanic_test_helper_demo.py
# flake8: noqa: E402
from typing import Type

from pydantic import BaseModel, Field
from sanic.app import Sanic
from sanic.response import HTTPResponse, json

from pait.app.sanic import pait
from pait.field import Json
from pait.model.response import JsonResponseModel


class DemoResponseModel(JsonResponseModel):
    class ResponseModel(BaseModel):
        uid: int = Field()
        user_name: str = Field()

    description: str = "demo response"
    response_data: Type[BaseModel] = ResponseModel


@pait(response_model_list=[DemoResponseModel])
async def demo_post(
    uid: int = Json.t(description="user id", gt=10, lt=1000),
    username: str = Json.t(description="user name", min_length=2, max_length=4),
    return_error_resp: bool = Json.i(description="return error resp", default=False),
) -> HTTPResponse:
    if return_error_resp:
        return json({})
    return json({"uid": uid, "user_name": username})



import uvicorn
uvicorn.run(app)
docs_source_code/docs_source_code/unit_test_helper/tornado_test_helper_demo.py
# flake8: noqa: E402
from typing import Type

from pydantic import BaseModel, Field
from tornado.web import Application, RequestHandler

from pait.app.tornado import pait
from pait.field import Json
from pait.model.response import JsonResponseModel


class DemoResponseModel(JsonResponseModel):
    class ResponseModel(BaseModel):
        uid: int = Field()
        user_name: str = Field()

    description: str = "demo response"
    response_data: Type[BaseModel] = ResponseModel


class DemoHandler(RequestHandler):
    @pait(response_model_list=[DemoResponseModel])
    def post(
        self,
        uid: int = Json.t(description="user id", gt=10, lt=1000),
        username: str = Json.t(description="user name", min_length=2, max_length=4),
        return_error_resp: bool = Json.i(description="return error resp", default=False),
    ) -> None:
        if return_error_resp:
            self.write({})
        else:
            self.write({"uid": uid, "user_name": username})

app.listen(8000)

from tornado.ioloop import IOLoop
IOLoop.instance().start()

接着就可以通过TestHelper来编写测试用例了,首先需要导入TestHelper以及对应Web框架的测试客户端,同时还要进行测试框架的初始化:

Note

由于Flask在注册POST方法的路由会自动注册OPTIONS方法,它会干扰TestHelper的HTTP方法自动发现,所以需要通过apply_block_http_method_set屏蔽OPTIONS方法。

docs_source_code/docs_source_code/unit_test_helper/flask_test_helper_demo.py
from pait.extra.config import apply_block_http_method_set

###########################################################
# Block the OPTIONS method that Flask automatically adds  #
###########################################################
from pait.g import config

config.init_config(apply_func_list=[apply_block_http_method_set({"OPTIONS"})])


#############
# unit test #
#############
from typing import Generator

import pytest
from flask.ctx import AppContext
from flask.testing import FlaskClient

from pait.app.flask import FlaskTestHelper


@pytest.fixture
def client() -> Generator[FlaskClient, None, None]:
    # Flask provides a way to test your application by exposing the Werkzeug test Client
    # and handling the context locals for you.
    client: FlaskClient = app.test_client()
    # Establish an application context before running the tests.
    ctx: AppContext = app.app_context()
    ctx.push()
    yield client  # this is where the testing happens!
    ctx.pop()

Note

在使用with TestClient(app) as client时,Starlette会自动调用app的startupshutdown方法,虽然本次测试用例并没有用到,但是使用with TestClient(app) as client是一个好习惯。

docs_source_code/docs_source_code/unit_test_helper/starlette_test_helper_demo.py
from typing import Generator

#############
# unit test #
#############
import pytest
from starlette.testclient import TestClient

from pait.app.starlette import StarletteTestHelper


@pytest.fixture
def client() -> Generator[TestClient, None, None]:
    with TestClient(app) as client:
        yield client
docs_source_code/docs_source_code/unit_test_helper/sanic_test_helper_demo.py
from typing import Generator

#############
# unit test #
#############
import pytest
from sanic_testing.testing import SanicTestClient

from pait.app.sanic import SanicTestHelper


@pytest.fixture
def client() -> Generator[SanicTestClient, None, None]:
    yield app.test_client

Note

目前我并不知道如何通过Pytest执行Tornado的测试用例,所以使用了TornadoAsyncHTTPTestCase进行初始化。如果你知道如何通过Pytest执行Tornado的测试用例,欢迎通过issue反馈。

docs_source_code/docs_source_code/unit_test_helper/tornado_test_helper_demo.py
from tornado.testing import AsyncHTTPTestCase

#############
# unit test #
#############
from pait.app.tornado import TornadoTestHelper


class TestTornado(AsyncHTTPTestCase):
    def get_app(self) -> Application:
        return app

    def get_url(self, path: str) -> str:
        """Returns an absolute url for the given path on the test server."""
        return "%s://localhost:%s%s" % (self.get_protocol(), self.get_http_port(), path)

在编写完测试用例的初始化代码后,就可以编写测试用例代码了,首先将演示如何通过TestHelper来编写一个测试用例,代码如下:

docs_source_code/unit_test_helper/flask_test_helper_demo.py
def test_demo_post_route_by_call_json(client: FlaskClient) -> None:
    test_helper = FlaskTestHelper(
        client=client,
        func=demo_post,
        body_dict={"uid": 11, "username": "test"},
    )
    assert test_helper.json() == {"uid": 11, "user_name": "test"}
docs_source_code/unit_test_helper/starlette_test_helper_demo.py
def test_demo_post_route_by_call_json(client: TestClient) -> None:
    test_helper = StarletteTestHelper(
        client=client,
        func=demo_post,
        body_dict={"uid": 11, "username": "test"},
    )
    assert test_helper.json() == {"uid": 11, "user_name": "test"}
docs_source_code/unit_test_helper/sanic_test_helper_demo.py
def test_demo_post_route_by_call_json(client: SanicTestClient) -> None:
    test_helper = SanicTestHelper(
        client=client,
        func=demo_post,
        body_dict={"uid": 11, "username": "test"},
    )
    assert test_helper.json() == {"uid": 11, "user_name": "test"}
docs_source_code/unit_test_helper/tornado_test_helper_demo.py
class TestTornado(AsyncHTTPTestCase):
    ...

    def test_demo_post_route_by_call_json(self) -> None:
        test_helper = TornadoTestHelper(
            client=self,
            func=DemoHandler.post,
            body_dict={"uid": 11, "username": "test"},
        )
        assert test_helper.json() == {"uid": 11, "user_name": "test"}

在这个测试用例中, 会对TestHelper进行初始化,TestHelper的初始化需要Web框架对应的测试客户端、路由函数,以及路由函数的一些请求参数, 在初始化完成后就可以通过TestHelper获得请求响应了。

在执行测试的时候,TestHelper会通过路由函数自动发现了路由函数的URL和HTTP方法,所以调用json方法的时候TestHelper会自动发起了post请求,并通过post请求获得响应结果, 然后把响应Body序列化为Pythondict对象并返回, 但是当该路由函数绑定了多个请求方法时,TestHelper则无法自动执行,需要在调用json方法时指定对应的HTTP方法, 使用方法如下:

docs_source_code/unit_test_helper/flask_test_helper_demo.py
def test_demo_post_route_by_use_method(client: FlaskClient) -> None:
    test_helper = FlaskTestHelper(
        client=client,
        func=demo_post,
        body_dict={"uid": 11, "username": "test"},
    )
    assert test_helper.json(method="POST") == {"uid": 11, "user_name": "test"}
docs_source_code/unit_test_helper/starlette_test_helper_demo.py
def test_demo_post_route_by_use_method(client: TestClient) -> None:
    test_helper = StarletteTestHelper(
        client=client,
        func=demo_post,
        body_dict={"uid": 11, "username": "test"},
    )
    assert test_helper.json(method="POST") == {"uid": 11, "user_name": "test"}
docs_source_code/unit_test_helper/sanic_test_helper_demo.py
def test_demo_post_route_by_use_method(client: SanicTestClient) -> None:
    test_helper = SanicTestHelper(
        client=client,
        func=demo_post,
        body_dict={"uid": 11, "username": "test"},
    )
    assert test_helper.json(method="POST") == {"uid": 11, "user_name": "test"}
docs_source_code/unit_test_helper/tornado_test_helper_demo.py
class TestTornado(AsyncHTTPTestCase):
    ...

    def test_demo_post_route_by_use_method(self) -> None:
        test_helper = TornadoTestHelper(
            client=self,
            func=DemoHandler.post,
            body_dict={"uid": 11, "username": "test"},
        )
        assert test_helper.json(method="POST") == {"uid": 11, "user_name": "test"}

此外,在编写测试用例时,可能需要的是一个响应对象,而不是响应数据,以便对状态码,Header之类的数据进行校验。 这时可以通过TestHelper的HTTP系列方法进行调用并获得Web框架对应测试客户端的响应对象Response。 如下代码就会通过post方法对路由函数发起请求,并返回Web框架测试客户端的响应对象,最后再通过响应对象进行断言:

docs_source_code/unit_test_helper/flask_test_helper_demo.py
def test_demo_post_route_by_raw_web_framework_response(client: FlaskClient) -> None:
    test_helper = FlaskTestHelper(
        client=client,
        func=demo_post,
        body_dict={"uid": 11, "username": "test"},
    )
    resp = test_helper.post()
    assert resp.status_code == 200
    assert resp.json == {"uid": 11, "user_name": "test"}
docs_source_code/unit_test_helper/starlette_test_helper_demo.py
def test_demo_post_route_by_raw_web_framework_response(client: TestClient) -> None:
    test_helper = StarletteTestHelper(
        client=client,
        func=demo_post,
        body_dict={"uid": 11, "username": "test"},
    )
    resp = test_helper.post()
    assert resp.status_code == 200
    assert resp.json() == {"uid": 11, "user_name": "test"}
docs_source_code/unit_test_helper/sanic_test_helper_demo.py
def test_demo_post_route_by_raw_web_framework_response(client: SanicTestClient) -> None:
    test_helper = SanicTestHelper(
        client=client,
        func=demo_post,
        body_dict={"uid": 11, "username": "test"},
    )
    resp = test_helper.post()
    assert resp.status_code == 200
    assert resp.json == {"uid": 11, "user_name": "test"}
docs_source_code/unit_test_helper/tornado_test_helper_demo.py
class TestTornado(AsyncHTTPTestCase):
    ...

    def test_demo_post_route_by_raw_web_framework_response(self) -> None:
        test_helper = TornadoTestHelper(
            client=self,
            func=DemoHandler.post,
            body_dict={"uid": 11, "username": "test"},
        )
        resp = test_helper.post()
        assert resp.code == 200
        assert resp.body.decode() == '{"uid": 11, "user_name": "test"}'

虽然这种情况下TestHelper与使用Web框架对应的测试客户端的使用方式没有太大的差别,但是TestHelper在获取到路由函数的响应后, 会根据路由响应从路由函数的response_model_list挑选一个最匹配的响应模型进行校验,如果检查到响应对象的HTTP状态码,Header与响应数据三者中有一个不符合响应模型的条件就会抛出错误并中断测试用例,如下例子:

docs_source_code/unit_test_helper/flask_test_helper_demo.py
def test_demo_post_route_by_test_helper_check_response_error(client: FlaskClient) -> None:
    test_helper = FlaskTestHelper(
        client=client,
        func=demo_post,
        body_dict={"uid": 11, "username": "test", "return_error_resp": True},
    )
    assert test_helper.json() == {"uid": 11, "user_name": "test"}
docs_source_code/unit_test_helper/starlette_test_helper_demo.py
def test_demo_post_route_by_test_helper_check_response_error(client: TestClient) -> None:
    test_helper = StarletteTestHelper(
        client=client,
        func=demo_post,
        body_dict={"uid": 11, "username": "test", "return_error_resp": True},
    )
    assert test_helper.json() == {"uid": 11, "user_name": "test"}
docs_source_code/unit_test_helper/sanic_test_helper_demo.py
def test_demo_post_route_by_test_helper_check_response_error(client: SanicTestClient) -> None:
    test_helper = SanicTestHelper(
        client=client,
        func=demo_post,
        body_dict={"uid": 11, "username": "test", "return_error_resp": True},
    )
    assert test_helper.json() == {"uid": 11, "user_name": "test"}
docs_source_code/unit_test_helper/tornado_test_helper_demo.py
class TestTornado(AsyncHTTPTestCase):
    ...

    def test_demo_post_route_by_test_helper_check_response_error(self) -> None:
        test_helper = TornadoTestHelper(
            client=self,
            func=DemoHandler.post,
            body_dict={"uid": 11, "username": "test", "return_error_resp": True},
        )
        assert test_helper.json() == {"uid": 11, "user_name": "test"}

在执行完测试用例后,TestHelper会发现路由函数的响应结果与路由函数定义的响应模型不匹配,此时会抛出异常,中断测试用例,并输出结果如下:

> raise exc
E pait.app.base.test_helper.CheckResponseException: maybe error result:
E >>>>>>>>>>
E check json content error, exec: 2 validation errors for ResponseModel
E uid
E   field required (type=value_error.missing)
E user_name
E   field required (type=value_error.missing)
E
E >>>>>>>>>>
E by response model:<class 'docs_source_code.unit_test_helper.flask_test_helper_demo.DemoResponseModel'>

通过输出结果可以发现,抛出的异常为CheckResponseException。同时,根据异常信息可以了解到,本次校验的响应模型是DemoResponseModel, 它在校验的过程中发现响应数据缺少了uid字段和user_name字段。

2.参数介绍

TestHelper的参数分为初始化必填参数,请求相关的参数,响应相关的结果参数共3种。其中,初始化参数的说明如下:

参数 描述
client Web框架的测试客户端
func 要进行测试的路由函数

而请求参数有多个,对于大部分Web框架来说只是封装了一层调用,但对于使用Tornado之类的没对测试客户端做过多封装的框架的则能提供了一些便利,这些参数有:

  • body_dict: 发起请求时的Json数据。
  • cookie_dict: 发起请求时的cookie数据。
  • file_dict: 发起请求时的file数据。
  • form_dict: 发起请求时的form数据。
  • header_dict: 发起请求时的header数据。
  • path_dict: 发起请求时的path数据。
  • query_dict: 发起请求时的query数据。

除此之外,TestHelper还有几个与响应结果校验相关的参数,比如strict_inspection_check_json_content参数。 默认情况下,strict_inspection_check_json_content参数的值为True,这会让TestHelper对响应结果的数据结构进行严格校验,比如下面的例子:

a = {
    "a": 1,
    "b": {
        "c": 3
    }
}
b = {
    "a": 2,
    "b": {
        "c": 3,
        "d": 4
    }
}

在这个例子中,ab的数据结构是不一样的,其中a变量代指响应模型的数据结构,b变量则是响应体的数据结构,当TestHelper进行校验时, 会因为检测到b变量多出来一个结构['b']['d']而直接抛出错误, 不过也可以直接设置参数strict_inspection_check_json_content的值为False,这样TestHelper只会校验出现在响应模型中出现的字段,而不会检查响应模型之外的字段。

除了上述的参数外,TestHelper还有另外几个参数,如下:

参数 描述
target_pait_response_class 如果值不为空,那么TestHelper会通过target_pait_response_classresponse_model_list中筛选出一批符合条件的response_model来进行校验。该值通常是响应模型的父类,默认值为None代表不匹配。
enable_assert_response 表示TestHelper是否会对响应结果进行断言,默认值为True。