# coding=utf-8 """db_pool.py 轻量 MySQL 连接池单元测试. 重点覆盖: - _connect_cfg:超时参数注入 - _init_pool:连接池初始化 - get_conn:惰性创建 + 自动重连 + 重试机制 - put_conn:归还连接(池满时关闭连接) - DBContext:上下文管理器(自动获取/归还) """ import pytest from unittest.mock import patch, MagicMock, call from queue import Queue # 将 Scripts/bin 加入 sys.path import os import sys _bin_dir = os.path.join(os.path.dirname(__file__), "..") if _bin_dir not in sys.path: sys.path.insert(0, _bin_dir) # ── _connect_cfg ───────────────────────────────────────── class TestConnectCfg: """连接配置构造测试.""" @patch("db_pool.MYSQL_CFG", {"host": "localhost", "port": 3306, "user": "root", "password": "", "database": "test", "charset": "utf8mb4"}) def test_returns_dict_with_timeouts(self): """返回的配置包含超时参数.""" from db_pool import _connect_cfg, _CONNECT_TIMEOUT, _READ_TIMEOUT cfg = _connect_cfg() assert cfg["connect_timeout"] == _CONNECT_TIMEOUT assert cfg["read_timeout"] == _READ_TIMEOUT @patch("db_pool.MYSQL_CFG", {"host": "localhost", "port": 3306, "user": "root", "password": "", "database": "test", "charset": "utf8mb4"}) def test_preserves_original_cfg_values(self): """保留原始 MYSQL_CFG 的值.""" from db_pool import _connect_cfg cfg = _connect_cfg() assert cfg["host"] == "localhost" assert cfg["port"] == 3306 assert cfg["user"] == "root" assert cfg["database"] == "test" @patch("db_pool.MYSQL_CFG", {"host": "localhost", "port": 3306, "user": "root", "password": "", "database": "test", "charset": "utf8mb4", "connect_timeout": 99}) def test_setdefault_does_not_override(self): """setdefault 不覆盖已有 connect_timeout.""" from db_pool import _connect_cfg cfg = _connect_cfg() # 已有 connect_timeout=99 时 setdefault 不覆盖 assert cfg["connect_timeout"] == 99 # ── _init_pool ─────────────────────────────────────────── class TestInitPool: """连接池初始化测试.""" def test_init_creates_pool_with_none_slots(self): """初始化后池中填充 None(惰性创建).""" import db_pool db_pool._pool = None db_pool._init_pool(max_conn=4) assert db_pool._pool is not None assert db_pool._pool.qsize() == 4 # 所有槽位应为 None for _ in range(4): assert db_pool._pool.get() is None def test_init_idempotent(self): """重复调用 _init_pool 不重新创建池.""" import db_pool db_pool._pool = None db_pool._init_pool(max_conn=4) first_pool = db_pool._pool db_pool._init_pool(max_conn=8) assert db_pool._pool is first_pool # 同一对象 def teardown_method(self, method): """每个测试后重置全局 _pool.""" import db_pool db_pool._pool = None # ── get_conn ───────────────────────────────────────────── class TestGetConn: """连接获取测试 — 惰性创建 + 重连 + 重试.""" def teardown_method(self, method): """每个测试后重置全局 _pool.""" import db_pool db_pool._pool = None @patch("db_pool.pymysql.connect") def test_creates_new_connection_when_none(self, mock_connect): """池中为 None 时创建新连接.""" import db_pool mock_conn = MagicMock() mock_connect.return_value = mock_conn db_pool._pool = None db_pool._init_pool(max_conn=2) conn = db_pool.get_conn() assert conn is mock_conn mock_connect.assert_called_once() @patch("db_pool.pymysql.connect") def test_reuses_existing_connection_via_ping(self, mock_connect): """池中有连接时通过 ping(reconnect=True) 验证可用性.""" import db_pool mock_conn = MagicMock() mock_conn.open = True db_pool._pool = None db_pool._init_pool(max_conn=2) # 清空池中所有 None,只放入 mock_conn db_pool._pool.get() db_pool._pool.get() db_pool._pool.put(mock_conn) conn = db_pool.get_conn() assert conn is mock_conn mock_conn.ping.assert_called_once_with(reconnect=True) # 不应调用 pymysql.connect mock_connect.assert_not_called() @patch("db_pool.pymysql.connect") def test_retries_on_failure(self, mock_connect): """连接失败时重试 max_retries 次.""" import db_pool mock_connect.side_effect = Exception("connection refused") db_pool._pool = None db_pool._init_pool(max_conn=2) with patch("time.sleep"): # 跳过重试延迟 with pytest.raises(Exception, match="connection refused"): db_pool.get_conn(max_retries=3, retry_delay=0) assert mock_connect.call_count == 3 @patch("db_pool.pymysql.connect") def test_reconnect_after_ping_failure(self, mock_connect): """ping 失败后重新创建连接.""" import db_pool mock_conn = MagicMock() mock_conn.ping.side_effect = Exception("connection lost") new_conn = MagicMock() mock_connect.return_value = new_conn db_pool._pool = None db_pool._init_pool(max_conn=2) # 第一次从池中拿到 mock_conn(ping 失败) # 第二次从池中拿到 None(新建 new_conn) db_pool._pool = Queue(maxsize=2) db_pool._pool.put(mock_conn) db_pool._pool.put(None) with patch("time.sleep"): conn = db_pool.get_conn(max_retries=3, retry_delay=0) assert conn is new_conn mock_conn.ping.assert_called_once() @patch("db_pool.pymysql.connect") def test_init_pool_called_if_none(self, mock_connect): """_pool 为 None 时自动初始化.""" import db_pool mock_connect.return_value = MagicMock() db_pool._pool = None db_pool.get_conn() assert db_pool._pool is not None # ── put_conn ───────────────────────────────────────────── class TestPutConn: """连接归还测试.""" def teardown_method(self, method): """每个测试后重置全局 _pool.""" import db_pool db_pool._pool = None def test_put_open_connection_back_to_pool(self): """开放连接归还到池中.""" import db_pool mock_conn = MagicMock() mock_conn.open = True db_pool._pool = None db_pool._init_pool(max_conn=4) # 取出一个 None 腾出位置,避免池满导致连接被关闭 db_pool._pool.get() db_pool.put_conn(mock_conn) # 池应已满(3 个 None + 1 个 mock_conn) assert db_pool._pool.qsize() == 4 # Queue 是 FIFO,取出的前 3 个是 None,第 4 个是 mock_conn assert db_pool._pool.get() is None assert db_pool._pool.get() is None assert db_pool._pool.get() is None assert db_pool._pool.get() is mock_conn def test_put_closed_connection_as_none(self): """已关闭连接归还为 None(惰性标记).""" import db_pool mock_conn = MagicMock() mock_conn.open = False db_pool._pool = None db_pool._init_pool(max_conn=4) db_pool.put_conn(mock_conn) assert db_pool._pool.get() is None def test_put_none_connection(self): """None 连接直接归还.""" import db_pool db_pool._pool = None db_pool._init_pool(max_conn=4) db_pool.put_conn(None) assert db_pool._pool.get() is None def test_pool_full_closes_connection(self): """池满时关闭连接而非阻塞.""" import db_pool mock_conn = MagicMock() mock_conn.open = True db_pool._pool = None db_pool._init_pool(max_conn=2) # 池已满(2个 None) # put_conn 时池满,应关闭连接 extra_conn = MagicMock() extra_conn.open = True db_pool.put_conn(extra_conn) # 连接被关闭 extra_conn.close.assert_called_once() def test_put_conn_initializes_pool_if_none(self): """_pool 为 None 时自动初始化.""" import db_pool db_pool._pool = None db_pool.put_conn(None) assert db_pool._pool is not None # ── 回归:连接泄漏导致后端挂死 ─────────────────────────── # # 曾存在的缺陷:_query_all 的异常路径不归还连接,而 _pool.get() 没有 timeout。 # 池容量 8,漏满之后所有请求永久阻塞在 get() 上——后端不报错、只剩线程堆积。 # 下面四条分别锁住:取连接有超时、归还有回滚、失败不吞池位、异常出栈回滚。 class TestPoolExhaustionRegression: """连接池耗尽与脏事务回归测试.""" def teardown_method(self, method): import db_pool db_pool._pool = None def test_get_conn_times_out_instead_of_hanging(self, monkeypatch): """池被耗尽时必须抛 TimeoutError,而不是永久阻塞.""" import db_pool # 容量 1 且槽位已被取走 → 池空 db_pool._pool = Queue(maxsize=1) monkeypatch.setattr(db_pool, "_POOL_GET_TIMEOUT", 0.05) with pytest.raises(TimeoutError, match="连接池已耗尽"): db_pool.get_conn() def test_put_conn_rolls_back_dirty_transaction(self): """归还未提交事务的连接前必须 rollback.""" import db_pool mock_conn = MagicMock() mock_conn.open = True db_pool._pool = None db_pool._init_pool(max_conn=4) db_pool._pool.get() # 腾出一格 db_pool.put_conn(mock_conn) mock_conn.rollback.assert_called_once() assert db_pool._pool.get() is None # mock_conn 已归还(3 个 None 之后是它) assert db_pool._pool.get() is None assert db_pool._pool.get() is None assert db_pool._pool.get() is mock_conn def test_put_conn_discards_connection_when_rollback_fails(self): """rollback 失败说明连接已坏,应丢弃(放 None)而非塞回池.""" import db_pool mock_conn = MagicMock() mock_conn.open = True mock_conn.rollback.side_effect = Exception("connection gone") db_pool._pool = None db_pool._init_pool(max_conn=2) db_pool._pool.get() # 腾出一格 db_pool.put_conn(mock_conn) mock_conn.close.assert_called_once() # 池里补回的是 None,不是坏连接 assert db_pool._pool.get() is None @patch("db_pool.pymysql.connect") def test_get_conn_returns_slot_on_total_failure(self, mock_connect): """连接创建全部失败后,占用的池位必须归还,否则池会被逐步耗尽.""" import db_pool mock_connect.side_effect = Exception("connection refused") db_pool._pool = None db_pool._init_pool(max_conn=2) with patch("time.sleep"): with pytest.raises(Exception, match="connection refused"): db_pool.get_conn(max_retries=2, retry_delay=0) # 池容量未被吃掉:取走 2 个仍能拿到(不会 Empty 阻塞) assert db_pool._pool.get(timeout=1) is None assert db_pool._pool.get(timeout=1) is None @patch("db_pool.get_conn") @patch("db_pool.put_conn") def test_dbcontext_rolls_back_on_exception(self, mock_put, mock_get): """异常出栈时 DBContext 显式回滚,避免脏事务被复用.""" import db_pool mock_conn = MagicMock() mock_get.return_value = mock_conn with pytest.raises(ValueError): with db_pool.DBContext(): raise ValueError("boom") mock_conn.rollback.assert_called_once() mock_put.assert_called_once_with(mock_conn) @patch("db_pool.get_conn") @patch("db_pool.put_conn") def test_dbcontext_no_rollback_on_success(self, mock_put, mock_get): """正常退出时不额外回滚(由 put_conn 统一兜底).""" import db_pool mock_conn = MagicMock() mock_get.return_value = mock_conn with db_pool.DBContext(): pass mock_conn.rollback.assert_not_called() mock_put.assert_called_once_with(mock_conn) # ── DBContext ──────────────────────────────────────────── class TestDBContext: """上下文管理器测试.""" def teardown_method(self, method): """每个测试后重置全局 _pool.""" import db_pool db_pool._pool = None @patch("db_pool.get_conn") @patch("db_pool.put_conn") def test_context_manager_get_and_put(self, mock_put, mock_get): """DBContext 正确获取和归还连接.""" import db_pool mock_conn = MagicMock() mock_get.return_value = mock_conn with db_pool.DBContext() as conn: assert conn is mock_conn mock_get.assert_called_once() mock_put.assert_called_once_with(mock_conn) @patch("db_pool.get_conn") @patch("db_pool.put_conn") def test_context_manager_returns_connection_even_on_exception(self, mock_put, mock_get): """异常时连接也被归还.""" import db_pool mock_conn = MagicMock() mock_get.return_value = mock_conn with pytest.raises(ValueError): with db_pool.DBContext() as conn: raise ValueError("test error") mock_put.assert_called_once_with(mock_conn) @patch("db_pool.get_conn") @patch("db_pool.put_conn") def test_context_manager_sets_conn_none_after_exit(self, mock_put, mock_get): """退出后 self.conn 被置为 None.""" import db_pool mock_conn = MagicMock() mock_get.return_value = mock_conn ctx = db_pool.DBContext() with ctx as conn: pass assert ctx.conn is None