Skip to content

Commit d0875e5

Browse files
authored
feat(test): integrate MongoDB Testcontainer and refactor integration tests to use real DB (#75)
* feat(test): add test database setup and refactor delete task integration test * feat(test): add shared MongoDB test container for integration tests * fix docker internal host ci * refactor: test_task_detail integration test to use db * fix: host name on linux machine * test: add more check on invalid task id * refactor: add error handling for mongo start and add proper spacing * refactor: use constant value instead of hardcoded value * fix: add logic to clear db with each test suite and use django inbuild reverse to generate url * fix: replace 1s sleep with log-based wait for Mongo readiness * fix: remove hardcoded host/port and use container IP for replica set init * Fix: Properly close MongoDB connection in DatabaseManager.reset()
1 parent 5bda8e4 commit d0875e5

File tree

8 files changed

+211
-113
lines changed

8 files changed

+211
-113
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,6 @@ on:
77
jobs:
88
build:
99
runs-on: ubuntu-latest
10-
services:
11-
db:
12-
image: mongo:latest
13-
ports:
14-
- 27017:27017
15-
16-
env:
17-
MONGODB_URI: mongodb://db:27017
18-
DB_NAME: todo-app
1910

2011
steps:
2112
- name: Checkout code
@@ -36,4 +27,4 @@ jobs:
3627
3728
- name: Run tests
3829
run: |
39-
python3.11 manage.py test
30+
python3.11 manage.py test

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ ruff==0.7.1
2020
sqlparse==0.5.1
2121
typing_extensions==4.12.2
2222
virtualenv==20.27.0
23+
testcontainers[mongodb]==4.10.0
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from django.test import TransactionTestCase, override_settings
2+
from pymongo import MongoClient
3+
from todo.tests.testcontainers.shared_mongo import get_shared_mongo_container
4+
from todo_project.db.config import DatabaseManager
5+
6+
class BaseMongoTestCase(TransactionTestCase):
7+
@classmethod
8+
def setUpClass(cls):
9+
super().setUpClass()
10+
cls.mongo_container = get_shared_mongo_container()
11+
cls.mongo_url = cls.mongo_container.get_connection_url()
12+
cls.mongo_client = MongoClient(cls.mongo_url)
13+
cls.db = cls.mongo_client.get_database("testdb")
14+
15+
cls.override = override_settings(
16+
MONGODB_URI=cls.mongo_url,
17+
DB_NAME="testdb",
18+
)
19+
cls.override.enable()
20+
DatabaseManager.reset()
21+
DatabaseManager().get_database()
22+
23+
def setUp(self):
24+
for collection in self.db.list_collection_names():
25+
self.db[collection].delete_many({})
26+
27+
@classmethod
28+
def tearDownClass(cls):
29+
cls.mongo_client.close()
30+
cls.override.disable()
31+
super().tearDownClass()
Lines changed: 56 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,60 @@
1-
from unittest.mock import patch
2-
from rest_framework import status
3-
from rest_framework.test import APITestCase
4-
from django.urls import reverse
1+
from http import HTTPStatus
52
from bson import ObjectId
6-
7-
from todo.services.task_service import TaskService
8-
from todo.constants.messages import ValidationErrors, ApiErrors
9-
from todo.dto.responses.error_response import ApiErrorSource
10-
from todo.exceptions.task_exceptions import TaskNotFoundException
11-
from todo.tests.fixtures.task import task_dtos
12-
from todo.constants.task import TaskPriority, TaskStatus
13-
14-
15-
class TaskDetailAPIIntegrationTest(APITestCase):
16-
@patch("todo.services.task_service.TaskService.get_task_by_id")
17-
def test_get_task_by_id_success(self, mock_get_task_by_id):
18-
fixture_task_dto = task_dtos[0]
19-
task_id_str = fixture_task_dto.id
20-
21-
mock_get_task_by_id.return_value = fixture_task_dto
22-
23-
url = reverse("task_detail", args=[task_id_str])
3+
from django.urls import reverse
4+
from rest_framework.test import APIClient
5+
from todo.tests.fixtures.task import tasks_db_data
6+
from todo.tests.integration.base_mongo_test import BaseMongoTestCase
7+
from todo.constants.messages import ApiErrors, ValidationErrors
8+
9+
10+
class TaskDetailAPIIntegrationTest(BaseMongoTestCase):
11+
def setUp(self):
12+
super().setUp()
13+
self.db.tasks.delete_many({}) # Clear tasks to avoid DuplicateKeyError
14+
self.task_doc = tasks_db_data[1].copy()
15+
self.task_doc["_id"] = self.task_doc.pop("id")
16+
self.db.tasks.insert_one(self.task_doc)
17+
self.existing_task_id = str(self.task_doc["_id"])
18+
self.non_existent_id = str(ObjectId())
19+
self.invalid_task_id = "invalid-task-id"
20+
self.client = APIClient()
21+
22+
def test_get_task_by_id_success(self):
23+
url = reverse('task_detail', args=[self.existing_task_id])
2424
response = self.client.get(url)
25-
26-
self.assertEqual(response.status_code, status.HTTP_200_OK)
27-
28-
response_data_outer = response.data
29-
response_data_inner = response_data_outer.get("data")
30-
self.assertIsNotNone(response_data_inner)
31-
32-
self.assertEqual(response_data_inner["id"], fixture_task_dto.id)
33-
self.assertEqual(response_data_inner["title"], fixture_task_dto.title)
34-
35-
self.assertEqual(response_data_inner["priority"], TaskPriority(fixture_task_dto.priority).name)
36-
self.assertEqual(response_data_inner["status"], TaskStatus(fixture_task_dto.status).value)
37-
38-
self.assertEqual(response_data_inner["displayId"], fixture_task_dto.displayId)
39-
40-
if fixture_task_dto.createdBy:
41-
self.assertEqual(response_data_inner["createdBy"]["id"], fixture_task_dto.createdBy.id)
42-
self.assertEqual(response_data_inner["createdBy"]["name"], fixture_task_dto.createdBy.name)
43-
44-
mock_get_task_by_id.assert_called_once_with(task_id_str)
45-
46-
@patch("todo.services.task_service.TaskService.get_task_by_id")
47-
def test_get_task_by_id_not_found(self, mock_get_task_by_id):
48-
non_existent_id = str(ObjectId())
49-
mock_get_task_by_id.side_effect = TaskNotFoundException()
50-
51-
url = reverse("task_detail", args=[non_existent_id])
25+
self.assertEqual(response.status_code, HTTPStatus.OK)
26+
data = response.json()["data"]
27+
self.assertEqual(data["id"], self.existing_task_id)
28+
self.assertEqual(data["title"], self.task_doc["title"])
29+
self.assertEqual(data["priority"], "MEDIUM")
30+
self.assertEqual(data["status"], self.task_doc["status"])
31+
self.assertEqual(data["displayId"], self.task_doc["displayId"])
32+
self.assertEqual(data["createdBy"]["id"],
33+
self.task_doc["createdBy"])
34+
35+
def test_get_task_by_id_not_found(self):
36+
url = reverse('task_detail', args=[self.non_existent_id])
5237
response = self.client.get(url)
53-
54-
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
55-
error_detail = response.data.get("errors", [{}])[0].get("detail")
56-
self.assertEqual(error_detail, "Task not found.")
57-
mock_get_task_by_id.assert_called_once_with(non_existent_id)
58-
59-
@patch.object(TaskService, "get_task_by_id", wraps=TaskService.get_task_by_id)
60-
def test_get_task_by_id_invalid_format(self, mock_actual_get_task_by_id):
61-
invalid_task_id = "invalid-id"
62-
url = reverse("task_detail", args=[invalid_task_id])
38+
self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND)
39+
40+
data = response.json()
41+
error_message = ApiErrors.TASK_NOT_FOUND.format(self.non_existent_id)
42+
self.assertEqual(data["message"], error_message)
43+
error = data["errors"][0]
44+
self.assertEqual(error["source"]["path"], "task_id")
45+
self.assertEqual(error["title"], ApiErrors.RESOURCE_NOT_FOUND_TITLE)
46+
self.assertEqual(error["detail"], error_message)
47+
48+
def test_get_task_by_id_invalid_format(self):
49+
url = reverse('task_detail', args=[self.invalid_task_id])
6350
response = self.client.get(url)
64-
65-
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
66-
self.assertEqual(response.data["message"], ValidationErrors.INVALID_TASK_ID_FORMAT)
67-
self.assertIsNotNone(response.data.get("errors"))
68-
self.assertEqual(len(response.data["errors"]), 1)
69-
70-
error_obj = response.data["errors"][0]
71-
self.assertEqual(error_obj["detail"], ValidationErrors.INVALID_TASK_ID_FORMAT)
72-
self.assertIn(ApiErrorSource.PATH.value, error_obj["source"])
73-
self.assertEqual(error_obj["source"][ApiErrorSource.PATH.value], "task_id")
74-
self.assertEqual(error_obj["title"], ApiErrors.VALIDATION_ERROR)
75-
76-
mock_actual_get_task_by_id.assert_called_once_with(invalid_task_id)
51+
self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
52+
data = response.json()
53+
self.assertEqual(data["statusCode"], 400)
54+
self.assertEqual(
55+
data["message"], ValidationErrors.INVALID_TASK_ID_FORMAT)
56+
self.assertEqual(data["errors"][0]["source"]["path"], "task_id")
57+
self.assertEqual(data["errors"][0]["title"],
58+
ApiErrors.VALIDATION_ERROR)
59+
self.assertEqual(data["errors"][0]["detail"],
60+
ValidationErrors.INVALID_TASK_ID_FORMAT)
Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,51 @@
1-
from django.urls import reverse
2-
from rest_framework import status
3-
from rest_framework.test import APITestCase
1+
from http import HTTPStatus
42
from bson import ObjectId
5-
from unittest.mock import patch
6-
from todo.constants.messages import ApiErrors
7-
from todo.tests.fixtures.task import task_dtos
3+
from django.urls import reverse
4+
from rest_framework.test import APIClient
5+
from todo.tests.fixtures.task import tasks_db_data
6+
from todo.tests.integration.base_mongo_test import BaseMongoTestCase
7+
from todo.constants.messages import ValidationErrors, ApiErrors
88

