为了账号安全,请及时绑定邮箱和手机立即绑定

软件工程测试系列:FastAPI单元测试(第一部分)

监控、可观察性,以及 FastAPI

词汇表

  • PRD: 产品需求文档。
  • 请求-响应周期:从发出请求到收到响应的过程。
  • QA:质量保障。

作为一名工程师的成长已不仅仅局限于编码和将业务文档(PRD)转译成代码。现在,它还涉及对请求-响应流程(即 Request-Response Cycle)中发生的所有事情的从头到尾的理解,讽刺的是,我自以为已经了解所有从请求到响应返回的过程。

了解系统内部发生的事情的需求,导致了被称为可观测性和监控的工程工具生态系统蓬勃发展。诸如Loki、Grafana和Prometheus之类的工具是常用的监控工具。也可以使用自制工具,我个人会将日志和错误推送到Slack。(感谢一位队友提出了这种方法(并不是什么新方法)),这是基于成本考虑的一个决定。将错误推送到频道后,我们可以获取服务器日志并实时处理,同时继续开发新功能。

这是 Slack 上显示的错误日志示例。

这是进入 Slack 的一个服务器错误。

当我开始将服务器日志流式传输到 Slack 时,我意识到服务器收到了恶意请求,幸好未被路由的端点返回了 404。这让我更加警惕安全问题,我也会花时间检查和审计这些日志。

这里有一个服务器日志的例子,展示了日志如何流式传输到Slack。

示例的恶意服务器日志,如下实时传输到Slack平台。

除了监控应用程序之外,软件工程师也会测试我们构建的应用程序。在理想情况下,自动化测试是主要推崇的,但是要说在尼日利亚充满激情的创业环境中,并没有为测试编写提供足够的时间。主要原因是,创业公司为了生存,必须迅速转型。因此,在精简团队中,大多数测试都是手动的质量保证测试。

使用 pytest 的 FastAPI 项目

自动化测试是一项单调且有时枯燥的任务,这项任务确保代码能够完成预期的功能。虽然有多种类型的测试,但我只提及两种,即单元测试和集成测试这两种。

单元测试是对代码单元进行测试的一种方法。可以将代码单元视为单个函数,测试不一定覆盖这个代码块内调用的所有方法或函数。大多数类似“让我们开始构建”这样的课程所包含的材料通常教授的是集成测试,而不是单元测试。

集成测试可以像流程测试一样,它是一种测试,涉及所有的相关功能和/或方法。例如,在“注册”流程中,这些功能和/或方法应该按预期工作。这些功能或代码块包括路由、服务以及数据库层。

代码设置是怎样的

在简要介绍测试及其类型时,这里提供了一个执行的单元测试示例,并提供了一个可以适应您正在使用的任何项目的测试配置模板,涉及Python中的路由、服务和数据库。我支持FastAPI和Python生态系统,使用的测试工具是Pytest,Pytest提供了一些配置用于创建测试。我相信你已经熟悉Pytest,但如果你不熟悉,这里有一些资源可能会对你有用(Pytest with EricPytest Documentation)。

通过举例说明如何对数据库处理程序、服务和路由进行单元测试,我们依赖于 pytest 的 conftest.py 文件,该文件包含用于创建测试环境的作用域函数。测试环境的概念在于我们可以将测试环境的依赖与开发服务器的依赖隔离开来。这里有一些在为这些代码编写测试时需要注意的事项。

  • 该应用使用一个名为“.env”的文件来注入环境变量。
  • 如果你正在编写同步应用,你可能需要适配这些异步函数以适应你的同步应用。

