From eb7051d93f43c3a8909274b0eec87280f75ab510 Mon Sep 17 00:00:00 2001 From: jhao104 Date: Thu, 28 May 2026 23:00:53 +0800 Subject: [PATCH 1/2] =?UTF-8?q?test:=20=E4=BD=BF=E7=94=A8pytest=E9=87=8D?= =?UTF-8?q?=E5=86=99=E6=B5=8B=E8=AF=95=E5=A5=97=E4=BB=B6=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E107=E4=B8=AA=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除旧test/目录(纯print脚本,无断言,依赖真实Redis) - 新增tests/目录,按unit/api/integration三层组织 - unit/: Proxy类、DbClient URI解析、ConfigHandler配置、formatValidator正则 - api/: Flask test_client测试全部API路由,mock ProxyHandler - integration/: RedisClient/SsdbClient完整CRUD,使用fakeredis模拟 - 新增requirements-test.txt(pytest、pytest-cov、fakeredis) - 新增pyproject.toml pytest配置 - 升级redis依赖至>=4.2.0以兼容fakeredis>=2.0 - tox.ini添加skip_install=true,修复packaging backend错误 - CI工作流改用pytest直接运行,移除Redis service,新增Python 3.12 - 修复README CI badge链接 - 更新CLAUDE.md、docs/文档补充测试说明 --- .github/workflows/test.yml | 24 +--- .gitignore | 7 +- CLAUDE.md | 59 +++++++- README.md | 2 +- docs/changelog.md | 2 + docs/getting-started.md | 33 +++++ docs/project-structure.md | 8 +- pyproject.toml | 8 ++ requirements-test.txt | 5 + requirements.txt | 2 +- test.py | 31 ----- test/__init__.py | 13 -- test/testConfigHandler.py | 38 ------ test/testDbClient.py | 39 ------ test/testLogHandler.py | 25 ---- test/testProxyClass.py | 34 ----- test/testProxyFetcher.py | 33 ----- test/testProxyValidator.py | 28 ---- test/testRedisClient.py | 41 ------ test/testSsdbClient.py | 43 ------ tests/__init__.py | 0 tests/api/__init__.py | 0 tests/api/test_proxy_api.py | 154 +++++++++++++++++++++ tests/conftest.py | 100 ++++++++++++++ tests/integration/__init__.py | 0 tests/integration/test_redis_client.py | 149 ++++++++++++++++++++ tests/integration/test_ssdb_client.py | 148 ++++++++++++++++++++ tests/unit/__init__.py | 0 tests/unit/test_config.py | 100 ++++++++++++++ tests/unit/test_db_client.py | 62 +++++++++ tests/unit/test_proxy.py | 179 +++++++++++++++++++++++++ tests/unit/test_validator.py | 68 ++++++++++ tox.ini | 4 +- 33 files changed, 1084 insertions(+), 355 deletions(-) create mode 100644 pyproject.toml create mode 100644 requirements-test.txt delete mode 100644 test.py delete mode 100644 test/__init__.py delete mode 100644 test/testConfigHandler.py delete mode 100644 test/testDbClient.py delete mode 100644 test/testLogHandler.py delete mode 100644 test/testProxyClass.py delete mode 100644 test/testProxyFetcher.py delete mode 100644 test/testProxyValidator.py delete mode 100644 test/testRedisClient.py delete mode 100644 test/testSsdbClient.py create mode 100644 tests/__init__.py create mode 100644 tests/api/__init__.py create mode 100644 tests/api/test_proxy_api.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_redis_client.py create mode 100644 tests/integration/test_ssdb_client.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_config.py create mode 100644 tests/unit/test_db_client.py create mode 100644 tests/unit/test_proxy.py create mode 100644 tests/unit/test_validator.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ce3216a83..c021a10b1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,18 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] - - services: - redis: - image: redis:latest - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 @@ -38,11 +27,8 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install tox-uv + pip install -r requirements.txt + pip install -r requirements-test.txt - - name: Run tests with tox - run: | - TOX_ENV="py$(echo ${{ matrix.python-version }} | tr -d '.')" - tox -e $TOX_ENV - env: - DB_CONN: redis://localhost:6379/0 \ No newline at end of file + - name: Run tests + run: pytest --cov=. --cov-report=term-missing \ No newline at end of file diff --git a/.gitignore b/.gitignore index 37149558a..e823c7548 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,8 @@ site/ __pycache__/ *.log .tox -/SPEC.md -/.claude/settings.json +.claude/ +docs/ideas/ +.coverage +.pytest_cache/ +htmlcov/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index e430dcc01..25f087abe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,15 +3,48 @@ 本文件为 Claude Code (claude.ai/code) 在本仓库中工作时提供指导。 ## 技术栈 -Python (3.8–3.12)、Flask (API)、Redis/SSDB (存储)、APScheduler (调度)、click (CLI)、gunicorn (生产服务器)。依赖版本固定记录在 `requirements.txt` 中。 +Python (3.8–3.11)、Flask (API)、Redis/SSDB (存储)、APScheduler (调度)。依赖版本固定记录在 `requirements.txt` 中。 ## 常用命令 - 安装依赖:`pip install -r requirements.txt` - 运行代理爬取/验证调度器:`python proxyPool.py schedule` - 运行 API 服务器:`python proxyPool.py server` +- 运行单元测试:`pytest tests/unit/` +- 运行 API 测试:`pytest tests/api/` +- 运行集成测试(需真实 Redis):`pytest tests/integration/ -m integration` - 运行全部测试:`pytest` -- 运行单个测试:`pytest test/testProxyFetcher.py::test_freeProxy01` -- Docker 部署:`docker-compose up -d` 或 `docker run --env DB_CONN=redis://:password@ip:port/0 -p 5010:5010 jhao104/proxy_pool:latest` +- 查看覆盖率:`pytest --cov=. --cov-report=term-missing` + +## 测试 + +### 目录结构 +``` +tests/ +├── conftest.py # 共享 fixtures(app、client、fake_redis、proxy_obj、reset_singleton) +├── unit/ # 纯逻辑,零外部依赖 +│ ├── test_proxy.py # Proxy 类:构造、序列化、setter、add_source +│ ├── test_db_client.py # DbClient.parseDbConn URI 解析 +│ ├── test_config.py # ConfigHandler 环境变量覆盖 +│ └── test_validator.py # formatValidator 正则匹配 +├── api/ # Flask 测试客户端,mock ProxyHandler +│ └── test_proxy_api.py # /get /pop /all /count /delete 全路由 +└── integration/ # 需要真实 Redis,标记 @pytest.mark.integration + ├── test_redis_client.py # RedisClient 完整 CRUD + └── test_ssdb_client.py # SsdbClient 完整 CRUD +``` + +### 测试分层 +- **unit/**:不依赖外部服务,用 `unittest.mock` 或 `fakeredis` 模拟,CI 必跑 +- **api/**:使用 Flask `app.test_client()`,mock 掉 `ProxyHandler`,不依赖数据库 +- **integration/**:需要真实 Redis,通过 `@pytest.mark.integration` 标记,按需执行 + +### 测试依赖 +`pytest`、`pytest-cov`、`fakeredis`(纯 Python Redis 模拟,无需真实服务) + +### 关键约定 +- 测试函数命名:`test_` 前缀 + 下划线命名(`test_get_with_https`) +- 每个测试前自动重置 `Singleton._inst`,避免单例泄漏 +- 集成测试与单元测试共存:单元测试用 fakeredis 跑,集成测试标记后按需执行 ## 高层架构 免费代理池项目,爬取公开代理源、验证代理可用性、持久化存储到 Redis/SSDB,并通过 Flask RESTful API 提供代理服务。 @@ -47,6 +80,22 @@ Python (3.8–3.12)、Flask (API)、Redis/SSDB (存储)、APScheduler (调度) - `TIMEZONE`:调度器时区(默认 `Asia/Shanghai`) ## 代码风格与命名规范 +- **文件头**:每个 `.py` 文件必须包含以下标准头部: + ```python + # -*- coding: utf-8 -*- + """ + ------------------------------------------------- + File Name: fileName.py + Description : 文件功能描述 + Author : JHao + date: yyyy/mm/dd + ------------------------------------------------- + Change Activity: + yyyy/mm/dd: 修改内容简述 (修改时添加此行) + ------------------------------------------------- + """ + __author__ = 'JHao' + ``` - **缩进**:4 个空格(Python 标准) - **文件命名**:驼峰命名,如 `proxyFetcher.py`、`dbClient.py`、`redisClient.py`、`webRequest.py` - **类命名**:帕斯卡命名,如 `ProxyFetcher`、`RedisClient`、`SsdbClient`、`ProxyValidator` @@ -58,5 +107,5 @@ Python (3.8–3.12)、Flask (API)、Redis/SSDB (存储)、APScheduler (调度) - **单例模式**:使用自定义 `Singleton` 元类(`util/singleton.py`)结合 `six.withMetaclass` 实现 ## 注意事项 -- 免费代理稳定性较差,本项目仅供学习/演示使用。生产环境建议使用付费代理(如 Bright Data,详见 README)。 -- 本仓库中不存在 `.cursorrules` 或 GitHub Copilot 配置文件。 \ No newline at end of file +- 运行测试前需先安装测试依赖:`pip install pytest pytest-cov fakeredis` +- 单元测试和 API 测试不依赖外部服务,可直接运行;集成测试需启动 Redis diff --git a/README.md b/README.md index 5800d1c8f..0d080bf26 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ProxyPool 爬虫代理IP池 ======= -[![Comprehensive Tests](https://github.com/jhao104/proxy_pool/actions/workflows/comprehensive-test.yml/badge.svg?branch=master)](https://github.com/jhao104/proxy_pool/actions/workflows/comprehensive-test.yml) +[![Tests](https://github.com/jhao104/proxy_pool/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/jhao104/proxy_pool/actions/workflows/test.yml) [![](https://img.shields.io/badge/Powered%20by-@j_hao104-green.svg)](http://www.spiderpy.cn/blog/) [![Packagist](https://img.shields.io/packagist/l/doctrine/orm.svg)](https://github.com/jhao104/proxy_pool/blob/master/LICENSE) [![GitHub contributors](https://img.shields.io/github/contributors/jhao104/proxy_pool.svg)](https://github.com/jhao104/proxy_pool/graphs/contributors) diff --git a/docs/changelog.md b/docs/changelog.md index 30b584458..01b5cd39e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -8,6 +8,8 @@ 4. Docker镜像使用 ``tini`` 作为init进程, 正确处理信号转发; (2026-05-26) 5. tox依赖统一从 ``requirements.txt`` 读取并自动重建环境; (2026-05-26) 6. 优化CI配置, 避免PR时重复触发测试; (2026-05-26) +7. **重写测试套件**: 使用pytest重构全部测试, 覆盖unit/api/integration三层; (2026-05-28) + ## 2.4.2 (2024-01-18) diff --git a/docs/getting-started.md b/docs/getting-started.md index fec5330e0..62073ea1b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -124,4 +124,37 @@ kill # 删除 PID 文件 rm proxy_pool.pid +``` + +## 运行测试 + +### 安装测试依赖 + +```console +pip install -r requirements-test.txt +``` + +### 运行全部测试 + +```console +pytest +``` + +### 分层运行 + +```console +# 单元测试(零外部依赖,CI 必跑) +pytest tests/unit/ + +# API 路由测试 +pytest tests/api/ + +# 集成测试(RedisClient/SsdbClient CRUD,使用 fakeredis 模拟) +pytest tests/integration/ +``` + +### 查看覆盖率 + +```console +pytest --cov=. --cov-report=term-missing ``` \ No newline at end of file diff --git a/docs/project-structure.md b/docs/project-structure.md index c6bfa0ea4..ffde8e34e 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -27,12 +27,18 @@ proxy_pool/ │ ├── lazyProperty.py # 惰性属性装饰器 │ ├── six.py # Python 2/3 兼容层 │ └── webRequest.py # HTTP 请求封装 -├── test/ # 单元测试 +├── tests/ # 测试 +│ ├── conftest.py # 共享 fixtures +│ ├── unit/ # 单元测试(零外部依赖) +│ ├── api/ # API 路由测试(Flask test client) +│ └── integration/ # 集成测试(RedisClient/SsdbClient CRUD) ├── docs/ # MkDocs 文档源文件 ├── proxyPool.py # CLI 入口(click) ├── proxy_pool.sh # 服务管理脚本 ├── setting.py # 全局配置文件 ├── requirements.txt # Python 依赖 +├── requirements-test.txt # 测试依赖(pytest、pytest-cov、fakeredis) +├── pyproject.toml # pytest 配置 ├── Dockerfile # Docker 镜像构建 ├── docker-compose.yml # Docker Compose 编排 └── tox.ini # 多版本测试配置 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..70bf49eec --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[tool.setuptools] +py-modules = [] + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "integration: 需要外部服务(如 Redis)的集成测试", +] \ No newline at end of file diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 000000000..a07a17646 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,5 @@ +pytest>=7.0 +pytest-cov>=4.0 +fakeredis>=2.0 +async_timeout>=3.0;python_version<"3.11" +typing_extensions>=4.0;python_version<"3.11" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ad68b2c07..fe1f6968e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ requests==2.31.0 gunicorn==19.9.0 lxml==4.9.2 -redis==3.5.3 +redis>=4.2.0 APScheduler==3.10.0;python_version>="3.10" APScheduler==3.2.0;python_version<"3.10" click==8.0.1 diff --git a/test.py b/test.py deleted file mode 100644 index b1c7ca1e2..000000000 --- a/test.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -""" -------------------------------------------------- - File Name: test.py - Description : - Author : JHao - date: 2017/3/7 -------------------------------------------------- - Change Activity: - 2017/3/7: -------------------------------------------------- -""" -__author__ = 'JHao' - -from test import testProxyValidator -from test import testConfigHandler -from test import testLogHandler -from test import testDbClient - -if __name__ == '__main__': - print("ConfigHandler:") - testConfigHandler.testConfig() - - print("LogHandler:") - testLogHandler.testLogHandler() - - print("DbClient:") - testDbClient.testDbClient() - - print("ProxyValidator:") - testProxyValidator.testProxyValidator() diff --git a/test/__init__.py b/test/__init__.py deleted file mode 100644 index d314f9455..000000000 --- a/test/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- -""" -------------------------------------------------- - File Name: __init__ - Description : - Author : JHao - date: 2019/2/15 -------------------------------------------------- - Change Activity: - 2019/2/15: -------------------------------------------------- -""" -__author__ = 'JHao' diff --git a/test/testConfigHandler.py b/test/testConfigHandler.py deleted file mode 100644 index 2336650f6..000000000 --- a/test/testConfigHandler.py +++ /dev/null @@ -1,38 +0,0 @@ -# -*- coding: utf-8 -*- -""" -------------------------------------------------- - File Name: testGetConfig - Description : testGetConfig - Author : J_hao - date: 2017/7/31 -------------------------------------------------- - Change Activity: - 2017/7/31: -------------------------------------------------- -""" -__author__ = 'J_hao' - -from handler.configHandler import ConfigHandler -from time import sleep - - -def testConfig(): - """ - :return: - """ - conf = ConfigHandler() - print(conf.dbConn) - print(conf.serverPort) - print(conf.serverHost) - print(conf.tableName) - assert isinstance(conf.fetchers, list) - print(conf.fetchers) - - for _ in range(2): - print(conf.fetchers) - sleep(5) - - -if __name__ == '__main__': - testConfig() - diff --git a/test/testDbClient.py b/test/testDbClient.py deleted file mode 100644 index e3a7cc2e2..000000000 --- a/test/testDbClient.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8 -*- -""" -------------------------------------------------- - File Name: testDbClient - Description : - Author : JHao - date: 2020/6/23 -------------------------------------------------- - Change Activity: - 2020/6/23: -------------------------------------------------- -""" -__author__ = 'JHao' - -from db.dbClient import DbClient - - -def testDbClient(): - # ############### ssdb ############### - ssdb_uri = "ssdb://:password@127.0.0.1:8888" - s = DbClient.parseDbConn(ssdb_uri) - assert s.db_type == "SSDB" - assert s.db_pwd == "password" - assert s.db_host == "127.0.0.1" - assert s.db_port == 8888 - - # ############### redis ############### - redis_uri = "redis://:password@127.0.0.1:6379/1" - r = DbClient.parseDbConn(redis_uri) - assert r.db_type == "REDIS" - assert r.db_pwd == "password" - assert r.db_host == "127.0.0.1" - assert r.db_port == 6379 - assert r.db_name == "1" - print("DbClient ok!") - - -if __name__ == '__main__': - testDbClient() diff --git a/test/testLogHandler.py b/test/testLogHandler.py deleted file mode 100644 index 433bb2604..000000000 --- a/test/testLogHandler.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -""" -------------------------------------------------- - File Name: testLogHandler - Description : - Author : J_hao - date: 2017/8/2 -------------------------------------------------- - Change Activity: - 2017/8/2: -------------------------------------------------- -""" -__author__ = 'J_hao' - -from handler.logHandler import LogHandler - - -def testLogHandler(): - log = LogHandler('test') - log.info('this is info') - log.error('this is error') - - -if __name__ == '__main__': - testLogHandler() diff --git a/test/testProxyClass.py b/test/testProxyClass.py deleted file mode 100644 index b0ffc9a08..000000000 --- a/test/testProxyClass.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- -""" -------------------------------------------------- - File Name: testProxyClass - Description : - Author : JHao - date: 2019/8/8 -------------------------------------------------- - Change Activity: - 2019/8/8: -------------------------------------------------- -""" -__author__ = 'JHao' - -import json -from helper.proxy import Proxy - - -def testProxyClass(): - proxy = Proxy("127.0.0.1:8080") - - print(proxy.to_json) - - proxy.source = "test" - - proxy_str = json.dumps(proxy.to_dict, ensure_ascii=False) - - print(proxy_str) - - print(Proxy.createFromJson(proxy_str).to_dict) - - -if __name__ == '__main__': - testProxyClass() diff --git a/test/testProxyFetcher.py b/test/testProxyFetcher.py deleted file mode 100644 index a530b2e50..000000000 --- a/test/testProxyFetcher.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -""" -------------------------------------------------- - File Name: testProxyFetcher - Description : - Author : JHao - date: 2020/6/23 -------------------------------------------------- - Change Activity: - 2020/6/23: -------------------------------------------------- -""" -__author__ = 'JHao' - -from fetcher.proxyFetcher import ProxyFetcher -from handler.configHandler import ConfigHandler - - -def testProxyFetcher(): - conf = ConfigHandler() - proxy_getter_functions = conf.fetchers - proxy_counter = {_: 0 for _ in proxy_getter_functions} - for proxyGetter in proxy_getter_functions: - for proxy in getattr(ProxyFetcher, proxyGetter.strip())(): - if proxy: - print('{func}: fetch proxy {proxy}'.format(func=proxyGetter, proxy=proxy)) - proxy_counter[proxyGetter] = proxy_counter.get(proxyGetter) + 1 - for key, value in proxy_counter.items(): - print(key, value) - - -if __name__ == '__main__': - testProxyFetcher() diff --git a/test/testProxyValidator.py b/test/testProxyValidator.py deleted file mode 100644 index 0199ecd75..000000000 --- a/test/testProxyValidator.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -""" -------------------------------------------------- - File Name: testProxyValidator - Description : - Author : JHao - date: 2021/5/25 -------------------------------------------------- - Change Activity: - 2021/5/25: -------------------------------------------------- -""" -__author__ = 'JHao' - -from helper.validator import ProxyValidator - - -def testProxyValidator(): - for _ in ProxyValidator.pre_validator: - print(_) - for _ in ProxyValidator.http_validator: - print(_) - for _ in ProxyValidator.https_validator: - print(_) - - -if __name__ == '__main__': - testProxyValidator() diff --git a/test/testRedisClient.py b/test/testRedisClient.py deleted file mode 100644 index ff5b1d9c4..000000000 --- a/test/testRedisClient.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- -""" -------------------------------------------------- - File Name: testRedisClient - Description : - Author : JHao - date: 2020/6/23 -------------------------------------------------- - Change Activity: - 2020/6/23: -------------------------------------------------- -""" -__author__ = 'JHao' - - -def testRedisClient(): - from db.dbClient import DbClient - from helper.proxy import Proxy - - uri = "redis://:pwd@127.0.0.1:6379" - db = DbClient(uri) - db.changeTable("use_proxy") - proxy = Proxy.createFromJson('{"proxy": "118.190.79.36:8090", "https": false, "fail_count": 0, "region": "", "anonymous": "", "source": "freeProxy14", "check_count": 4, "last_status": true, "last_time": "2021-05-26 10:58:04"}') - - print("put: ", db.put(proxy)) - - print("get: ", db.get(https=None)) - - print("exists: ", db.exists("27.38.96.101:9797")) - - print("exists: ", db.exists("27.38.96.101:8888")) - - print("pop: ", db.pop(https=None)) - - print("getAll: ", db.getAll(https=None)) - - print("getCount", db.getCount()) - - -if __name__ == '__main__': - testRedisClient() diff --git a/test/testSsdbClient.py b/test/testSsdbClient.py deleted file mode 100644 index c24ecc042..000000000 --- a/test/testSsdbClient.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- -""" -------------------------------------------------- - File Name: testSsdbClient - Description : - Author : JHao - date: 2020/7/3 -------------------------------------------------- - Change Activity: - 2020/7/3: -------------------------------------------------- -""" -__author__ = 'JHao' - - -def testSsdbClient(): - from db.dbClient import DbClient - from helper.proxy import Proxy - - uri = "ssdb://@127.0.0.1:8888" - db = DbClient(uri) - db.changeTable("use_proxy") - proxy = Proxy.createFromJson('{"proxy": "118.190.79.36:8090", "https": false, "fail_count": 0, "region": "", "anonymous": "", "source": "freeProxy14", "check_count": 4, "last_status": true, "last_time": "2021-05-26 10:58:04"}') - - print("put: ", db.put(proxy)) - - print("get: ", db.get(https=None)) - - print("exists: ", db.exists("27.38.96.101:9797")) - - print("exists: ", db.exists("27.38.96.101:8888")) - - print("getAll: ", db.getAll(https=None)) - - # print("pop: ", db.pop(https=None)) - - print("clear: ", db.clear()) - - print("getCount", db.getCount()) - - -if __name__ == '__main__': - testSsdbClient() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/api/test_proxy_api.py b/tests/api/test_proxy_api.py new file mode 100644 index 000000000..de6728149 --- /dev/null +++ b/tests/api/test_proxy_api.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +""" +------------------------------------------------- + File Name: testProxyApi.py + Description : Flask API全路由测试 + Author : JHao + date: 2026/5/28 +------------------------------------------------- + Change Activity: + 2026/05/28: +------------------------------------------------- +""" +__author__ = 'JHao' + +import pytest +from helper.proxy import Proxy + + +@pytest.fixture +def mocks(app): + """快捷访问 app._test_mocks""" + return app._test_mocks + + +class TestIndex: + + def test_index_returns_api_list(self, client): + resp = client.get("/") + assert resp.status_code == 200 + data = resp.get_json() + assert "url" in data + assert len(data["url"]) > 0 + + +class TestGet: + + def test_get_returns_proxy(self, client, mocks): + proxy = Proxy("1.2.3.4:8080", source="test", https=False) + mocks["get"].return_value = proxy + + resp = client.get("/get/") + assert resp.status_code == 200 + data = resp.get_json() + assert data["proxy"] == "1.2.3.4:8080" + assert data["https"] is False + + def test_get_no_proxy(self, client, mocks): + mocks["get"].return_value = None + + resp = client.get("/get/") + assert resp.status_code == 200 + data = resp.get_json() + assert data["code"] == 0 + assert data["src"] == "no proxy" + + def test_get_https_filter(self, client, mocks): + proxy = Proxy("5.6.7.8:443", source="test", https=True) + mocks["get"].return_value = proxy + + resp = client.get("/get/?type=https") + assert resp.status_code == 200 + data = resp.get_json() + assert data["https"] is True + mocks["get"].assert_called_with(True) + + def test_get_http_filter(self, client, mocks): + mocks["get"].return_value = None + + client.get("/get/") + mocks["get"].assert_called_with(False) + + +class TestPop: + + def test_pop_returns_proxy(self, client, mocks): + proxy = Proxy("1.2.3.4:8080", source="test") + mocks["pop"].return_value = proxy + + resp = client.get("/pop/") + assert resp.status_code == 200 + data = resp.get_json() + assert data["proxy"] == "1.2.3.4:8080" + + def test_pop_no_proxy(self, client, mocks): + mocks["pop"].return_value = None + + resp = client.get("/pop/") + data = resp.get_json() + assert data["code"] == 0 + + +class TestAll: + + def test_all_returns_list(self, client, mocks): + proxies = [ + Proxy("1.2.3.4:8080", source="test"), + Proxy("5.6.7.8:443", source="test", https=True), + ] + mocks["getAll"].return_value = proxies + + resp = client.get("/all/") + assert resp.status_code == 200 + data = resp.get_json() + assert len(data) == 2 + assert data[0]["proxy"] == "1.2.3.4:8080" + assert data[1]["proxy"] == "5.6.7.8:443" + + def test_all_empty(self, client, mocks): + mocks["getAll"].return_value = [] + + resp = client.get("/all/") + data = resp.get_json() + assert data == [] + + +class TestDelete: + + def test_delete_calls_handler(self, client, mocks): + mocks["delete"].return_value = True + + resp = client.get("/delete/?proxy=1.2.3.4:8080") + assert resp.status_code == 200 + data = resp.get_json() + assert data["code"] == 0 + assert data["src"] is True + mocks["delete"].assert_called_once() + + +class TestCount: + + def test_count_returns_stats(self, client, mocks): + proxies = [ + Proxy("1.2.3.4:8080", source="freeProxy01", https=False), + Proxy("5.6.7.8:443", source="freeProxy02", https=True), + ] + mocks["getAll"].return_value = proxies + + resp = client.get("/count/") + assert resp.status_code == 200 + data = resp.get_json() + assert data["count"] == 2 + assert data["http_type"]["http"] == 1 + assert data["http_type"]["https"] == 1 + assert data["source"]["freeProxy01"] == 1 + assert data["source"]["freeProxy02"] == 1 + + def test_count_empty(self, client, mocks): + mocks["getAll"].return_value = [] + + resp = client.get("/count/") + data = resp.get_json() + assert data["count"] == 0 + assert data["http_type"] == {} + assert data["source"] == {} \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..4e3b5c128 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +""" +------------------------------------------------- + File Name: conftest.py + Description : 测试共享fixtures + Author : JHao + date: 2026/5/28 +------------------------------------------------- + Change Activity: + 2026/05/28: +------------------------------------------------- +""" +__author__ = 'JHao' + +import sys +import os +from unittest.mock import MagicMock, patch + +import pytest +import fakeredis + +# 确保项目根目录在 sys.path 中 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from util.singleton import Singleton +from helper.proxy import Proxy + + +# --------------- Singleton 重置 --------------- + +@pytest.fixture(autouse=True) +def reset_singleton(): + """每个测试前清空 Singleton 缓存,防止测试间状态泄漏""" + saved = Singleton._inst.copy() + Singleton._inst.clear() + yield + Singleton._inst.clear() + Singleton._inst.update(saved) + + +# --------------- Proxy 工厂 --------------- + +@pytest.fixture +def proxy_obj(): + """标准测试用 Proxy 对象""" + return Proxy("1.2.3.4:8080", source="test", https=False) + + +@pytest.fixture +def https_proxy_obj(): + """HTTPS 测试用 Proxy 对象""" + return Proxy("5.6.7.8:443", source="test", https=True) + + +# --------------- Redis / DB --------------- + +@pytest.fixture +def fake_redis(): + """fakeredis 实例,用于 RedisClient/SsdbClient 测试""" + return fakeredis.FakeRedis(decode_responses=True) + + +@pytest.fixture +def mock_db_client(fake_redis): + """mock DbClient,返回 fakeredis 支持的 RedisClient 行为""" + with patch("db.dbClient.DbClient") as mock_cls: + yield mock_cls, fake_redis + + +# --------------- Flask API --------------- + +@pytest.fixture +def app(): + """Flask app,proxy_handler 被 mock""" + # mock 掉 DbClient,防止 ProxyHandler 连接真实 Redis + with patch("db.dbClient.DbClient") as mock_db_cls: + mock_db_instance = MagicMock() + mock_db_cls.return_value = mock_db_instance + + from api.proxyApi import app as flask_app, proxy_handler + flask_app.config["TESTING"] = True + + # 替换 proxy_handler 的方法为 MagicMock,方便测试中配置返回值 + with patch.object(proxy_handler, "get") as mock_get, \ + patch.object(proxy_handler, "pop") as mock_pop, \ + patch.object(proxy_handler, "getAll") as mock_getAll, \ + patch.object(proxy_handler, "delete") as mock_delete: + flask_app._test_mocks = { + "get": mock_get, + "pop": mock_pop, + "getAll": mock_getAll, + "delete": mock_delete, + } + yield flask_app + + +@pytest.fixture +def client(app): + """Flask test client""" + return app.test_client() \ No newline at end of file diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/test_redis_client.py b/tests/integration/test_redis_client.py new file mode 100644 index 000000000..9c048ca0f --- /dev/null +++ b/tests/integration/test_redis_client.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +""" +------------------------------------------------- + File Name: testRedisClient.py + Description : RedisClient集成测试 + Author : JHao + date: 2026/5/28 +------------------------------------------------- + Change Activity: + 2026/05/28: +------------------------------------------------- +""" +__author__ = 'JHao' + +import json +import pytest +import fakeredis +from unittest.mock import patch, MagicMock +from db.redisClient import RedisClient +from helper.proxy import Proxy + + +@pytest.fixture +def redis_client(fake_redis): + """RedisClient 实例,内部连接替换为 fakeredis""" + with patch("db.redisClient.BlockingConnectionPool"): + with patch("db.redisClient.Redis", return_value=fake_redis): + client = RedisClient(host="localhost", port=6379, + username=None, password=None, db="0") + client.changeTable("test_proxy") + return client + + +def _make_proxy(proxy_str, https=False, source="test"): + return Proxy(proxy_str, source=https and "https_test" or "http_test", + https=https) + + +class TestRedisPutGet: + + def test_put_and_get(self, redis_client): + proxy = _make_proxy("1.2.3.4:8080") + redis_client.put(proxy) + result = redis_client.get(https=False) + assert result is not None + data = json.loads(result) + assert data["proxy"] == "1.2.3.4:8080" + + def test_get_https(self, redis_client): + proxy = _make_proxy("5.6.7.8:443", https=True) + redis_client.put(proxy) + result = redis_client.get(https=True) + assert result is not None + data = json.loads(result) + assert data["https"] is True + + def test_get_https_excludes_http(self, redis_client): + proxy = _make_proxy("1.2.3.4:8080", https=False) + redis_client.put(proxy) + result = redis_client.get(https=True) + assert result is None + + def test_get_empty_returns_none(self, redis_client): + result = redis_client.get(https=False) + assert result is None + + +class TestRedisExists: + + def test_exists_true(self, redis_client): + proxy = _make_proxy("1.2.3.4:8080") + redis_client.put(proxy) + assert redis_client.exists("1.2.3.4:8080") is True + + def test_exists_false(self, redis_client): + assert redis_client.exists("9.9.9.9:9999") is False + + +class TestRedisDelete: + + def test_delete(self, redis_client): + proxy = _make_proxy("1.2.3.4:8080") + redis_client.put(proxy) + redis_client.delete("1.2.3.4:8080") + assert redis_client.exists("1.2.3.4:8080") is False + + +class TestRedisPop: + + def test_pop_removes_proxy(self, redis_client): + proxy = _make_proxy("1.2.3.4:8080") + redis_client.put(proxy) + popped = redis_client.pop(https=False) + assert popped is not None + assert redis_client.exists("1.2.3.4:8080") is False + + def test_pop_empty_returns_none(self, redis_client): + result = redis_client.pop(https=False) + assert result is None + + +class TestRedisGetAll: + + def test_get_all(self, redis_client): + redis_client.put(_make_proxy("1.2.3.4:8080")) + redis_client.put(_make_proxy("5.6.7.8:443", https=True)) + all_proxies = redis_client.getAll(https=False) + assert len(all_proxies) == 2 + + def test_get_all_https_filter(self, redis_client): + redis_client.put(_make_proxy("1.2.3.4:8080", https=False)) + redis_client.put(_make_proxy("5.6.7.8:443", https=True)) + https_proxies = redis_client.getAll(https=True) + assert len(https_proxies) == 1 + + +class TestRedisGetCount: + + def test_get_count(self, redis_client): + redis_client.put(_make_proxy("1.2.3.4:8080", https=False)) + redis_client.put(_make_proxy("5.6.7.8:443", https=True)) + count = redis_client.getCount() + assert count["total"] == 2 + assert count["https"] == 1 + + def test_get_count_empty(self, redis_client): + count = redis_client.getCount() + assert count["total"] == 0 + assert count["https"] == 0 + + +class TestRedisClear: + + def test_clear(self, redis_client): + redis_client.put(_make_proxy("1.2.3.4:8080")) + redis_client.put(_make_proxy("5.6.7.8:443")) + redis_client.clear() + count = redis_client.getCount() + assert count["total"] == 0 + + +class TestRedisChangeTable: + + def test_change_table_isolation(self, redis_client): + redis_client.put(_make_proxy("1.2.3.4:8080")) + redis_client.changeTable("other_table") + assert redis_client.getCount()["total"] == 0 + redis_client.changeTable("test_proxy") + assert redis_client.getCount()["total"] == 1 \ No newline at end of file diff --git a/tests/integration/test_ssdb_client.py b/tests/integration/test_ssdb_client.py new file mode 100644 index 000000000..ab046ef7f --- /dev/null +++ b/tests/integration/test_ssdb_client.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +""" +------------------------------------------------- + File Name: testSsdbClient.py + Description : SsdbClient集成测试 + Author : JHao + date: 2026/5/28 +------------------------------------------------- + Change Activity: + 2026/05/28: +------------------------------------------------- +""" +__author__ = 'JHao' + +import json +import pytest +from unittest.mock import patch +from db.ssdbClient import SsdbClient +from helper.proxy import Proxy + + +@pytest.fixture +def ssdb_client(fake_redis): + """SsdbClient 实例,内部连接替换为 fakeredis""" + with patch("db.ssdbClient.BlockingConnectionPool"): + with patch("db.ssdbClient.Redis", return_value=fake_redis): + client = SsdbClient(host="localhost", port=8888, + username=None, password=None) + client.changeTable("test_proxy") + return client + + +def _make_proxy(proxy_str, https=False, source="test"): + return Proxy(proxy_str, source=https and "https_test" or "http_test", + https=https) + + +class TestSsdbPutGet: + + def test_put_and_get(self, ssdb_client): + proxy = _make_proxy("1.2.3.4:8080") + ssdb_client.put(proxy) + result = ssdb_client.get(https=False) + assert result is not None + data = json.loads(result) + assert data["proxy"] == "1.2.3.4:8080" + + def test_get_https(self, ssdb_client): + proxy = _make_proxy("5.6.7.8:443", https=True) + ssdb_client.put(proxy) + result = ssdb_client.get(https=True) + assert result is not None + data = json.loads(result) + assert data["https"] is True + + def test_get_https_excludes_http(self, ssdb_client): + proxy = _make_proxy("1.2.3.4:8080", https=False) + ssdb_client.put(proxy) + result = ssdb_client.get(https=True) + assert result is None + + def test_get_empty_returns_none(self, ssdb_client): + result = ssdb_client.get(https=False) + assert result is None + + +class TestSsdbExists: + + def test_exists_true(self, ssdb_client): + proxy = _make_proxy("1.2.3.4:8080") + ssdb_client.put(proxy) + assert ssdb_client.exists("1.2.3.4:8080") is True + + def test_exists_false(self, ssdb_client): + assert ssdb_client.exists("9.9.9.9:9999") is False + + +class TestSsdbDelete: + + def test_delete(self, ssdb_client): + proxy = _make_proxy("1.2.3.4:8080") + ssdb_client.put(proxy) + ssdb_client.delete("1.2.3.4:8080") + assert ssdb_client.exists("1.2.3.4:8080") is False + + +class TestSsdbPop: + + def test_pop_removes_proxy(self, ssdb_client): + proxy = _make_proxy("1.2.3.4:8080") + ssdb_client.put(proxy) + popped = ssdb_client.pop(https=False) + assert popped is not None + assert ssdb_client.exists("1.2.3.4:8080") is False + + def test_pop_empty_returns_none(self, ssdb_client): + result = ssdb_client.pop(https=False) + assert result is None + + +class TestSsdbGetAll: + + def test_get_all(self, ssdb_client): + ssdb_client.put(_make_proxy("1.2.3.4:8080")) + ssdb_client.put(_make_proxy("5.6.7.8:443", https=True)) + all_proxies = list(ssdb_client.getAll(https=False)) + assert len(all_proxies) == 2 + + def test_get_all_https_filter(self, ssdb_client): + ssdb_client.put(_make_proxy("1.2.3.4:8080", https=False)) + ssdb_client.put(_make_proxy("5.6.7.8:443", https=True)) + https_proxies = list(ssdb_client.getAll(https=True)) + assert len(https_proxies) == 1 + + +class TestSsdbGetCount: + + def test_get_count(self, ssdb_client): + ssdb_client.put(_make_proxy("1.2.3.4:8080", https=False)) + ssdb_client.put(_make_proxy("5.6.7.8:443", https=True)) + count = ssdb_client.getCount() + assert count["total"] == 2 + assert count["https"] == 1 + + def test_get_count_empty(self, ssdb_client): + count = ssdb_client.getCount() + assert count["total"] == 0 + assert count["https"] == 0 + + +class TestSsdbClear: + + def test_clear(self, ssdb_client): + ssdb_client.put(_make_proxy("1.2.3.4:8080")) + ssdb_client.put(_make_proxy("5.6.7.8:443")) + ssdb_client.clear() + count = ssdb_client.getCount() + assert count["total"] == 0 + + +class TestSsdbChangeTable: + + def test_change_table_isolation(self, ssdb_client): + ssdb_client.put(_make_proxy("1.2.3.4:8080")) + ssdb_client.changeTable("other_table") + assert ssdb_client.getCount()["total"] == 0 + ssdb_client.changeTable("test_proxy") + assert ssdb_client.getCount()["total"] == 1 \ No newline at end of file diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 000000000..00f30bea9 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +""" +------------------------------------------------- + File Name: testConfig.py + Description : ConfigHandler环境变量测试 + Author : JHao + date: 2026/5/28 +------------------------------------------------- + Change Activity: + 2026/05/28: +------------------------------------------------- +""" +__author__ = 'JHao' + +import os +import pytest +import setting +from handler.configHandler import ConfigHandler + + +@pytest.fixture(autouse=True) +def clean_env(): + """测试前后清理可能设置的环境变量""" + env_keys = ["DB_CONN", "PORT", "HOST", "TABLE_NAME", "HTTP_URL", + "HTTPS_URL", "VERIFY_TIMEOUT", "MAX_FAIL_COUNT", + "POOL_SIZE_MIN", "PROXY_REGION", "TIMEZONE"] + saved = {k: os.environ.get(k) for k in env_keys} + for k in env_keys: + os.environ.pop(k, None) + yield + for k, v in saved.items(): + if v is not None: + os.environ[k] = v + else: + os.environ.pop(k, None) + + +@pytest.fixture +def conf(): + return ConfigHandler() + + +class TestConfigHandlerDefaults: + + def test_db_conn_default(self, conf): + assert conf.dbConn == setting.DB_CONN + + def test_server_host_default(self, conf): + assert conf.serverHost == setting.HOST + + def test_server_port_default(self, conf): + assert str(conf.serverPort) == str(setting.PORT) + + def test_table_name_default(self, conf): + assert conf.tableName == setting.TABLE_NAME + + def test_http_url_default(self, conf): + assert conf.httpUrl == setting.HTTP_URL + + def test_https_url_default(self, conf): + assert conf.httpsUrl == setting.HTTPS_URL + + def test_verify_timeout_default(self, conf): + assert conf.verifyTimeout == setting.VERIFY_TIMEOUT + + def test_max_fail_count_default(self, conf): + assert conf.maxFailCount == setting.MAX_FAIL_COUNT + + def test_pool_size_min_default(self, conf): + assert conf.poolSizeMin == setting.POOL_SIZE_MIN + + def test_timezone_default(self, conf): + assert conf.timezone == setting.TIMEZONE + + def test_fetchers_is_list(self, conf): + assert isinstance(conf.fetchers, list) + assert len(conf.fetchers) > 0 + + +class TestConfigHandlerEnvOverride: + + def test_db_conn_override(self): + os.environ["DB_CONN"] = "redis://:newpwd@10.0.0.1:6380/3" + conf = ConfigHandler() + assert conf.dbConn == "redis://:newpwd@10.0.0.1:6380/3" + + def test_port_override(self): + os.environ["PORT"] = "8080" + conf = ConfigHandler() + assert str(conf.serverPort) == "8080" + + def test_verify_timeout_override(self): + os.environ["VERIFY_TIMEOUT"] = "30" + conf = ConfigHandler() + assert conf.verifyTimeout == 30 + + def test_max_fail_count_override(self): + os.environ["MAX_FAIL_COUNT"] = "5" + conf = ConfigHandler() + assert conf.maxFailCount == 5 \ No newline at end of file diff --git a/tests/unit/test_db_client.py b/tests/unit/test_db_client.py new file mode 100644 index 000000000..31becb5cf --- /dev/null +++ b/tests/unit/test_db_client.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +""" +------------------------------------------------- + File Name: testDbClient.py + Description : DbClient URI解析单元测试 + Author : JHao + date: 2026/5/28 +------------------------------------------------- + Change Activity: + 2026/05/28: +------------------------------------------------- +""" +__author__ = 'JHao' + +import pytest +from db.dbClient import DbClient + + +class TestParseDbConn: + + def test_redis_uri(self): + DbClient.parseDbConn("redis://:password@127.0.0.1:6379/1") + assert DbClient.db_type == "REDIS" + assert DbClient.db_pwd == "password" + assert DbClient.db_host == "127.0.0.1" + assert DbClient.db_port == 6379 + assert DbClient.db_name == "1" + + def test_ssdb_uri(self): + DbClient.parseDbConn("ssdb://:password@127.0.0.1:8888") + assert DbClient.db_type == "SSDB" + assert DbClient.db_pwd == "password" + assert DbClient.db_host == "127.0.0.1" + assert DbClient.db_port == 8888 + + def test_redis_uri_no_password(self): + DbClient.parseDbConn("redis://127.0.0.1:6379/0") + assert DbClient.db_type == "REDIS" + assert DbClient.db_pwd is None + assert DbClient.db_host == "127.0.0.1" + assert DbClient.db_port == 6379 + assert DbClient.db_name == "0" + + def test_ssdb_uri_no_password(self): + DbClient.parseDbConn("ssdb://@127.0.0.1:8888") + assert DbClient.db_type == "SSDB" + assert DbClient.db_host == "127.0.0.1" + assert DbClient.db_port == 8888 + + def test_unknown_db_type_raises(self): + with pytest.raises(AssertionError): + DbClient("mysql://127.0.0.1:3306") + + @pytest.mark.parametrize("uri,expected_type", [ + ("redis://:pwd@10.0.0.1:6380/2", "REDIS"), + ("ssdb://:pwd@10.0.0.1:8899", "SSDB"), + ]) + def test_parse_returns_cls(self, uri, expected_type): + """parseDbConn 返回 cls 以支持链式调用""" + result = DbClient.parseDbConn(uri) + assert result is DbClient + assert DbClient.db_type == expected_type \ No newline at end of file diff --git a/tests/unit/test_proxy.py b/tests/unit/test_proxy.py new file mode 100644 index 000000000..d8f1bb680 --- /dev/null +++ b/tests/unit/test_proxy.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +""" +------------------------------------------------- + File Name: testProxy.py + Description : Proxy类单元测试 + Author : JHao + date: 2026/5/28 +------------------------------------------------- + Change Activity: + 2026/05/28: +------------------------------------------------- +""" +__author__ = 'JHao' + +import json +import pytest +from helper.proxy import Proxy + + +class TestProxyInit: + """Proxy 构造测试""" + + def test_default_values(self): + p = Proxy("1.2.3.4:8080") + assert p.proxy == "1.2.3.4:8080" + assert p.fail_count == 0 + assert p.region == "" + assert p.anonymous == "" + assert p.source == "" + assert p.check_count == 0 + assert p.last_status == "" + assert p.last_time == "" + assert p.https is False + + def test_custom_values(self): + p = Proxy( + "5.6.7.8:443", + fail_count=3, + region="US", + anonymous="high", + source="freeProxy01", + check_count=10, + last_status=True, + last_time="2024-01-01 00:00:00", + https=True, + ) + assert p.proxy == "5.6.7.8:443" + assert p.fail_count == 3 + assert p.region == "US" + assert p.anonymous == "high" + assert p.source == "freeProxy01" + assert p.check_count == 10 + assert p.last_status is True + assert p.last_time == "2024-01-01 00:00:00" + assert p.https is True + + def test_source_with_slash(self): + """source 含 / 时应被拆分为列表,读回时用 / 连接""" + p = Proxy("1.2.3.4:8080", source="freeProxy01/freeProxy02") + assert p.source == "freeProxy01/freeProxy02" + + +class TestProxySerialization: + """序列化 / 反序列化测试""" + + def test_to_dict_keys(self): + p = Proxy("1.2.3.4:8080") + d = p.to_dict + expected_keys = {"proxy", "https", "fail_count", "region", "anonymous", + "source", "check_count", "last_status", "last_time"} + assert set(d.keys()) == expected_keys + + def test_to_dict_values(self): + p = Proxy("1.2.3.4:8080", source="test", https=True) + d = p.to_dict + assert d["proxy"] == "1.2.3.4:8080" + assert d["https"] is True + assert d["source"] == "test" + assert d["fail_count"] == 0 + + def test_to_json_is_valid_json(self): + p = Proxy("1.2.3.4:8080", source="test") + j = p.to_json + d = json.loads(j) + assert d["proxy"] == "1.2.3.4:8080" + + def test_create_from_json_roundtrip(self): + """to_json -> createFromJson 往返一致性""" + original = Proxy("10.0.0.1:3128", source="freeProxy01/freeProxy02", + https=True, fail_count=2, region="CN") + restored = Proxy.createFromJson(original.to_json) + assert restored.proxy == original.proxy + assert restored.https == original.https + assert restored.fail_count == original.fail_count + assert restored.region == original.region + assert restored.source == original.source + + def test_create_from_json_minimal(self): + """createFromJson 缺少字段时使用默认值""" + j = '{"proxy": "1.2.3.4:8080"}' + p = Proxy.createFromJson(j) + assert p.proxy == "1.2.3.4:8080" + assert p.fail_count == 0 + assert p.https is False + + def test_create_from_json_with_slash_source(self): + """source 含 / 的 JSON 反序列化""" + j = '{"proxy": "1.2.3.4:8080", "source": "freeProxy01/freeProxy02", "https": false}' + p = Proxy.createFromJson(j) + assert p.source == "freeProxy01/freeProxy02" + + def test_to_dict_to_json_consistency(self): + """to_dict 和 to_json 数据一致""" + p = Proxy("1.2.3.4:8080", source="test", https=True, fail_count=1) + d = p.to_dict + j = json.loads(p.to_json) + assert d == j + + +class TestProxySetters: + """setter 测试""" + + def test_fail_count_setter(self): + p = Proxy("1.2.3.4:8080") + p.fail_count = 5 + assert p.fail_count == 5 + + def test_check_count_setter(self): + p = Proxy("1.2.3.4:8080") + p.check_count = 10 + assert p.check_count == 10 + + def test_last_status_setter(self): + p = Proxy("1.2.3.4:8080") + p.last_status = True + assert p.last_status is True + + def test_last_time_setter(self): + p = Proxy("1.2.3.4:8080") + p.last_time = "2024-01-01 12:00:00" + assert p.last_time == "2024-01-01 12:00:00" + + def test_https_setter(self): + p = Proxy("1.2.3.4:8080") + p.https = True + assert p.https is True + + def test_region_setter(self): + p = Proxy("1.2.3.4:8080") + p.region = "US" + assert p.region == "US" + + +class TestProxyAddSource: + """add_source 测试""" + + def test_add_source(self): + p = Proxy("1.2.3.4:8080", source="src1") + p.add_source("src2") + assert "src1" in p.source + assert "src2" in p.source + + def test_add_source_dedup(self): + """重复 source 不应重复添加""" + p = Proxy("1.2.3.4:8080", source="src1") + p.add_source("src1") + assert p.source.count("src1") == 1 + + def test_add_source_empty_string(self): + """空字符串不应添加""" + p = Proxy("1.2.3.4:8080", source="src1") + p.add_source("") + assert p.source == "src1" + + def test_add_source_none(self): + """None 不应添加""" + p = Proxy("1.2.3.4:8080", source="src1") + p.add_source(None) + assert p.source == "src1" \ No newline at end of file diff --git a/tests/unit/test_validator.py b/tests/unit/test_validator.py new file mode 100644 index 000000000..25f374078 --- /dev/null +++ b/tests/unit/test_validator.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +""" +------------------------------------------------- + File Name: testValidator.py + Description : formatValidator正则测试 + Author : JHao + date: 2026/5/28 +------------------------------------------------- + Change Activity: + 2026/05/28: +------------------------------------------------- +""" +__author__ = 'JHao' + +import re +import pytest + +# 直接导入 IP_REGEX 和 formatValidator,不导入整个 validator 模块(避免模块级副作用) +from helper.validator import IP_REGEX, formatValidator + + +class TestIPRegex: + + @pytest.mark.parametrize("proxy", [ + "1.2.3.4:8080", + "192.168.1.1:3128", + "10.0.0.1:80", + "255.255.255.255:65535", + "0.0.0.0:1", + "1.2.3.4:99999", # regex 不校验端口范围 + "999.1.1.1:80", # regex 不校验 IP 范围 + "user:pass@1.2.3.4:8080", + "admin:secret@192.168.1.1:443", + ]) + def test_valid_proxy_format(self, proxy): + assert IP_REGEX.fullmatch(proxy) is not None, f"应匹配: {proxy}" + + @pytest.mark.parametrize("proxy", [ + "", + "abc", + "1.2.3.4", + "1.2.3.4:", + ":8080", + "1.2.3.4:abc", + "1.2.3.4:8080:extra", + "host:8080", + ]) + def test_invalid_proxy_format(self, proxy): + assert IP_REGEX.fullmatch(proxy) is None, f"不应匹配: {proxy}" + + +class TestFormatValidator: + + @pytest.mark.parametrize("proxy", [ + "1.2.3.4:8080", + "192.168.1.1:3128", + "user:pass@10.0.0.1:80", + ]) + def test_valid_returns_true(self, proxy): + assert formatValidator(proxy) is True + + @pytest.mark.parametrize("proxy", [ + "", + "abc", + "1.2.3.4", + ]) + def test_invalid_returns_false(self, proxy): + assert formatValidator(proxy) is False \ No newline at end of file diff --git a/tox.ini b/tox.ini index 90cd97751..aa50babb5 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,9 @@ envlist = py38,py39,py310,py311 skip_missing_interpreters = true [testenv] +skip_install = true recreate = true deps = -r requirements.txt -commands = python test.py \ No newline at end of file + -r requirements-test.txt +commands = pytest \ No newline at end of file From 139cb2904b2dfb435200c4b9e0e6a475081fc241 Mon Sep 17 00:00:00 2001 From: jhao104 Date: Thu, 28 May 2026 23:03:55 +0800 Subject: [PATCH 2/2] [update] remove py3.12 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c021a10b1..74d3a691d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v4