Skip to content

Commit 154e945

Browse files
authored
Merge pull request #2 from mongodb-developer/add-tests-and-ci
test: add runtime tests, integration tests, and CI with local MongoDB
2 parents c3f5c25 + 255ae1d commit 154e945

7 files changed

Lines changed: 336 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
workflow_dispatch:
7+
8+
jobs:
9+
smoke:
10+
runs-on: ubuntu-latest
11+
12+
services:
13+
mongodb:
14+
image: mongo:latest
15+
options: >-
16+
--health-cmd mongosh
17+
--health-interval 10s
18+
--health-timeout 5s
19+
--health-retries 5
20+
ports:
21+
- 27017:27017
22+
env:
23+
MONGO_INITDB_ROOT_USERNAME: admin
24+
MONGO_INITDB_ROOT_PASSWORD: mongodb
25+
26+
steps:
27+
- name: Checkout
28+
uses: actions/checkout@v4
29+
30+
- name: Set up Python
31+
uses: actions/setup-python@v5
32+
with:
33+
python-version: "3.12"
34+
35+
- name: Set up Node
36+
uses: actions/setup-node@v4
37+
with:
38+
node-version: "20"
39+
40+
- name: Repo smoke checks
41+
shell: bash
42+
run: |
43+
set -euo pipefail
44+
45+
echo "Validating repository structure and basic runnable signals"
46+
47+
has_signal=0
48+
49+
if find . -maxdepth 4 -type f -name "package.json" | grep -q .; then
50+
has_signal=1
51+
while IFS= read -r pkg; do
52+
[ -z "$pkg" ] && continue
53+
node -e "const fs=require('fs'); JSON.parse(fs.readFileSync(process.argv[1],'utf8'));" "$pkg"
54+
done < <(find . -maxdepth 4 -type f -name "package.json")
55+
fi
56+
57+
if find . -maxdepth 4 -type f \( -name "pyproject.toml" -o -name "requirements.txt" -o -name "setup.py" -o -name "manage.py" \) | grep -q .; then
58+
has_signal=1
59+
fi
60+
61+
if find . -maxdepth 4 -type f \( -name "app.py" -o -name "main.py" -o -name "wsgi.py" -o -name "asgi.py" \) | grep -q .; then
62+
has_signal=1
63+
fi
64+
65+
if find . -maxdepth 4 -type f \( -name "pom.xml" -o -name "build.gradle" -o -name "build.gradle.kts" -o -name "gradlew" \) | grep -q .; then
66+
has_signal=1
67+
fi
68+
69+
if find . -maxdepth 4 -type f -name "go.mod" | grep -q .; then
70+
has_signal=1
71+
fi
72+
73+
if find . -maxdepth 4 -type f -name "Cargo.toml" | grep -q .; then
74+
has_signal=1
75+
fi
76+
77+
if find . -maxdepth 4 -type f \( -name "*.csproj" -o -name "*.sln" \) | grep -q .; then
78+
has_signal=1
79+
fi
80+
81+
if find . -maxdepth 4 -type f \( -name "Dockerfile" -o -name "docker-compose.yml" -o -name "docker-compose.yaml" \) | grep -q .; then
82+
has_signal=1
83+
fi
84+
85+
if find . -maxdepth 4 -type f -name "Makefile" | grep -q .; then
86+
has_signal=1
87+
fi
88+
89+
if [ "$has_signal" -ne 1 ]; then
90+
echo "No runnable/build signals found in repository"
91+
exit 1
92+
fi
93+
94+
echo "Running Python syntax smoke check"
95+
python_files="$(find . -type f -name '*.py' -not -path './.git/*' 2>/dev/null || true)"
96+
if [ -n "$python_files" ]; then
97+
while IFS= read -r f; do
98+
[ -z "$f" ] && continue
99+
python -m py_compile "$f"
100+
done <<< "$python_files"
101+
fi
102+
103+
echo "Smoke checks passed"
104+
105+
- name: Run repository runtime smoke test
106+
shell: bash
107+
run: |
108+
set -euo pipefail
109+
if [ -f tests/test_runtime.py ]; then
110+
python tests/test_runtime.py
111+
else
112+
echo "No runtime smoke test file found"
113+
exit 1
114+
fi
115+
116+
- name: Install integration test dependencies
117+
run: pip install pytest pymongo
118+
119+
- name: Run integration tests
120+
env:
121+
MONGODB_URI: mongodb://admin:mongodb@localhost:27017/
122+
run: pytest tests/test_integration.py -v

backend/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from fastapi import FastAPI
22
import uvicorn
3-
from motor.motor_asyncio import AsyncIOMotorClient
3+
from pymongo import AsyncMongoClient
44
from config import settings
55