99

10-
class TaskDeleteAPIIntegrationTest(APITestCase):
10+
class TaskDeleteAPIIntegrationTest(BaseMongoTestCase):
1111
def setUp(self):
12-
self.task_id = task_dtos[0].id
12+
super().setUp()
13+
self.db.tasks.delete_many({})
14+
task_doc = tasks_db_data[0].copy()
15+
task_doc["_id"] = task_doc.pop("id")
16+
self.db.tasks.insert_one(task_doc)
17+
self.existing_task_id = str(task_doc["_id"])
18+
self.non_existent_id = str(ObjectId())
19+
self.invalid_task_id = "invalid-task-id"
20+
self.client = APIClient()
1321

14-
@patch("todo.repositories.task_repository.TaskRepository.delete_by_id")
15-
def test_delete_task_success(self, mock_delete_by_id):
16-
url = reverse("task_detail", args=[self.task_id])
22+
def test_delete_task_success(self):
23+
url = reverse('task_detail', args=[self.existing_task_id])
1724
response = self.client.delete(url)
18-
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
25+
self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT)
1926

20-
@patch("todo.repositories.task_repository.TaskRepository.delete_by_id")
21-
def test_delete_task_not_found(self, mock_delete_by_id):
22-
mock_delete_by_id.return_value = None
23-
non_existent_id = str(ObjectId())
24-
url = reverse("task_detail", args=[non_existent_id])
27+
def test_delete_task_not_found(self):
28+
url = reverse('task_detail', args=[self.non_existent_id])
2529
response = self.client.delete(url)
26-
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
27-
error_detail = response.data.get("errors", [{}])[0].get("detail")
28-
self.assertEqual(error_detail, ApiErrors.TASK_NOT_FOUND.format(non_existent_id))
30+
self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND)
31+
response_data = response.json()
32+
error_message = ApiErrors.TASK_NOT_FOUND.format(self.non_existent_id)
33+
self.assertEqual(response_data["message"], error_message)
34+
error = response_data["errors"][0]
35+
self.assertEqual(error["source"]["path"], "task_id")
36+
self.assertEqual(error["title"], ApiErrors.RESOURCE_NOT_FOUND_TITLE)
37+
self.assertEqual(error["detail"], error_message)
2938

