fix: rewrite voice_clone task tests — mock CosyVoiceService + fix bind=True call
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Failing after 36h4m16s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 36h4m16s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Failing after 36h4m16s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 36h4m16s
Root causes of 6 test failures:
1. CosyVoiceService was not mocked — production code instantiates it
with real get_shared_settings() + httpx.Client() calls
2. VoiceCloneWorkflowService mocked as MagicMock doesn't execute real
poll_and_process_clone logic — not_found test needs explicit side_effect
3. Celery bind=True task.__call__ auto-passes self — passing mock_self
manually caused 'takes 2 positional arguments but 3 were given'
Fixes:
- Add @patch('worker_app.tasks.voice_clone.CosyVoiceService') to all tests
- Use patch.object(task, 'retry') for timeout retry test
- Use MagicMock for profile return values instead of real dataclass
- Call process_voice_clone(profile_id) without manual self arg
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,8 @@
|
||||
因此必须在 @patch 装饰器解析模块路径之前,将 worker_app.db 预注入 sys.modules。
|
||||
|
||||
注意:production code 使用 VoiceCloneWorkflowService(非直接 CosyVoiceService),
|
||||
且 @task(bind=True) 装饰后直接 import 的函数需要显式传入 self 参数。
|
||||
Celery bind=True 任务通过 __call__ 调用时自动传入 self,不需要手动传 mock_self。
|
||||
CosyVoiceService 在 voice_clone.py 中被实例化传入 workflow,必须 mock 防止真实初始化。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -33,24 +34,17 @@ from packages.application.cosyvoice_service import (
|
||||
CosyVoiceError,
|
||||
CosyVoiceTimeoutError,
|
||||
)
|
||||
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
|
||||
from packages.application.voice_clone.use_cases import VoiceCloneNotFoundError
|
||||
|
||||
|
||||
def _make_profile(
|
||||
def _make_mock_profile(
|
||||
*,
|
||||
status: VoiceCloneStatus = VoiceCloneStatus.PROCESSING,
|
||||
metadata: dict | None = None,
|
||||
) -> VoiceCloneProfile:
|
||||
"""创建测试用 VoiceCloneProfile。"""
|
||||
if metadata is None:
|
||||
metadata = {"cosyvoice_task_id": "task-abc"}
|
||||
profile = VoiceCloneProfile.create(
|
||||
user_id="user-123",
|
||||
name="测试音色",
|
||||
source_audio_url="https://example.com/audio.wav",
|
||||
max_retries=3,
|
||||
metadata=metadata,
|
||||
)
|
||||
voice_id: str = "voice-xyz",
|
||||
status: str = "ready",
|
||||
) -> MagicMock:
|
||||
"""创建测试用 mock profile。"""
|
||||
profile = MagicMock()
|
||||
profile.voice_id = voice_id
|
||||
profile.status = status
|
||||
return profile
|
||||
|
||||
@@ -61,64 +55,71 @@ def _make_profile(
|
||||
class TestProcessVoiceCloneSuccess:
|
||||
"""测试成功场景。"""
|
||||
|
||||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||||
@patch("worker_app.tasks.voice_clone.VoiceCloneWorkflowService")
|
||||
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
|
||||
def test_process_voice_clone_success(self, mock_repo_cls: MagicMock, mock_workflow_cls: MagicMock) -> None:
|
||||
def test_process_voice_clone_success(
|
||||
self,
|
||||
mock_repo_cls: MagicMock,
|
||||
mock_workflow_cls: MagicMock,
|
||||
mock_cosy_cls: MagicMock,
|
||||
) -> None:
|
||||
"""克隆成功:轮询返回 voice_id,profile 标记为 ready。"""
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_workflow = MagicMock()
|
||||
|
||||
profile = _make_profile()
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_workflow.poll_and_process_clone.return_value = VoiceCloneProfile(
|
||||
id="profile-123",
|
||||
user_id="user-123",
|
||||
name="测试音色",
|
||||
source_audio_url="https://example.com/audio.wav",
|
||||
max_retries=3,
|
||||
metadata={"cosyvoice_task_id": "task-abc"},
|
||||
voice_id="voice-xyz",
|
||||
status=VoiceCloneStatus.READY,
|
||||
)
|
||||
mock_workflow_cls.return_value = mock_workflow
|
||||
|
||||
mock_result = _make_mock_profile(voice_id="voice-xyz")
|
||||
mock_workflow.poll_and_process_clone.return_value = mock_result
|
||||
|
||||
_mock_db_module.SessionLocal.return_value = mock_session
|
||||
|
||||
from worker_app.tasks.voice_clone import process_voice_clone
|
||||
|
||||
# bind=True: 直接调用需传入 self 参数
|
||||
mock_self = MagicMock()
|
||||
result = process_voice_clone(mock_self, "profile-123")
|
||||
# bind=True: Celery __call__ 自动传入 self,只需传 profile_id
|
||||
result = process_voice_clone("profile-123")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["voice_id"] == "voice-xyz"
|
||||
mock_workflow.poll_and_process_clone.assert_called_once_with("profile-123", timeout=300)
|
||||
mock_workflow.poll_and_process_clone.assert_called_once_with(
|
||||
"profile-123",
|
||||
timeout=300,
|
||||
)
|
||||
mock_session.commit.assert_called_once()
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||||
@patch("worker_app.tasks.voice_clone.VoiceCloneWorkflowService")
|
||||
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
|
||||
def test_process_voice_clone_profile_not_found(
|
||||
self, mock_repo_cls: MagicMock, mock_workflow_cls: MagicMock
|
||||
self,
|
||||
mock_repo_cls: MagicMock,
|
||||
mock_workflow_cls: MagicMock,
|
||||
mock_cosy_cls: MagicMock,
|
||||
) -> None:
|
||||
"""profile 不存在时返回 failed。"""
|
||||
"""profile 不存在时 workflow 抛 VoiceCloneNotFoundError,返回 failed。"""
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
mock_workflow = MagicMock()
|
||||
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_workflow_cls.return_value = mock_workflow
|
||||
|
||||
# workflow 是 MagicMock,不会执行真实逻辑,需手动设置 side_effect
|
||||
mock_workflow.poll_and_process_clone.side_effect = VoiceCloneNotFoundError("Voice clone nonexistent not found")
|
||||
|
||||
_mock_db_module.SessionLocal.return_value = mock_session
|
||||
|
||||
from worker_app.tasks.voice_clone import process_voice_clone
|
||||
|
||||
mock_self = MagicMock()
|
||||
result = process_voice_clone(mock_self, "nonexistent")
|
||||
result = process_voice_clone("nonexistent")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "not found" in result["error"].lower()
|
||||
mock_session.rollback.assert_called_once()
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
|
||||
@@ -128,33 +129,38 @@ class TestProcessVoiceCloneSuccess:
|
||||
class TestProcessVoiceCloneTimeout:
|
||||
"""测试超时场景。"""
|
||||
|
||||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||||
@patch("worker_app.tasks.voice_clone.VoiceCloneWorkflowService")
|
||||
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
|
||||
def test_process_voice_clone_timeout_retries(self, mock_repo_cls: MagicMock, mock_workflow_cls: MagicMock) -> None:
|
||||
def test_process_voice_clone_timeout_retries(
|
||||
self,
|
||||
mock_repo_cls: MagicMock,
|
||||
mock_workflow_cls: MagicMock,
|
||||
mock_cosy_cls: MagicMock,
|
||||
) -> None:
|
||||
"""超时时调用 self.retry() 进行重试,Retry 异常向上传播。"""
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_workflow = MagicMock()
|
||||
|
||||
profile = _make_profile()
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_workflow_cls.return_value = mock_workflow
|
||||
|
||||
mock_workflow.poll_and_process_clone.side_effect = CosyVoiceTimeoutError("任务超时")
|
||||
mock_workflow_cls.return_value = mock_workflow
|
||||
|
||||
_mock_db_module.SessionLocal.return_value = mock_session
|
||||
|
||||
from worker_app.tasks.voice_clone import process_voice_clone
|
||||
|
||||
# bind=True: mock self,设置 retry 抛出 Retry
|
||||
mock_self = MagicMock()
|
||||
mock_self.retry.side_effect = Retry("retrying")
|
||||
# mock self.retry:Celery __call__ 传入的 self 是 task 对象本身
|
||||
with patch.object(process_voice_clone, "retry") as mock_retry:
|
||||
mock_retry.side_effect = Retry("retrying")
|
||||
|
||||
with pytest.raises(Retry):
|
||||
process_voice_clone(mock_self, "profile-123")
|
||||
with pytest.raises(Retry):
|
||||
process_voice_clone("profile-123")
|
||||
|
||||
mock_retry.assert_called_once()
|
||||
|
||||
mock_self.retry.assert_called_once()
|
||||
mock_session.rollback.assert_called_once()
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
@@ -165,85 +171,92 @@ class TestProcessVoiceCloneTimeout:
|
||||
class TestProcessVoiceCloneFailure:
|
||||
"""测试失败场景。"""
|
||||
|
||||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||||
@patch("worker_app.tasks.voice_clone.VoiceCloneWorkflowService")
|
||||
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
|
||||
def test_process_voice_clone_cosyvoice_error(self, mock_repo_cls: MagicMock, mock_workflow_cls: MagicMock) -> None:
|
||||
def test_process_voice_clone_cosyvoice_error(
|
||||
self,
|
||||
mock_repo_cls: MagicMock,
|
||||
mock_workflow_cls: MagicMock,
|
||||
mock_cosy_cls: MagicMock,
|
||||
) -> None:
|
||||
"""CosyVoice 错误:profile 标记为 failed。"""
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_workflow = MagicMock()
|
||||
|
||||
profile = _make_profile()
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_workflow_cls.return_value = mock_workflow
|
||||
|
||||
mock_workflow.poll_and_process_clone.side_effect = CosyVoiceError("克隆失败")
|
||||
mock_workflow_cls.return_value = mock_workflow
|
||||
|
||||
_mock_db_module.SessionLocal.return_value = mock_session
|
||||
|
||||
from worker_app.tasks.voice_clone import process_voice_clone
|
||||
|
||||
mock_self = MagicMock()
|
||||
result = process_voice_clone(mock_self, "profile-123")
|
||||
result = process_voice_clone("profile-123")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "克隆失败" in result["error"]
|
||||
mock_session.rollback.assert_called_once()
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||||
@patch("worker_app.tasks.voice_clone.VoiceCloneWorkflowService")
|
||||
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
|
||||
def test_process_voice_clone_unexpected_error(self, mock_repo_cls: MagicMock, mock_workflow_cls: MagicMock) -> None:
|
||||
def test_process_voice_clone_unexpected_error(
|
||||
self,
|
||||
mock_repo_cls: MagicMock,
|
||||
mock_workflow_cls: MagicMock,
|
||||
mock_cosy_cls: MagicMock,
|
||||
) -> None:
|
||||
"""意外异常:profile 标记为 failed。"""
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_workflow = MagicMock()
|
||||
|
||||
profile = _make_profile()
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_workflow_cls.return_value = mock_workflow
|
||||
|
||||
mock_workflow.poll_and_process_clone.side_effect = RuntimeError("未知错误")
|
||||
mock_workflow_cls.return_value = mock_workflow
|
||||
|
||||
_mock_db_module.SessionLocal.return_value = mock_session
|
||||
|
||||
from worker_app.tasks.voice_clone import process_voice_clone
|
||||
|
||||
mock_self = MagicMock()
|
||||
result = process_voice_clone(mock_self, "profile-123")
|
||||
result = process_voice_clone("profile-123")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "未知错误" in result["error"]
|
||||
mock_session.rollback.assert_called_once()
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||||
@patch("worker_app.tasks.voice_clone.VoiceCloneWorkflowService")
|
||||
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
|
||||
def test_process_voice_clone_no_task_id(self, mock_repo_cls: MagicMock, mock_workflow_cls: MagicMock) -> None:
|
||||
def test_process_voice_clone_no_task_id(
|
||||
self,
|
||||
mock_repo_cls: MagicMock,
|
||||
mock_workflow_cls: MagicMock,
|
||||
mock_cosy_cls: MagicMock,
|
||||
) -> None:
|
||||
"""metadata 中没有 cosyvoice_task_id 时返回 failed。"""
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_workflow = MagicMock()
|
||||
|
||||
# 显式传入空 dict,确保没有 cosyvoice_task_id
|
||||
profile = _make_profile(metadata={})
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
# workflow 对缺少 task_id 的 profile 抛出 CosyVoiceError
|
||||
mock_workflow.poll_and_process_clone.side_effect = CosyVoiceError("missing task_id")
|
||||
mock_workflow_cls.return_value = mock_workflow
|
||||
|
||||
mock_workflow.poll_and_process_clone.side_effect = CosyVoiceError("missing task_id")
|
||||
|
||||
_mock_db_module.SessionLocal.return_value = mock_session
|
||||
|
||||
from worker_app.tasks.voice_clone import process_voice_clone
|
||||
|
||||
mock_self = MagicMock()
|
||||
result = process_voice_clone(mock_self, "profile-123")
|
||||
result = process_voice_clone("profile-123")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "task_id" in result["error"]
|
||||
mock_session.rollback.assert_called_once()
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user