Conftest 代码段 注:Conftest 为特定术语,此处意指“Conftest 代码段”。

    从 sqlalchemy 导入 create_engine
    从 root.settings 导入 Settings

    导入 pytest
    从 unittest.mock 导入 patch
    从 sqlalchemy.ext.asyncio 导入 create_async_engine, async_sessionmaker

    从 root.utils.abstract_base 导入 AbstractBase
    从 tests.conftest_utils.db_test_setup 导入 SqlDbTestConnector
    导入 asyncio
    导入 logging

    LOGGER = logging.getLogger(__name__)

    base_pg_url = str(Settings().postgres_url).split("://")[1].split("/")[0]
    pg_props = base_pg_url.split(":")
    pg_username, pg_password = pg_props[0], pg_props[1].split("@")[0]
    pg_host, pg_port = pg_props[1].split("@")[1], pg_props[-1]

    @pytest.fixture(scope="session")
    def event_loop():
        """覆盖 pytest 默认会话范围的事件循环"""
        policy = asyncio.get_event_loop_policy()
        loop = policy.new_event_loop()
        yield loop
        loop.close()

    @pytest.fixture()
    def db_connector():
        """使用SqlDbTestConnector创建数据库连接"""
        with SqlDbTestConnector(
            user=pg_username, password=pg_password, host=pg_host, port=pg_port
        ) as connector:
            yield connector
            """生成并返回数据库连接"""

    @pytest.fixture()
    async def setup_test_db(db_connector):
        engine = create_engine(db_connector.get_sync_db_url(), echo=True)
        LOGGER.info("创建表格...")
        AbstractBase.metadata.create_all(engine)
        LOGGER.info(db_connector.get_sync_db_url())

        engine = create_async_engine(url=db_connector.get_db_url(), echo=True)

        yield engine
        await engine.dispose()
        """生成并返回数据库设置"""

    @pytest.fixture(scope="function")
    async def session(setup_test_db):
        async_session = async_sessionmaker(bind=setup_test_db, expire_on_commit=False)
        async with async_session() as session:
            with patch(
                "database.handlers.user_handler.async_session"
            ) as mock_session:
                mock_session.return_value = session

                yield session
                """生成并返回会话"""

上述的 conftest 需要解释一下。

event_loop函数:此fixture旨在覆盖pytest默认提供的函数范围事件循环,扩展到了整个会话的范围。当你需要在整个测试会话中使用一个事件循环时,这特别有用,例如,在使用pytest和asyncio测试异步代码时。

接下来是db_connector函数,不过,为了说明这个函数,我得先介绍SqlDbTestConnector类。SqlDbTestConnector类会创建一个测试数据库,并且可能还会删除这个数据库。

import uuid  
import psycopg2  

class SqlDbTestConnector:  
    """  
    该类用于为测试创建一个临时数据库。  
    请根据需要传递相应的参数。  
    """  

    def __init__(  
            self,  
            default_db: str = "postgres",  
            user: str = "postgres",  
            password: str = "password",  
            host: str = "localhost",  
            port: str = "5432",  
            be_async: bool = True,  # (是否异步)
    ) -> None:  
        self.user = user  
        self.password = password  
        self.host = host  
        self.port = port  
        self.db_name = f'A{str(uuid.uuid4()).replace("-", "")}Z'.lower()  
        self.conn = psycopg2.connect(  
            database=default_db, user=user, password=password, host=host, port=port  
        )  
        self.conn.autocommit = True  
        self.cursor = self.conn.cursor()  
        print(self.db_name)  
        sql = f"""CREATE database {self.db_name};"""  # noqa: E703  
        self.cursor.execute(sql)  
        print("数据库创建成功!!")  
        self.db_url = f"postgresql://{user}:{password}@{host}:{port}/{self.db_name}"  
        if be_async:  
            self.db_url = (  
                f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{self.db_name}"  
            )  

    def get_db_url(self):  
        return self.db_url  

    def get_sync_db_url(self):  
        return f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.db_name}"  # 获取同步数据库URL

    def _drop_db(self):  
        if not self.conn:  
            self.conn = psycopg2.connect(  
                database="postgres",  
                user=self.user,  
                password=self.password,  
                host=self.host,  
                port=self.port,  
            )  

        self.cursor = self.conn.cursor()  

        sql = f"""DROP database {self.db_name} WITH (FORCE);"""  # noqa: E703  
        self.cursor.execute(sql)  
        print("数据库删除成功!!")  
        self.conn.close()  
        self.conn = None  

    def __enter__(self):  
        return self  

    def __exit__(self, exc_type, exc_value, exc_traceback):  
        self._drop_db()

