Skip to content

Commit 9ee7955

Browse files
committed
Add integration testing setup with UvicornTestServer
- Introduced UvicornTestServer class for running a uvicorn server in a background thread, allowing for real HTTP endpoint testing without blocking. - Added pytest fixture `running_server` for session-scoped server management, ensuring isolation between tests. - Created README.md with detailed instructions on integration testing approaches and usage examples. - Removed outdated test_basic.py file and added test_health.py to validate the health endpoint using the new server setup.
1 parent 3cffb62 commit 9ee7955

4 files changed

Lines changed: 326 additions & 5 deletions

File tree

integration-tests/README.md

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
# Integration Testing with Uvicorn Server
2+
3+
This directory contains examples of how to properly run and stop uvicorn servers for integration testing. Unlike the problematic approach of using `uvicorn.run()` (which blocks forever), these examples show you how to create real HTTP endpoints that you can send requests to.
4+
5+
## 🚨 The Problem with `uvicorn.run()`
6+
7+
The original code had this issue:
8+
9+
```python
10+
@pytest.mark.asyncio
11+
async def test_basic():
12+
uvicorn.run(app, host="127.0.0.1", port=8000) # ❌ This blocks forever!
13+
async with ClientSession() as session: # ❌ This never executes
14+
# ... test code that never runs
15+
```
16+
17+
**Problem**: `uvicorn.run()` is a blocking call that never returns, so your test code after it never executes.
18+
19+
## ✅ Proper Solutions
20+
21+
### 1. UvicornTestServer Class (Recommended)
22+
23+
The `UvicornTestServer` class in `test_basic.py` provides a clean way to start and stop uvicorn servers:
24+
25+
```python
26+
from test_basic import UvicornTestServer
27+
28+
# Create and start server
29+
server = UvicornTestServer(app, host="127.0.0.1", port=8000)
30+
server.start()
31+
32+
# Make HTTP requests
33+
async with ClientSession() as session:
34+
async with session.get(f"{server.base_url}/health") as response:
35+
assert response.status == 200
36+
37+
# Stop server
38+
server.stop()
39+
```
40+
41+
**Features:**
42+
- ✅ Automatic port detection (avoids conflicts)
43+
- ✅ Proper startup/shutdown lifecycle
44+
- ✅ Thread-based server execution
45+
- ✅ Waits for server to be ready
46+
- ✅ Graceful cleanup
47+
48+
### 2. Pytest Fixtures
49+
50+
Use pytest fixtures for automatic server management:
51+
52+
```python
53+
@pytest.fixture(scope="session")
54+
def running_server():
55+
"""Server shared across all tests in the session."""
56+
server = UvicornTestServer(app)
57+
server.start()
58+
yield server
59+
server.stop()
60+
61+
@pytest.fixture(scope="function")
62+
def fresh_server():
63+
"""Fresh server for each test."""
64+
server = UvicornTestServer(app)
65+
server.start()
66+
yield server
67+
server.stop()
68+
```
69+
70+
### 3. Manual Server Management
71+
72+
For full control over server lifecycle:
73+
74+
```python
75+
@pytest.mark.asyncio
76+
async def test_manual_server():
77+
server = UvicornTestServer(app)
78+
79+
try:
80+
server.start()
81+
# Your test code here
82+
async with ClientSession() as session:
83+
async with session.get(f"{server.base_url}/health") as response:
84+
assert response.status == 200
85+
finally:
86+
server.stop() # Always cleanup
87+
```
88+
89+
## 🏃‍♂️ Running the Examples
90+
91+
### Install Dependencies
92+
93+
```bash
94+
cd integration-tests
95+
uv sync
96+
```
97+
98+
### Run Tests
99+
100+
```bash
101+
# Run all tests
102+
uv run python -m pytest test_basic.py -v
103+
104+
# Run specific test
105+
uv run python -m pytest test_basic.py::test_basic_with_session_server -v -s
106+
107+
# Run with output
108+
uv run python -m pytest test_basic.py -v -s
109+
```
110+
111+
### Run Demo Server
112+
113+
```bash
114+
# Start a server you can send requests to
115+
uv run python demo_server.py
116+
```
117+
118+
Then in another terminal:
119+
```bash
120+
curl http://127.0.0.1:8000/health
121+
curl http://127.0.0.1:8000/test
122+
```
123+
124+
## 📁 File Overview
125+
126+
- **`test_basic.py`** - Main test file with UvicornTestServer class and examples
127+
- **`test_server_examples.py`** - Comprehensive examples of different testing approaches
128+
- **`demo_server.py`** - Simple script to run a server manually
129+
- **`pyproject.toml`** - Project dependencies
130+
131+
## 🎯 When to Use Each Approach
132+
133+
### Session-Scoped Server (`running_server` fixture)
134+
- ✅ Fast test execution (server starts once)
135+
- ✅ Good for multiple tests that don't interfere
136+
- ❌ Tests share state
137+
- **Use for**: Most integration tests
138+
139+
### Function-Scoped Server (`fresh_server` fixture)
140+
- ✅ Complete isolation between tests
141+
- ✅ Clean state for each test
142+
- ❌ Slower (starts server for each test)
143+
- **Use for**: Tests that modify server state
144+
145+
### Manual Server Management
146+
- ✅ Full control over lifecycle
147+
- ✅ Can test server startup/shutdown
148+
- ❌ More verbose
149+
- **Use for**: Complex scenarios, debugging
150+
151+
### Demo Server Script
152+
- ✅ Perfect for development and debugging
153+
- ✅ Can send real HTTP requests
154+
- ✅ Easy to test endpoints manually
155+
- **Use for**: Development, manual testing
156+
157+
## 🔧 Key Features of UvicornTestServer
158+
159+
1. **Automatic Port Detection**: Finds free ports to avoid conflicts
160+
2. **Proper Lifecycle**: Clean startup and shutdown
161+
3. **Thread Safety**: Runs server in background thread
162+
4. **Health Checking**: Waits for server to be ready
163+
5. **Graceful Shutdown**: Proper cleanup on exit
164+
6. **Error Handling**: Robust error handling and timeouts
165+
166+
## 🚀 Making HTTP Requests
167+
168+
Once you have a running server, you can make requests using:
169+
170+
### With aiohttp (async)
171+
```python
172+
async with ClientSession() as session:
173+
async with session.get(f"{server.base_url}/health") as response:
174+
data = await response.json()
175+
assert data["message"] == "OK"
176+
```
177+
178+
### With httpx (async)
179+
```python
180+
async with httpx.AsyncClient() as client:
181+
response = await client.get(f"{server.base_url}/health")
182+
assert response.status_code == 200
183+
```
184+
185+
### With curl (command line)
186+
```bash
187+
curl http://127.0.0.1:8000/health
188+
curl -X POST http://127.0.0.1:8000/api/data -H "Content-Type: application/json" -d '{"key": "value"}'
189+
```
190+
191+
### With requests (sync)
192+
```python
193+
import requests
194+
response = requests.get(f"{server.base_url}/health")
195+
assert response.status_code == 200
196+
```
197+
198+
## 🎉 Success!
199+
200+
Now you have a proper way to run uvicorn servers for integration testing that:
201+
- ✅ Actually starts and stops properly
202+
- ✅ Provides real HTTP endpoints
203+
- ✅ Handles cleanup automatically
204+
- ✅ Avoids port conflicts
205+
- ✅ Works reliably in CI/CD
206+
207+
No more blocking `uvicorn.run()` calls or tests that never execute!