66
from apps.todo.routers import router as todo_router
@@ -10,7 +10,7 @@
1010

1111
@app.on_event("startup")
1212
async def startup_db_client():
13-
app.mongodb_client = AsyncIOMotorClient(settings.DB_URL)
13+
app.mongodb_client = AsyncMongoClient(settings.DB_URL)
1414
app.mongodb = app.mongodb_client[settings.DB_NAME]
1515

1616

backend/requirements.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ fastapi
33
pydantic
44

55
# Database
6-
motor[srv]
6+
pymongo[srv]
77

88
# Development
99
pip-tools

backend/requirements.txt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,6 @@ markupsafe==2.1.5
5858
# via jinja2
5959
mdurl==0.1.2
6060
# via markdown-it-py
61-
motor[srv]==3.4.0
62-
# via -r requirements.in
6361
mypy-extensions==1.0.0
6462
# via black
6563
orjson==3.10.3
@@ -82,8 +80,8 @@ pydantic-core==2.18.4
8280
# via pydantic
8381
pygments==2.18.0
8482
# via rich
85-
pymongo[srv]==4.7.3
86-
# via motor
83+
pymongo[srv]==4.13.1
84+
# via -r requirements.in
8785
pyproject-hooks==1.1.0
8886
# via
8987
# build

tests/__init__.py

Whitespace-only changes.