db_connector函数:这个函数创建一个临时数据库并将状态传递给其他的conftest函数。

setup_test_db函数:此函数从db_connector函数获取连接并创建表。上述函数使用了SQLAlchemy的同步引擎(sync)来创建数据库中的表,接着返回一个异步引擎。之所以这样做,是因为在我的情况中,每次尝试使用异步引擎创建表时都会遇到一个bug,因此采取了一个临时措施。

The 会话功能:修补并替换在数据库处理程序(db_handlers)中导入的 async_session,然后将新创建的 async_session 注入到函数中。

所以通过这个 conftest 设置,我们可以在 db_handler 上对 db_handler 进行单元测试。
测试代码文件的结构

所以我创建了一个名为unit-tests的文件夹,包含了像test_db_handlers、test_services和test_routers这样的单独测试部分。

测试/单元测试的文件结构

所以对于 db_handlers 的这个测试,服务端和路由器可以放到相应的文件夹/模块中。我写的测试主要关注两种情况,即成功案例(200 cases)和失败案例(400 cases)。

如200个情况所示,顺利的情况是指一切顺利的情况;而如400个情况所示,不那么顺利的情况是指预期或已处理的错误发生的情况。

测一测 DB_Handlers 的功能

这里是 test_auth_db_handler 脚本中的测试。

    导入 tests.utils 作为 general_utils 的别名;  

    导入 pytest;  

    从 database.handlers 导入 user_handler;  
    导入 tests.unit_tests.test_db_handlers.db_test_utils 作为 seeder_db_handler;  
    从 services.service_utils.accountant_exceptions 导入 (  
        DeleteError,  
        NotFoundError,  
        UpdateError,  
        CreateError,  
    );  

    async def test_create_user_happy_path(session):  

        user = general_utils.get_user()  

        user_profile = await user_handler.create_user(user=user)  
        assert user.email == user_profile.email  
        assert user.name == user_profile.name  

    async def test_create_user_sad_path(session):  

        user = general_utils.get_user()  

        user_profile = await seeder_db_handler.create_user()  

        user.email = user_profile.email  

        with pytest.raises(CreateError):  
            await user_handler.create_user(user=user)

test_create_user_happy_path 测试数据库中是否成功创建了一条记录,而 sad_path 测试是否产生了错误。

我们提供测试服务,帮助您解决问题

以下是 test_auth_service 脚本中的测试示例。

from unittest.mock import patch, AsyncMock, Mock
import pytest
from fastapi import HTTPException
from services.service_utils.accountant_exceptions import NotFoundError
from services.service_utils.token_utils import gr_token_gen
import tests.utils as general_utils
from uuid import uuid4
import services.auth_service as auth_service

@patch("services.auth_service.send_mail")
@patch("services.auth_service.user_db_handler", new_callable=AsyncMock)
@patch("services.auth_service")
@patch("services.auth_service.redis_utils")
@patch("services.auth_service.gr_token_gen")
@patch("services.auth_service.auth_utils")
async def test_create_user_happy_path(
    mock_auth_utils,
    mock_token_gen,
    mock_redis,
    mock_auth_service,
    mock_auth_db,
    mock_mailer,
):

    token = gr_token_gen()
    user_uid = uuid4()
    access_uid = str(uuid4())
    refresh_uid = str(uuid4())

    user = general_utils.get_user()
    user_extended_profile = general_utils.get_user_extended_profile(
        user=user, user_uid=user_uid
    )
    mock_auth_db.create_user.return_value = user_extended_profile

    mock_auth_db.create_user_group.return_value = None
    mock_auth_db.check_user.side_effect = NotFoundError
    mock_auth_service.create_user_group = AsyncMock(return_value=None)

    mock_token_gen.return_value = token
    mock_redis.add_user_verification_token = Mock(return_value=None)

    mock_mailer.return_value = None

    mock_auth_utils.hash_password = Mock(return_value=user.password)
    mock_auth_utils.create_access_token = Mock(return_value=access_uid)
    mock_auth_utils.create_refresh_token = Mock(return_value=refresh_uid)

    user_access_cred = await auth_service.create_user(user=user, user_group_token=None)

    mock_auth_db.create_user.assert_awaited_once_with(user=user)
    assert user_access_cred.access_token == access_uid
    assert user_access_cred.refresh_token == refresh_uid