3039
def test_delete_task_invalid_id_format(self):
31-
invalid_task_id = "invalid-id"
32-
url = reverse("task_detail", args=[invalid_task_id])
40+
url = reverse('task_detail', args=[self.invalid_task_id])
3341
response = self.client.delete(url)
34-
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
35-
self.assertEqual(response.data["message"], "Please enter a valid Task ID format.")
36-
self.assertIsNotNone(response.data.get("errors"))
37-
self.assertEqual(len(response.data["errors"]), 1)
38-
39-
error_obj = response.data["errors"][0]
40-
self.assertEqual(error_obj["detail"], "Please enter a valid Task ID format.")
41-
self.assertEqual(error_obj["source"]["path"], "task_id")
42-
self.assertEqual(error_obj["title"], "Validation Error")
42+
self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
43+
data = response.json()
44+
self.assertEqual(data["statusCode"], 400)
45+
self.assertEqual(
46+
data["message"], ValidationErrors.INVALID_TASK_ID_FORMAT)
47+
self.assertEqual(data["errors"][0]["source"]["path"], "task_id")
48+
self.assertEqual(data["errors"][0]["title"],
49+
ApiErrors.VALIDATION_ERROR)
50+
self.assertEqual(data["errors"][0]["detail"],
51+
ValidationErrors.INVALID_TASK_ID_FORMAT)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import time
2+
import json
3+
from testcontainers.core.generic import DockerContainer
4+
from pymongo import MongoClient
5+
from testcontainers.core.waiting_utils import wait_for_logs
6+
7+
8+
class MongoReplicaSetContainer(DockerContainer):
9+
def __init__(self, image: str = "mongo:6.0"):
10+
super().__init__(image=image)
11+
self.with_exposed_ports(27017)
12+
self.with_command(["mongod", "--replSet", "rs0", "--bind_ip_all"])
13+
self._mongo_url = None
14+
15+
def start(self):
16+
super().start()
17+
self._container.reload()
18+
mapped_port = self.get_exposed_port(27017)
19+
container_ip = self._container.attrs["NetworkSettings"]["IPAddress"]
20+
member_host = f"{container_ip}:27017"
21+
initiate_js = json.dumps(
22+
{"_id": "rs0", "members": [{"_id": 0, "host": member_host}]})
23+
wait_for_logs(self, r"Waiting for connections", timeout=20)
24+
cmd = ["mongosh", "--quiet", "--host", "localhost", "--port",
25+
"27017", "--eval", f"rs.initiate({initiate_js})"]
26+
exit_code, output = self.exec(cmd)
27+
if exit_code != 0:
28+
raise RuntimeError(
29+
f"rs.initiate() failed (exit code {exit_code}):\n" f"{output.decode('utf-8', errors='ignore')}"
30+
)
31+
self._mongo_url = f"mongodb://localhost:{mapped_port}/testdb?directConnection=true"
32+
self._wait_for_primary()
33+
return self
34+
35+
def get_connection_url(self) -> str:
36+
return self._mongo_url
37+
38+
def _wait_for_primary(self, timeout=10):
39+
client = MongoClient(self.get_connection_url())
40+
start = time.time()
41+
while time.time() - start < timeout:
42+
try:
43+
status = client.admin.command("isMaster")
44+
if status.get("ismaster", False):
45+
return
46+
except Exception as e:
47+
print(f"Waiting for PRIMARY: {e}")
48+
time.sleep(0.5)
49+
raise TimeoutError(
50+
"Timed out waiting for replica set to become PRIMARY.")
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from todo.tests.testcontainers.mongo_container import MongoReplicaSetContainer
2+
import atexit
3+
4+
_mongo_container = None
5+
6+
def _cleanup_mongo_container():
7+
global _mongo_container
8+
if _mongo_container is not None:
9+
try:
10+
_mongo_container.stop()
11+
except Exception as e:
12+
print("Failed to stop MongoDB container:", str(e))
13+
14+
15+
def get_shared_mongo_container():
16+
global _mongo_container
17+
if _mongo_container is None:
18+
try:
19+
_mongo_container = MongoReplicaSetContainer()
20+
_mongo_container.start()
21+
atexit.register(_cleanup_mongo_container)
22+
except Exception as e:
23+
print("Failed to start MongoDB container:", str(e))
24+
raise
25+
26+
return _mongo_container

todo_project/db/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,9 @@ def check_database_health(self):
3939
except ConnectionFailure as e:
4040
logger.error(f"Failed to establish database connection: {e}")
4141
return False
42+
43+
@classmethod
44+
def reset(cls):
45+
if cls.__instance is not None and cls.__instance._database_client is not None:
46+
cls.__instance._database_client.close()
47+
cls.__instance = None

0 commit comments

Comments
 (0)