Skip to content

Commit 286a845

Browse files
WEIFENG2333claudehappy-otter
committed
Add integration tests with real model inference
- tests/test_integration.py: 12 tests that actually install the runtime, start the server, load FSMN-VAD model, and run inference - .github/workflows/integration.yml: manual-trigger CI for integration tests - CI unit tests now skip integration tests via -m "not integration" - Register integration marker in pyproject.toml Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
1 parent c24a217 commit 286a845

File tree

4 files changed

+221
-1
lines changed

4 files changed

+221
-1
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,4 @@ jobs:
2828
pip install pytest
2929
3030
- name: Run tests
31-
run: python -m pytest tests/ -v
31+
run: python -m pytest tests/ -v -m "not integration"

.github/workflows/integration.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: Integration Tests
2+
3+
on:
4+
workflow_dispatch: # Manual trigger from GitHub UI
5+
6+
jobs:
7+
integration:
8+
runs-on: ubuntu-latest
9+
timeout-minutes: 30
10+
11+
steps:
12+
- uses: actions/checkout@v4
13+
14+
- uses: actions/setup-python@v5
15+
with:
16+
python-version: "3.11"
17+
18+
- name: Install uv
19+
run: curl -LsSf https://astral.sh/uv/install.sh | sh
20+
21+
- name: Install package and test dependencies
22+
run: |
23+
pip install -e .
24+
pip install pytest
25+
26+
- name: Run integration tests
27+
run: python -m pytest tests/test_integration.py -v -s -m integration

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ packages = ["src/funasr_server"]
3636

3737
[tool.pytest.ini_options]
3838
testpaths = ["tests"]
39+
markers = [
40+
"integration: real environment installation and model inference (slow, needs internet)",
41+
]
3942

