Skip to content

Commit 8cb1ec0

Browse files
authored
Backend: Add unit tests (#163)
* add unit-tests to CI * remove unused readme * test coverage 69.46 * update unit tests prereqs * improve CI speed - just pull from HF * install hf-cli --------- Signed-off-by: Jack Luar <[email protected]>
1 parent 1dafd4f commit 8cb1ec0

26 files changed

+5056
-3
lines changed

.github/workflows/ci-secret.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ jobs:
3232
- name: Run formatting checks
3333
run: |
3434
make check
35+
- name: Run unit tests
36+
working-directory: backend
37+
run: |
38+
pip install huggingface_hub[cli]
39+
huggingface-cli download --repo-type dataset The-OpenROAD-Project/ORAssistant_RAG_Dataset --include source_list.json --local-dir data/
40+
export GOOGLE_API_KEY="dummy-unit-test-key"
41+
make test
3542
- name: Populate environment variables
3643
run: |
3744
cp backend/.env.example backend/.env

.github/workflows/ci.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ jobs:
3030
- name: Run formatting checks
3131
run: |
3232
make check
33+
- name: Run unit tests
34+
working-directory: backend
35+
run: |
36+
pip install huggingface_hub[cli]
37+
huggingface-cli download --repo-type dataset The-OpenROAD-Project/ORAssistant_RAG_Dataset --include source_list.json --local-dir data/
38+
export GOOGLE_API_KEY="dummy-unit-test-key"
39+
make test
3340
- name: Build Docker images
3441
run: |
3542
docker compose build

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,8 @@ faiss_db
3939
# frontend
4040
node_modules
4141
.next
42+
43+
# coverage
44+
coverage.xml
45+
report.html
46+
.coverage

backend/Makefile

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,21 @@ init-dev: init
1313
.PHONY: format
1414
format:
1515
@. .venv/bin/activate && \
16-
ruff format
16+
ruff format && \
17+
ruff check --fix
1718

1819
.PHONY: check
1920
check:
2021
@. .venv/bin/activate && \
2122
mypy . && \
2223
ruff check
24+
25+
.PHONY: build-docs
26+
build-docs:
27+
@. .venv/bin/activate && \
28+
python build_docs.py
29+
30+
.PHONY: test
31+
test:
32+
@. .venv/bin/activate && \
33+
pytest

backend/pyproject.toml

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,11 @@ exclude = [
7171
]
7272
line-length = 88
7373
indent-width = 4
74-
target-version = "py310"
74+
target-version = "py312"
7575

7676
[tool.ruff.lint]
7777
select = ["E4", "E7", "E9","E301","E304","E305","E401","E223","E224","E242", "E", "F" ,"N", "W", "C90"]
78-
extend-select = ["D203", "D204"]
78+
extend-select = ["D204"]
7979
ignore = ["E501"]
8080
preview = true
8181

@@ -93,3 +93,67 @@ skip-magic-trailing-comma = false
9393
line-ending = "auto"
9494
docstring-code-format = false
9595
docstring-code-line-length = "dynamic"
96+
97+
[tool.pytest.ini_options]
98+
testpaths = ["tests"]
99+
python_files = ["test_*.py", "*_test.py"]
100+
python_classes = ["Test*"]
101+
python_functions = ["test_*"]
102+
addopts = [
103+
"--cov=src",
104+
"--cov-report=html:htmlcov",
105+
"--cov-report=term-missing",
106+
"--cov-report=xml",
107+
"--cov-fail-under=40",
108+
"--strict-markers",
109+
"--strict-config",
110+
"--html=reports/report.html",
111+
"--self-contained-html",
112+
"-v"
113+
]
114+
markers = [
115+
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
116+
"integration: marks tests as integration tests",
117+
"unit: marks tests as unit tests"
118+
]
119+
filterwarnings = [
120+
"ignore::DeprecationWarning",
121+
"ignore::PendingDeprecationWarning"
122+
]
123+
asyncio_mode = "auto"
124+
125+
[tool.coverage.run]
126+
source = ["src"]
127+
omit = [
128+
"*/tests/*",
129+
"*/test_*",
130+
"*/__pycache__/*",
131+
"*/venv/*",
132+
"*/env/*",
133+
"*/.venv/*",
134+
"*/site-packages/*",
135+
"*/migrations/*",
136+
"*/post_install.py",
137+
"*/secret.json"
138+
]
139+
140+
[tool.coverage.report]
141+
exclude_lines = [
142+
"pragma: no cover",
143+
"def __repr__",
144+
"if self.debug:",
145+
"if settings.DEBUG",
146+
"raise AssertionError",
147+
"raise NotImplementedError",
148+
"if 0:",
149+
"if __name__ == .__main__.:",
150+
"class .*\\bProtocol\\):",
151+
"@(abc\\.)?abstractmethod"
152+
]
153+
154+
[tool.coverage.html]
155+
directory = "htmlcov"
156+
title = "ORAssistant Backend Coverage Report"
157+
158+
[tool.coverage.xml]
159+
output = "coverage.xml"

backend/requirements-test.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,8 @@ types-tqdm==4.66.0.20240417
66
types-beautifulsoup4==4.12.0.20240511
77
ruff==0.5.1
88
pre-commit==3.7.1
9+
pytest==8.3.2
10+
pytest-cov==5.0.0
11+
pytest-html==4.1.1
12+
pytest-xdist==3.6.0
13+
pytest-asyncio==0.23.8

backend/tests/conftest.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import pytest
2+
import sys
3+
from pathlib import Path
4+
from unittest.mock import Mock, patch
5+
import tempfile
6+
import os
7+
8+
9+
@pytest.fixture(scope="session")
10+
def test_data_dir():
11+
"""Get test data directory path."""
12+
return Path(__file__).parent / "data"
13+
14+
15+
@pytest.fixture
16+
def mock_openai_client():
17+
"""Mock OpenAI client for testing."""
18+
with patch("openai.OpenAI") as mock_client:
19+
mock_instance = Mock()
20+
mock_client.return_value = mock_instance
21+
yield mock_instance
22+
23+
24+
@pytest.fixture
25+
def mock_langchain_llm():
26+
"""Mock LangChain LLM for testing."""
27+
with patch("langchain_openai.ChatOpenAI") as mock_llm:
28+
mock_instance = Mock()
29+
mock_llm.return_value = mock_instance
30+
yield mock_instance
31+
32+
33+
@pytest.fixture
34+
def mock_faiss_vectorstore():
35+
"""Mock FAISS vectorstore for testing."""
36+
with patch("langchain_community.vectorstores.FAISS") as mock_faiss:
37+
mock_instance = Mock()
38+
mock_faiss.return_value = mock_instance
39+
yield mock_instance
40+
41+
42+
@pytest.fixture
43+
def temp_dir():
44+
"""Create a temporary directory for tests."""
45+
with tempfile.TemporaryDirectory() as temp_dir:
46+
yield Path(temp_dir)
47+
48+
49+
@pytest.fixture
50+
def sample_documents():
51+
"""Sample documents for testing."""
52+
return [
53+
{
54+
"content": "This is a sample document about OpenROAD installation.",
55+
"metadata": {"source": "installation.md", "category": "installation"},
56+
},
57+
{
58+
"content": "This document explains OpenROAD flow configuration.",
59+
"metadata": {"source": "flow.md", "category": "configuration"},
60+
},
61+
]
62+
63+
64+
@pytest.fixture
65+
def mock_env_vars():
66+
"""Mock environment variables for testing."""
67+
env_vars = {
68+
"OPENAI_API_KEY": "test-key",
69+
"GOOGLE_API_KEY": "test-google-key",
70+
"HUGGINGFACE_API_KEY": "test-hf-key",
71+
}
72+
73+
with patch.dict(os.environ, env_vars):
74+
yield env_vars
75+
76+
77+
@pytest.fixture(autouse=True)
78+
def setup_test_environment():
79+
"""Set up test environment before each test."""
80+
# Add src directory to Python path
81+
src_path = Path(__file__).parent.parent / "src"
82+
if str(src_path) not in sys.path:
83+
sys.path.insert(0, str(src_path))
84+
85+
yield
86+
87+
# Cleanup after test
88+
if str(src_path) in sys.path:
89+
sys.path.remove(str(src_path))

backend/tests/data/sample.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# OpenROAD Test Document
2+
3+
This is a sample markdown document for testing purposes.
4+
5+
## Installation
6+
7+
OpenROAD can be installed using the following methods:
8+
9+
1. Build from source
10+
2. Use Docker container
11+
3. Install pre-built binaries
12+
13+
## Configuration
14+
15+
Configure OpenROAD using the following commands:
16+
17+
```tcl
18+
set_design_name "my_design"
19+
set_top_module "top"
20+
```
21+
22+
## Flow
23+
24+
The OpenROAD flow consists of several stages:
25+
26+
- Synthesis
27+
- Floorplanning
28+
- Placement
29+
- Clock Tree Synthesis
30+
- Routing
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import pytest
2+
from fastapi.testclient import TestClient
3+
from fastapi import FastAPI
4+
5+
from src.api.routers.healthcheck import router, HealthCheckResponse
6+
7+
8+
class TestHealthCheckAPI:
9+
"""Test suite for healthcheck API endpoints."""
10+
11+
@pytest.fixture
12+
def app(self):
13+
"""Create FastAPI application with healthcheck router."""
14+
app = FastAPI()
15+
app.include_router(router)
16+
return app
17+
18+
@pytest.fixture
19+
def client(self, app):
20+
"""Create test client."""
21+
return TestClient(app)
22+
23+
def test_healthcheck_endpoint_success(self, client):
24+
"""Test healthcheck endpoint returns success response."""
25+
response = client.get("/healthcheck")
26+
27+
assert response.status_code == 200
28+
assert response.json() == {"status": "ok"}
29+
30+
def test_healthcheck_response_model(self):
31+
"""Test HealthCheckResponse model."""
32+
response = HealthCheckResponse(status="ok")
33+
34+
assert response.status == "ok"
35+
assert response.model_dump() == {"status": "ok"}
36+
37+
def test_healthcheck_response_model_validation(self):
38+
"""Test HealthCheckResponse model validation."""
39+
# Test with valid status
40+
response = HealthCheckResponse(status="healthy")
41+
assert response.status == "healthy"
42+
43+
# Test with empty status
44+
response = HealthCheckResponse(status="")
45+
assert response.status == ""
46+
47+
@pytest.mark.integration
48+
def test_healthcheck_endpoint_headers(self, client):
49+
"""Test healthcheck endpoint response headers."""
50+
response = client.get("/healthcheck")
51+
52+
assert response.status_code == 200
53+
assert "application/json" in response.headers.get("content-type", "")
54+
55+
def test_healthcheck_endpoint_multiple_requests(self, client):
56+
"""Test healthcheck endpoint handles multiple requests."""
57+
for _ in range(5):
58+
response = client.get("/healthcheck")
59+
assert response.status_code == 200
60+
assert response.json() == {"status": "ok"}
61+
62+
@pytest.mark.unit
63+
@pytest.mark.asyncio
64+
async def test_healthcheck_function_direct_call(self):
65+
"""Test healthcheck function can be called directly."""
66+
from src.api.routers.healthcheck import healthcheck
67+
68+
result = await healthcheck()
69+
70+
assert isinstance(result, HealthCheckResponse)
71+
assert result.status == "ok"

0 commit comments

Comments
 (0)