Skip to content

Commit c0dc08b

Browse files
authored
Add basic command tests (#18)
* Add basic command tests * Add 'mcp' configuration option to smoke test
1 parent 3919038 commit c0dc08b

File tree

3 files changed

+569
-0
lines changed

3 files changed

+569
-0
lines changed

tests/README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# dspy-cli Test Suite
2+
3+
## Test Coverage
4+
5+
This test suite ensures the CLI commands work correctly and continue to work.
6+
7+
### Serve Integration Tests (`test_serve_integration.py`)
8+
9+
Real server tests without making LLM calls.
10+
11+
Done by creating a module that doesn't have any LLM calls in it.
12+
13+
## Test Strategy
14+
15+
Tests avoid LLM dependencies by using a dummy `Echo` module that returns deterministic results without calling LLMs
16+
17+
Otherwise they use:
18+
19+
- Using FastAPI's `TestClient` to make real HTTP requests to test endpoints
20+
- Stubbing `uvicorn.run` only for runner orchestration tests
21+
- Everything else is real - actual server, routes, discovery, OpenAPI generation
22+
- Use Click's `CliRunner` for CLI testing
23+
- Use pytest fixtures for temp projects and configs
24+
25+
## Running Tests
26+
27+
```bash
28+
# Run all tests
29+
uv run pytest
30+
```
31+
32+
## Maintenance
33+
34+
When adding new CLI commands:
35+
1. Add smoke test to `test_commands_smoke.py`
36+
2. Keep tests simple - something is better than nothing
37+
3. Focus on happy paths and basic validation
38+
39+
When modifying serve behavior:
40+
1. Add integration test to `test_serve_integration.py`
41+
2. Use the `test_config` and `temp_project` fixtures
42+
3. Stub external dependencies (uvicorn, dspy.LM, etc.)

tests/test_commands_smoke.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""Smoke tests for CLI commands: new, generate, serve."""
2+
3+
import os
4+
from pathlib import Path
5+
6+
import pytest
7+
from click.testing import CliRunner
8+
9+
from dspy_cli.cli import main
10+
11+
12+
@pytest.fixture
13+
def runner():
14+
"""Create Click CLI test runner."""
15+
return CliRunner()
16+
17+
18+
@pytest.fixture
19+
def tmp_cwd(tmp_path, monkeypatch):
20+
"""Change to temp directory for test, restore after."""
21+
old = os.getcwd()
22+
os.chdir(tmp_path)
23+
try:
24+
yield tmp_path
25+
finally:
26+
os.chdir(old)
27+
28+
29+
def test_cli_e2e_smoke(runner, tmp_cwd, monkeypatch):
30+
"""End-to-end smoke test: new -> generate -> serve.
31+
32+
Creates project, generates components, validates serve args.
33+
"""
34+
# 1. Test 'new' command
35+
res = runner.invoke(main, ["new", "acme-app"], catch_exceptions=False)
36+
assert res.exit_code == 0
37+
38+
proj = tmp_cwd / "acme-app"
39+
40+
# Verify config files created
41+
for name in ["pyproject.toml", "dspy.config.yaml", "Dockerfile", ".dockerignore", ".env", "README.md", ".gitignore"]:
42+
assert (proj / name).exists(), f"Missing {name}"
43+
44+
# Verify code structure created
45+
assert (proj / "src" / "acme_app" / "modules" / "acme_app_predict.py").exists()
46+
assert (proj / "src" / "acme_app" / "signatures" / "acme_app.py").exists()
47+
assert (proj / "tests" / "test_modules.py").exists()
48+
49+
# 2. Test 'generate scaffold' command
50+
os.chdir(proj)
51+
res = runner.invoke(
52+
main,
53+
["g", "scaffold", "categorizer", "-m", "CoT", "-s", "question -> answer"],
54+
catch_exceptions=False
55+
)
56+
assert res.exit_code == 0
57+
assert (proj / "src" / "acme_app" / "signatures" / "categorizer.py").exists()
58+
assert (proj / "src" / "acme_app" / "modules" / "categorizer_cot.py").exists()
59+
60+
# 3. Test 'generate signature' command
61+
res = runner.invoke(
62+
main,
63+
["g", "signature", "tags", "-s", "post -> tags: list[str]"],
64+
catch_exceptions=False
65+
)
66+
assert res.exit_code == 0
67+
assert (proj / "src" / "acme_app" / "signatures" / "tags.py").exists()
68+
69+
# 4. Test 'generate module' command
70+
res = runner.invoke(
71+
main,
72+
["g", "module", "my_mod", "-m", "Predict"],
73+
catch_exceptions=False
74+
)
75+
assert res.exit_code == 0
76+
assert (proj / "src" / "acme_app" / "modules" / "my_mod_predict.py").exists()
77+
78+
# 5. Test 'serve' command (stubbed to avoid starting actual server)
79+
calls = {}
80+
81+
def fake_runner_main(**kwargs):
82+
calls.update(kwargs)
83+
84+
monkeypatch.setattr("dspy_cli.commands.serve.runner_main", fake_runner_main)
85+
86+
res = runner.invoke(
87+
main,
88+
[
89+
"serve",
90+
"--system",
91+
"--host", "127.0.0.1",
92+
"--port", "8765",
93+
"--no-reload",
94+
"--openapi-format", "yaml",
95+
"--logs-dir", "logs",
96+
"--ui"
97+
],
98+
catch_exceptions=False
99+
)
100+
assert res.exit_code == 0
101+
102+
# Verify serve received correct arguments
103+
assert calls == {
104+
"port": 8765,
105+
"host": "127.0.0.1",
106+
"logs_dir": "logs",
107+
"ui": True,
108+
"reload": False,
109+
"save_openapi": True,
110+
"openapi_format": "yaml",
111+
"mcp": False,
112+
}
113+
114+
115+
def test_new_with_signature(runner, tmp_cwd):
116+
"""Test 'new' command with custom signature."""
117+
res = runner.invoke(
118+
main,
119+
["new", "my-project", "-p", "analyzer", "-s", "text, context: list[str] -> summary"],
120+
catch_exceptions=False
121+
)
122+
assert res.exit_code == 0
123+
124+
proj = tmp_cwd / "my-project"
125+
assert (proj / "src" / "my_project" / "modules" / "analyzer_predict.py").exists()
126+
assert (proj / "src" / "my_project" / "signatures" / "analyzer.py").exists()
127+
128+
# Verify signature has correct fields
129+
sig_content = (proj / "src" / "my_project" / "signatures" / "analyzer.py").read_text()
130+
assert "text: str = dspy.InputField" in sig_content
131+
assert "context: list[str] = dspy.InputField" in sig_content
132+
assert "summary: str = dspy.OutputField" in sig_content
133+
134+
135+
def test_generate_different_module_types(runner, tmp_cwd):
136+
"""Test generating different module types."""
137+
# Create a project first
138+
res = runner.invoke(main, ["new", "test-app"], catch_exceptions=False)
139+
assert res.exit_code == 0
140+
141+
proj = tmp_cwd / "test-app"
142+
os.chdir(proj)
143+
144+
# Test different module types
145+
test_cases = [
146+
("react_mod", "ReAct", "react_mod_react.py"),
147+
("pot_mod", "PoT", "pot_mod_pot.py"),
148+
("refine_mod", "Refine", "refine_mod_refine.py"),
149+
]
150+
151+
for prog_name, mod_type, expected_file in test_cases:
152+
res = runner.invoke(
153+
main,
154+
["g", "module", prog_name, "-m", mod_type],
155+
catch_exceptions=False
156+
)
157+
assert res.exit_code == 0
158+
assert (proj / "src" / "test_app" / "modules" / expected_file).exists()
159+
160+
161+
def test_new_invalid_name(runner, tmp_cwd):
162+
"""Test 'new' command rejects invalid project names."""
163+
# Empty name
164+
res = runner.invoke(main, ["new", ""], catch_exceptions=False)
165+
assert res.exit_code != 0
166+
167+
# Program name starting with digit should fail
168+
res = runner.invoke(main, ["new", "valid-proj", "-p", "1invalid"], catch_exceptions=False)
169+
assert res.exit_code != 0
170+
assert "not a valid Python identifier" in res.output
171+
172+
173+
def test_generate_outside_project(runner, tmp_cwd):
174+
"""Test 'generate' commands fail outside a DSPy project."""
175+
# Try to generate without being in a project
176+
res = runner.invoke(main, ["g", "scaffold", "test"], catch_exceptions=False)
177+
assert res.exit_code != 0
178+
assert "Not in a valid DSPy project" in res.output

0 commit comments

Comments
 (0)