tests/test_integration.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Integration tests for FARM-Intro.
2+
3+
Tests real MongoDB CRUD operations for the task data model
4+
used by the FARM-Intro backend.
5+
6+
Requires a running MongoDB instance. Set MONGODB_URI (default:
7+
mongodb://admin:mongodb@localhost:27017/) or the tests will be skipped.
8+
"""
9+
10+
import os
11+
import pytest
12+
from pymongo import MongoClient
13+
from bson import ObjectId
14+
15+
MONGODB_URI = os.environ.get("MONGODB_URI", "mongodb://admin:mongodb@localhost:27017/")
16+
TEST_DB = "farm_intro_integration_test"
17+
18+
19+
@pytest.fixture(scope="module")
20+
def db():
21+
client = MongoClient(MONGODB_URI, serverSelectionTimeoutMS=2000)
22+
try:
23+
client.admin.command("ping")
24+
except Exception:
25+
client.close()
26+
pytest.skip(f"MongoDB not reachable at {MONGODB_URI}")
27+
database = client[TEST_DB]
28+
yield database
29+
client.drop_database(TEST_DB)
30+
client.close()
31+
32+
33+
def test_mongodb_ping():
34+
client = MongoClient(MONGODB_URI, serverSelectionTimeoutMS=2000)
35+
try:
36+
result = client.admin.command("ping")
37+
assert result.get("ok") == 1.0
38+
except Exception:
39+
pytest.skip(f"MongoDB not reachable at {MONGODB_URI}")
40+
finally:
41+
client.close()
42+
43+
44+
def test_task_create_and_find(db):
45+
"""Tasks collection: insert and retrieve a task."""
46+
tasks = db["tasks"]
47+
48+
task_id = str(ObjectId())
49+
task = {
50+
"_id": task_id,
51+
"title": "Learn FastAPI",
52+
"description": "Build a FARM stack app",
53+
"completed": False,
54+
}
55+
56+
result = tasks.insert_one(task)
57+
assert result.inserted_id == task_id
58+
59+
found = tasks.find_one({"_id": task_id})
60+
assert found["title"] == "Learn FastAPI"
61+
assert found["completed"] is False
62+
63+
# Cleanup
64+
tasks.delete_one({"_id": task_id})
65+
66+
67+
def test_task_update(db):
68+
"""Tasks collection: update a task's completion status."""
69+
tasks = db["tasks"]
70+
71+
task_id = str(ObjectId())
72+
tasks.insert_one({"_id": task_id, "title": "Write tests", "completed": False})
73+
74+
tasks.update_one({"_id": task_id}, {"$set": {"completed": True}})
75+
updated = tasks.find_one({"_id": task_id})
76+
assert updated["completed"] is True
77+
78+
# Cleanup
79+
tasks.delete_one({"_id": task_id})
80+
81+
82+
def test_task_list(db):
83+
"""Tasks collection: list all tasks returns correct count."""
84+
tasks = db["tasks"]
85+
86+
ids = [str(ObjectId()) for _ in range(3)]
87+
docs = [
88+
{"_id": ids[0], "title": "Task 1", "completed": False},
89+
{"_id": ids[1], "title": "Task 2", "completed": True},
90+
{"_id": ids[2], "title": "Task 3", "completed": False},
91+
]
92+
tasks.insert_many(docs)
93+
94+
all_tasks = list(tasks.find({}))
95+
assert len(all_tasks) >= 3
96+
97+
incomplete = list(tasks.find({"completed": False}))
98+
assert all(not t["completed"] for t in incomplete)
99+
100+
# Cleanup
101+
tasks.delete_many({"_id": {"$in": ids}})
102+
103+
104+
def test_task_delete(db):
105+
"""Tasks collection: delete a task and confirm it is gone."""
106+
tasks = db["tasks"]
107+
108+
task_id = str(ObjectId())
109+
tasks.insert_one({"_id": task_id, "title": "To be deleted", "completed": False})
110+
111+
delete_result = tasks.delete_one({"_id": task_id})
112+
assert delete_result.deleted_count == 1
113+
114+
assert tasks.find_one({"_id": task_id}) is None
115+
116+
117+
def test_task_not_found(db):
118+
"""Tasks collection: querying a non-existent task returns None."""
119+
tasks = db["tasks"]
120+
assert tasks.find_one({"_id": "nonexistent-id"}) is None

tests/test_runtime.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import asyncio
2+
import importlib.util
3+
import sys
4+
import types
5+
import unittest
6+
from pathlib import Path
7+
8+
9+
class RuntimeTest(unittest.TestCase):
10+
@classmethod
11+
def setUpClass(cls):
12+
config = types.ModuleType("config")
13+
config.settings = types.SimpleNamespace(DB_URL="mongodb://example/test", DB_NAME="sample", HOST="127.0.0.1", DEBUG_MODE=False, PORT=8000)
14+
sys.modules["config"] = config
15+
16+
sys.modules.setdefault("uvicorn", types.ModuleType("uvicorn"))
17+
18+
pymongo = types.ModuleType("pymongo")
19+
class _AsyncMongoClient:
20+
def __init__(self, *a, **kw): self.closed = False
21+
def __getitem__(self, name): return {"db_name": name}
22+
def close(self): self.closed = True
23+
pymongo.AsyncMongoClient = _AsyncMongoClient
24+
sys.modules["pymongo"] = pymongo
25+
26+
# Minimal fastapi stub with APIRouter support
27+
fastapi_stub = types.ModuleType("fastapi")
28+
class APIRouter:
29+
def __init__(self):
30+
self._routes = []
31+
def get(self, path, **kwargs):
32+
def wrap(fn):
33+
self._routes.append(types.SimpleNamespace(path=path, endpoint=fn))
34+
return fn
35+
return wrap
36+
class FastAPI:
37+
def __init__(self, *args, **kwargs):
38+
self._routers = []
39+
self.mongodb = None
40+
self.mongodb_client = None
41+
def on_event(self, event):
42+
return lambda fn: fn
43+
def include_router(self, router, *, prefix="", **kwargs):
44+
self._routers.append((router, prefix))
45+
@property
46+
def routes(self):
47+
return [
48+
types.SimpleNamespace(path=prefix + r.path)
49+
for router, prefix in self._routers
50+
for r in router._routes
51+
]
52+
fastapi_stub.FastAPI = FastAPI
53+
fastapi_stub.APIRouter = APIRouter
54+
sys.modules["fastapi"] = fastapi_stub
55+
56+
todo_routers = types.ModuleType("apps.todo.routers")
57+
router = APIRouter()
58+
@router.get("/hello")
59+
async def hello():
60+
return {"ok": True}
61+
todo_routers.router = router
62+
sys.modules["apps.todo.routers"] = todo_routers
63+
64+
target = Path(__file__).resolve().parents[1] / "backend" / "main.py"
65+
spec = importlib.util.spec_from_file_location("farm_intro_main", target)
66+
cls.mod = importlib.util.module_from_spec(spec)
67+
spec.loader.exec_module(cls.mod)
68+
69+
class FakeClient:
70+
def __init__(self, *args, **kwargs):
71+
self.closed = False
72+
def __getitem__(self, name):
73+
return {"db_name": name}
74+
def close(self):
75+
self.closed = True
76+
77+
cls.mod.AsyncMongoClient = FakeClient
78+
79+
def test_startup_and_router_mount(self):
80+
asyncio.run(self.mod.startup_db_client())
81+
self.assertTrue(hasattr(self.mod.app, "mongodb"))
82+
paths = {route.path for route in self.mod.app.routes}
83+
self.assertIn("/task/hello", paths)
84+
asyncio.run(self.mod.shutdown_db_client())
85+
self.assertTrue(self.mod.app.mongodb_client.closed)
86+
87+
88+
if __name__ == "__main__":
89+
unittest.main()

0 commit comments

Comments
 (0)