Skip to content

Commit 619c6bc

Browse files
committed
add tests
1 parent fc6d2a1 commit 619c6bc

File tree

6 files changed

+306
-1
lines changed

6 files changed

+306
-1
lines changed

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,7 @@ generated-values.yaml
8989
TensorRT-LLM
9090

9191
# Local build artifacts for devcontainer
92-
.build/
92+
.build/
93+
94+
# Pytest
95+
.coverage
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Dynamo vLLM Backend Tests
2+
3+
This directory contains unit tests for the Dynamo vLLM backend components.
4+
5+
## Running Tests
6+
7+
### Run all tests
8+
```bash
9+
cd components/backends/vllm/src/dynamo/tests
10+
python -m pytest
11+
```
12+
13+
### Run specific test file
14+
```bash
15+
python -m pytest test_ports.py
16+
```
17+
18+
### Run with coverage
19+
```bash
20+
python -m pytest --cov=dynamo.vllm.ports --cov-report=term
21+
```
22+
23+
### Run specific test class or method
24+
```bash
25+
python -m pytest test_ports.py::TestPortBinding
26+
python -m pytest test_ports.py::TestPortBinding::test_single_port_binding
27+
```
28+
29+
## Dependencies
30+
31+
The tests require:
32+
- `pytest` - Test framework
33+
- `pytest-asyncio` - For async test support
34+
- `pytest-cov` - For coverage reports (optional)
35+
36+
Install with:
37+
```bash
38+
pip install pytest pytest-asyncio pytest-cov
39+
```
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Tests for dynamo vllm backend components."""
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
; [pytest]
2+
; # Pytest configuration for dynamo vllm tests
3+
4+
; testpaths = .
5+
; python_files = test_*.py
6+
; python_classes = Test*
7+
; python_functions = test_*
8+
9+
; # Add parent directory to Python path for imports
10+
; pythonpath = ../..
11+
12+
; # Markers for async tests
13+
; markers =
14+
; asyncio: marks tests as async (deselect with '-m "not asyncio"')
15+
16+
; # Test output options
17+
; addopts =
18+
; -v
19+
; --tb=short
20+
; --strict-markers
21+
; --disable-warnings
22+
23+
; # Async test configuration
24+
; asyncio_mode = auto
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Tests for port allocation and management utilities."""
5+
6+
import json
7+
import socket
8+
from unittest.mock import AsyncMock, MagicMock, Mock, patch
9+
10+
import pytest
11+
12+
from dynamo.vllm.ports import (
13+
DynamoPortRange,
14+
EtcdContext,
15+
PortAllocationRequest,
16+
PortMetadata,
17+
allocate_and_reserve_port,
18+
allocate_and_reserve_port_block,
19+
check_port_available,
20+
get_host_ip,
21+
hold_ports,
22+
reserve_port_in_etcd,
23+
)
24+
25+
26+
class TestDynamoPortRange:
27+
"""Test DynamoPortRange validation."""
28+
29+
def test_valid_port_range(self):
30+
"""Test creating a valid port range."""
31+
port_range = DynamoPortRange(min=2000, max=3000)
32+
assert port_range.min == 2000
33+
assert port_range.max == 3000
34+
35+
def test_port_range_outside_registered_range(self):
36+
"""Test that port ranges outside 1024-49151 are rejected."""
37+
with pytest.raises(ValueError, match="outside registered ports range"):
38+
DynamoPortRange(min=500, max=2000)
39+
40+
with pytest.raises(ValueError, match="outside registered ports range"):
41+
DynamoPortRange(min=2000, max=50000)
42+
43+
def test_invalid_port_range_min_greater_than_max(self):
44+
"""Test that min >= max is rejected."""
45+
with pytest.raises(ValueError, match="min .* must be less than max"):
46+
DynamoPortRange(min=3000, max=2000)
47+
48+
with pytest.raises(ValueError, match="min .* must be less than max"):
49+
DynamoPortRange(min=3000, max=3000)
50+
51+
52+
class TestPortMetadata:
53+
"""Test port metadata functionality."""
54+
55+
def test_to_etcd_value_with_block_info(self):
56+
"""Test converting metadata to ETCD value with block info."""
57+
metadata = PortMetadata(
58+
worker_id="test-worker",
59+
reason="test-reason",
60+
block_info={"block_index": 0, "block_size": 4, "block_start": 8080},
61+
)
62+
63+
value = metadata.to_etcd_value()
64+
assert value["block_index"] == 0
65+
assert value["block_size"] == 4
66+
assert value["block_start"] == 8080
67+
68+
69+
class TestHoldPorts:
70+
"""Test hold_ports context manager."""
71+
72+
def test_hold_single_port(self):
73+
"""Test holding a single port."""
74+
with socket.socket() as s:
75+
s.bind(("", 0))
76+
port = s.getsockname()[1]
77+
78+
with hold_ports(port):
79+
assert not check_port_available(port)
80+
81+
# Port should be released after context exit
82+
assert check_port_available(port)
83+
84+
def test_hold_multiple_ports(self):
85+
"""Test holding multiple ports."""
86+
ports = []
87+
for _ in range(2):
88+
with socket.socket() as s:
89+
s.bind(("", 0))
90+
ports.append(s.getsockname()[1])
91+
92+
with hold_ports(ports):
93+
for port in ports:
94+
assert not check_port_available(port)
95+
96+
# All ports should be released after context exit
97+
for port in ports:
98+
assert check_port_available(port)
99+
100+
101+
class TestReservePortInEtcd:
102+
"""Test ETCD port reservation."""
103+
104+
@pytest.mark.asyncio
105+
async def test_reserve_port_success(self):
106+
"""Test successful port reservation in ETCD."""
107+
mock_client = AsyncMock()
108+
mock_client.primary_lease_id = Mock(return_value="test-lease-123")
109+
110+
context = EtcdContext(client=mock_client, namespace="test-ns")
111+
metadata = PortMetadata(worker_id="test-worker", reason="test")
112+
113+
host_ip = get_host_ip()
114+
await reserve_port_in_etcd(context, 8080, metadata)
115+
116+
mock_client.kv_create.assert_called_once()
117+
call_args = mock_client.kv_create.call_args
118+
119+
assert call_args.kwargs["key"] == f"dyn://test-ns/ports/{host_ip}/8080"
120+
assert call_args.kwargs["lease_id"] == "test-lease-123"
121+
122+
# Check the value is valid JSON
123+
value_bytes = call_args.kwargs["value"]
124+
value_dict = json.loads(value_bytes.decode())
125+
assert value_dict["worker_id"] == "test-worker"
126+
assert value_dict["reason"] == "test"
127+
128+
129+
class TestAllocateAndReservePort:
130+
"""Test single port allocation."""
131+
132+
@pytest.mark.asyncio
133+
async def test_allocate_single_port_success(self):
134+
"""Test successful single port allocation."""
135+
mock_client = AsyncMock()
136+
mock_client.primary_lease_id = Mock(return_value="test-lease")
137+
138+
context = EtcdContext(client=mock_client, namespace="test-ns")
139+
metadata = PortMetadata(worker_id="test-worker", reason="test")
140+
port_range = DynamoPortRange(min=20000, max=20010)
141+
142+
# Mock that all ports are available
143+
with patch("dynamo.vllm.ports.check_port_available", return_value=True):
144+
with patch("dynamo.vllm.ports.hold_ports") as mock_hold:
145+
# Set up the context manager mock
146+
mock_hold.return_value.__enter__ = Mock()
147+
mock_hold.return_value.__exit__ = Mock(return_value=None)
148+
149+
port = await allocate_and_reserve_port(
150+
context, metadata, port_range, max_attempts=5
151+
)
152+
153+
assert 20000 <= port <= 20010
154+
mock_client.kv_create.assert_called_once()
155+
156+
157+
class TestAllocateAndReservePortBlock:
158+
"""Test port block allocation."""
159+
160+
@pytest.mark.asyncio
161+
async def test_allocate_block_success(self):
162+
"""Test successful port block allocation."""
163+
mock_client = AsyncMock()
164+
mock_client.primary_lease_id = Mock(return_value="test-lease")
165+
166+
context = EtcdContext(client=mock_client, namespace="test-ns")
167+
metadata = PortMetadata(worker_id="test-worker", reason="test")
168+
port_range = DynamoPortRange(min=20000, max=20010)
169+
170+
request = PortAllocationRequest(
171+
etcd_context=context,
172+
metadata=metadata,
173+
port_range=port_range,
174+
block_size=3,
175+
max_attempts=5,
176+
)
177+
178+
with patch("dynamo.vllm.ports.hold_ports") as mock_hold:
179+
# Set up the context manager mock
180+
mock_hold.return_value.__enter__ = Mock()
181+
mock_hold.return_value.__exit__ = Mock(return_value=None)
182+
183+
ports = await allocate_and_reserve_port_block(request)
184+
185+
assert len(ports) == 3
186+
assert all(20000 <= p <= 20010 for p in ports)
187+
assert ports == list(range(ports[0], ports[0] + 3))
188+
189+
# Should have reserved 3 ports
190+
assert mock_client.kv_create.call_count == 3
191+
192+
@pytest.mark.asyncio
193+
async def test_allocate_block_port_range_too_small(self):
194+
"""Test error when port range is too small for block."""
195+
context = EtcdContext(client=Mock(), namespace="test-ns")
196+
metadata = PortMetadata(worker_id="test-worker", reason="test")
197+
port_range = DynamoPortRange(min=20000, max=20002)
198+
199+
request = PortAllocationRequest(
200+
etcd_context=context,
201+
metadata=metadata,
202+
port_range=port_range,
203+
block_size=5, # Needs 5 ports but range only has 3
204+
)
205+
206+
with pytest.raises(
207+
ValueError, match="Port range .* is too small for block size"
208+
):
209+
await allocate_and_reserve_port_block(request)
210+
211+
212+
class TestGetHostIP:
213+
"""Test get_host_ip function."""
214+
215+
def test_get_host_ip_success(self):
216+
"""Test successful host IP retrieval."""
217+
with patch("socket.gethostname", return_value="test-host"):
218+
with patch("socket.gethostbyname", return_value="192.168.1.100"):
219+
with patch("socket.socket") as mock_socket_class:
220+
# Mock successful bind
221+
mock_socket = MagicMock()
222+
mock_socket_class.return_value.__enter__.return_value = mock_socket
223+
224+
ip = get_host_ip()
225+
assert ip == "192.168.1.100"
226+
227+
228+
if __name__ == "__main__":
229+
pytest.main([__file__, "-v"])

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,12 @@ indent = " "
132132
skip = ["build"]
133133
known_first_party = ["dynamo"]
134134

135+
[pytest]
136+
pythonpath = [
137+
".",
138+
"components/backends/vlm/src"
139+
]
140+
135141
[tool.pytest.ini_options]
136142
minversion = "8.0"
137143
tmp_path_retention_policy = "failed"

0 commit comments

Comments
 (0)