@patch("services.auth_service.user_db_handler", new_callable=AsyncMock)
async def test_create_user_sad_path(
    mock_auth_db,
):

    user_uid = uuid4()

    user = general_utils.get_user()
    user_extended_profile = general_utils.get_user_extended_profile(
        user=user, user_uid=user_uid
    )
    mock_auth_db.create_user.return_value = user_extended_profile

    mock_auth_db.check_user.return_value = user_extended_profile

    with pytest.raises(HTTPException):

        await auth_service.create_user(user=user, user_group_token=None)

由于这是单元测试,所以我们需要专注于核心行为,即测试一个函数是否用该函数预期的参数被调用。我们模拟正常情形的数据和异常情形,同时模拟超出待测函数范围之外的所有内容的函数调用和方法。

测试路由器呢

这里有一些在 test_auth_router 脚本中的测试示例。

from unittest.mock import patch, AsyncMock

from fastapi import HTTPException, status
from root.app import app
import tests.utils as general_utils

from uuid import uuid4
from fastapi.testclient import TestClient
import schemas.user_schemas as schemas

TEST_CLIENT = TestClient(app=app)

@patch("routers.auth_router.auth_service")
async def test_sign_up_happy_path(mock_auth_service):

    user = general_utils.get_user()

    access_token = str(uuid4())
    refresh_token = str(uuid4())

    expected_resp = schemas.AccessRefreshPayload(
        access_token=access_token, refresh_token=refresh_token
    )
    mock_auth_service.create_user = AsyncMock(return_value=expected_resp.model_dump())

    response = TEST_CLIENT.post(url="/v1/auth/sign-up", json=user.model_dump())
    response_json = response.json()
    assert response.status_code == 201
    assert response_json == expected_resp.model_dump()

    mock_auth_service.create_user.assert_awaited_once_with(
        user=user, user_group_token=None
    )

@patch("routers.auth_router.auth_service")
async def test_sign_up_sad_path(mock_auth_service):

    user = general_utils.get_user()

    mock_auth_service.create_user.side_effect = HTTPException(
        status_code=status.HTTP_400_BAD_REQUEST, detail="account exists"
    )

    response = TEST_CLIENT.post(url="/v1/auth/sign-up", json=user.model_dump())
    response_json = response.json()
    assert response.status_code == 400
    assert response_json == {"detail": "account exists"}

此路由器测试确保路由器被成功调用,并且服务层通过传递用户信息来调用。正常情况的状态码为200,而错误情况的状态码为400。

这些单元测试可以用 pytest 命令来运行,并且可以加上任何其他感兴趣的选项。

#: 使用pytest运行单元测试

还有其他的 pytest 包可以使用,比如 pytest-coverage 等。我使用 pytest-coverage 来获取应用程序的测试覆盖率报告。

我认为这个测试系统,结合并加以利用监测和观察的方法,可以帮助构建可扩展的、容易调试的软件,并成功地测试已编写的软件。

如果你正在构建软件(尤其是用Python写的),你对测试有兴致,并在阅读后愿意与我交流,可以通过mail与我取得联系,加油!祝好!

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消