integration-tests/conftest.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import pytest
2+
import asyncio
3+
import threading
4+
import time
5+
import uvicorn
6+
from app.main import app
7+
8+
9+
class UvicornTestServer:
10+
"""
11+
A proper uvicorn server for integration testing with real HTTP endpoints.
12+
This runs the server in a background thread with proper startup and shutdown.
13+
"""
14+
15+
def __init__(self, app, host="127.0.0.1", port=None):
16+
self.app = app
17+
self.host = host
18+
self.port = port or self._find_free_port()
19+
self.server = None
20+
self.thread = None
21+
self.started = False
22+
23+
def _find_free_port(self):
24+
"""Find a free port to avoid conflicts."""
25+
import socket
26+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
27+
s.bind(('', 0))
28+
s.listen(1)
29+
port = s.getsockname()[1]
30+
return port
31+
32+
def start(self):
33+
"""Start the uvicorn server in a background thread."""
34+
if self.started:
35+
return
36+
37+
config = uvicorn.Config(
38+
app=self.app,
39+
host=self.host,
40+
port=self.port,
41+
log_level="info",
42+
access_log=True
43+
)
44+
self.server = uvicorn.Server(config)
45+
46+
def run_server():
47+
"""Run the server in the thread."""
48+
assert self.server is not None
49+
asyncio.run(self.server.serve())
50+
51+
self.thread = threading.Thread(target=run_server, daemon=True)
52+
self.thread.start()
53+
54+
# Wait for server to be ready
55+
self._wait_for_server()
56+
self.started = True
57+
print(f"🚀 Server started on http://{self.host}:{self.port}")
58+
59+
def stop(self):
60+
"""Stop the server gracefully."""
61+
if not self.started:
62+
return
63+
64+
print("🛑 Stopping server...")
65+
if self.server:
66+
self.server.should_exit = True
67+
68+
if self.thread and self.thread.is_alive():
69+
self.thread.join(timeout=10)
70+
71+
self.started = False
72+
print("✅ Server stopped")
73+
74+
def _wait_for_server(self, timeout=30):
75+
"""Wait for the server to accept connections."""
76+
import socket
77+
start_time = time.time()
78+
79+
while time.time() - start_time < timeout:
80+
try:
81+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
82+
sock.settimeout(1)
83+
result = sock.connect_ex((self.host, self.port))
84+
if result == 0:
85+
return
86+
except Exception:
87+
pass
88+
time.sleep(0.2)
89+
90+
raise RuntimeError(f"Server failed to start within {timeout} seconds")
91+
92+
@property
93+
def base_url(self):
94+
"""Get the base URL of the running server."""
95+
return f"http://{self.host}:{self.port}"
96+
97+
98+
@pytest.fixture(scope="session")
99+
def running_server():
100+
"""
101+
Session-scoped fixture that provides a fresh server for each test.
102+
Use this when you need isolation between tests.
103+
"""
104+
server = UvicornTestServer(app)
105+
server.start()
106+
yield server
107+
server.stop()
108+

integration-tests/test_basic.py

Lines changed: 0 additions & 5 deletions
This file was deleted.

integration-tests/test_health.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import pytest
2+
from aiohttp import ClientSession
3+
4+
@pytest.mark.asyncio
5+
async def test_health_endpoint(running_server):
6+
"""Test using the session-scoped server (shared across tests)."""
7+
async with ClientSession() as session:
8+
async with session.get(f"{running_server.base_url}/health") as response:
9+
assert response.status == 200
10+
data = await response.json()
11+
assert data["message"] == "OK"

0 commit comments

Comments
 (0)