4043
[project.optional-dependencies]
4144
dev = [

tests/test_integration.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""Integration tests — real environment installation and model inference.
2+
3+
These tests actually:
4+
1. Install the full FunASR runtime (uv + torch + funasr)
5+
2. Start the server process
6+
3. Load real models and run inference
7+
4. Verify results
8+
9+
Requires: internet connection, ~2GB disk space, ~5 minutes.
10+
11+
Run with:
12+
pytest tests/test_integration.py -v -s
13+
14+
Skip in normal CI (these are marked with @pytest.mark.integration).
15+
"""
16+
17+
import math
18+
import struct
19+
import tempfile
20+
import wave
21+
from pathlib import Path
22+
23+
import pytest
24+
25+
from funasr_server import FunASR
26+
27+
28+
# Mark all tests in this module as integration tests
29+
pytestmark = pytest.mark.integration
30+
31+
32+
def _generate_test_wav(path: str, duration_sec: float = 2.0, sample_rate: int = 16000):
33+
"""Generate a simple sine wave WAV file using only the standard library.
34+
35+
Creates a 440Hz tone — enough for VAD to detect speech-like activity.
36+
"""
37+
frequency = 440.0
38+
num_samples = int(duration_sec * sample_rate)
39+
40+
with wave.open(path, "wb") as wf:
41+
wf.setnchannels(1)
42+
wf.setsampwidth(2) # 16-bit
43+
wf.setframerate(sample_rate)
44+
45+
for i in range(num_samples):
46+
sample = int(32767 * 0.5 * math.sin(2.0 * math.pi * frequency * i / sample_rate))
47+
wf.writeframes(struct.pack("<h", sample))
48+
49+
50+
@pytest.fixture(scope="module")
51+
def runtime_dir():
52+
"""Temporary directory for the FunASR runtime."""
53+
with tempfile.TemporaryDirectory(prefix="funasr_test_") as tmpdir:
54+
yield tmpdir
55+
56+
57+
@pytest.fixture(scope="module")
58+
def test_audio():
59+
"""Generate a temporary test audio file."""
60+
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
61+
_generate_test_wav(f.name, duration_sec=2.0)
62+
yield f.name
63+
Path(f.name).unlink(missing_ok=True)
64+
65+
66+
@pytest.fixture(scope="module")
67+
def client(runtime_dir):
68+
"""Create and start a FunASR client with a real runtime environment."""
69+
asr = FunASR(runtime_dir=runtime_dir)
70+
asr.ensure_installed()
71+
asr.start(timeout=120)
72+
yield asr
73+
asr.stop()
74+
75+
76+
class TestLifecycle:
77+
"""Test installation and server lifecycle."""
78+
79+
def test_ensure_installed(self, runtime_dir):
80+
"""Runtime environment can be installed."""
81+
asr = FunASR(runtime_dir=runtime_dir)
82+
# Should already be installed by the client fixture, or install now
83+
result = asr.ensure_installed()
84+
# True = already installed, False = just installed. Both are ok.
85+
assert isinstance(result, bool)
86+
87+
# Verify files exist
88+
rt = Path(runtime_dir)
89+
assert (rt / ".venv").exists()
90+
assert (rt / "pyproject.toml").exists()
91+
assert (rt / "server.py").exists()
92+
93+
def test_health(self, client):
94+
"""Server responds to health check."""
95+
result = client.health()
96+
assert result["status"] == "ok"
97+
assert isinstance(result["loaded_models"], list)
98+
assert "cuda_available" in result
99+
100+
def test_is_running(self, client):
101+
"""Server process is alive."""
102+
assert client.is_running() is True
103+
104+
105+
class TestVADModel:
106+
"""Test with FSMN-VAD (smallest model, ~36MB)."""
107+
108+
def test_load_vad_model(self, client):
109+
"""Load the FSMN-VAD model."""
110+
result = client.load_model(
111+
model="iic/speech_fsmn_vad_zh-cn-16k-common-pytorch",
112+
name="vad",
113+
)
114+
assert result["status"] == "loaded"
115+
assert result["name"] == "vad"
116+
117+
def test_infer_vad(self, client, test_audio):
118+
"""VAD inference returns speech segments."""
119+
result = client.infer(input=test_audio, name="vad")
120+
121+
assert isinstance(result, list)
122+
assert len(result) > 0
123+
124+
# VAD returns [{"key": ..., "value": [[start_ms, end_ms], ...]}]
125+
first = result[0]
126+
assert "key" in first
127+
assert "value" in first
128+
129+
segments = first["value"]
130+
assert isinstance(segments, list)
131+
# Should detect at least one segment in our 2-second tone
132+
assert len(segments) > 0
133+
134+
# Each segment is [start_ms, end_ms]
135+
for seg in segments:
136+
assert len(seg) == 2
137+
assert seg[0] >= 0
138+
assert seg[1] > seg[0]
139+
140+
def test_infer_vad_with_bytes(self, client, test_audio):
141+
"""VAD inference works with audio bytes input."""
142+
audio_bytes = Path(test_audio).read_bytes()
143+
result = client.infer(input_bytes=audio_bytes, name="vad")
144+
145+
assert isinstance(result, list)
146+
assert len(result) > 0
147+
assert "value" in result[0]
148+
149+
def test_list_models_shows_vad(self, client):
150+
"""Loaded VAD model appears in model list."""
151+
result = client.list_models()
152+
assert "vad" in result["models"]
153+
154+
def test_unload_vad(self, client):
155+
"""Unload VAD model."""
156+
result = client.unload_model(name="vad")
157+
assert result["status"] == "unloaded"
158+
159+
# Verify it's gone
160+
models = client.list_models()
161+
assert "vad" not in models["models"]
162+
163+
164+
class TestExecute:
165+
"""Test arbitrary code execution."""
166+
167+
def test_execute_simple(self, client):
168+
"""Execute simple Python code."""
169+
result = client.execute("result = 1 + 1")
170+
assert result["return_value"] == 2
171+
assert result.get("error") is None
172+
173+
def test_execute_import(self, client):
174+
"""Import and use libraries in the server environment."""
175+
result = client.execute(
176+
"import torch; result = torch.cuda.is_available()"
177+
)
178+
assert result.get("error") is None
179+
assert isinstance(result["return_value"], bool)
180+
181+
def test_execute_with_output(self, client):
182+
"""Capture stdout from executed code."""
183+
result = client.execute("print('hello from server')")
184+
assert "hello from server" in result["output"]
185+
186+
def test_execute_error(self, client):
187+
"""Errors in executed code are reported properly."""
188+
result = client.execute("raise ValueError('test error')")
189+
assert result.get("error") is not None
190+
assert "test error" in result["error"]

0 commit comments

Comments
 (0)