Skip to content

Commit a308870

Browse files
committed
test: improve unit test coverage ratio to 95%
1 parent c254bf0 commit a308870

9 files changed

Lines changed: 632 additions & 42 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# my own homegrown file to store coverage report output from Docker.
2+
coverage.out
3+
4+
15
# Jupyter Notebook
26
.ipynb_checkpoints
37
build

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ docker-coverage:
120120
-e LOGGING_LEVEL=${LOGGING_LEVEL} \
121121
-e LLM_TOOL_CHOICE=${LLM_TOOL_CHOICE} \
122122
${DOCKERHUB_USERNAME}/${REPO_NAME}:latest \
123-
/bin/bash -c "python -m coverage run --source=app -m unittest discover -s app/tests && python -m coverage report && python -m coverage xml"
123+
/bin/bash -c "python -m coverage run --source=app --omit='app/tests/*' -m unittest discover -s app/tests && python -m coverage report -m --omit='app/tests/*' && python -m coverage xml --omit='app/tests/*'"
124124

125125
docker-prune:
126126
@if [ "`docker ps -aq`" ]; then \

app/structured_outputs.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def register_course_with_structured_output(course_code: str, email: str, full_na
119119

120120
# Example of using OpenAI's beta structured outputs (requires openai>=1.0.0)
121121
# pylint: disable=unused-argument
122-
def completion_with_structured_output(prompt: str, response_model: type):
122+
def completion_with_structured_output(prompt: str, response_model: type) -> None:
123123
"""
124124
Example of using OpenAI's structured output parsing.
125125
@@ -143,4 +143,4 @@ def completion_with_structured_output(prompt: str, response_model: type):
143143
# pylint: disable=broad-except
144144
except Exception as e:
145145
logger.error("Structured completion error: %s", e)
146-
return None
146+
raise e

app/tests/test_agent.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# -*- coding: utf-8 -*-
2+
# pylint: disable=wrong-import-position
3+
# pylint: disable=R0801,C0115,W0613
4+
"""Test application."""
5+
6+
# python stuff
7+
import os
8+
import sys
9+
import unittest
10+
from pathlib import Path
11+
from unittest.mock import patch
12+
13+
from app import agent
14+
from app.agent import main # noqa: E402
15+
16+
17+
HERE = os.path.abspath(os.path.dirname(__file__))
18+
PROJECT_ROOT = str(Path(HERE).parent.parent)
19+
PYTHON_ROOT = str(Path(PROJECT_ROOT).parent)
20+
if PYTHON_ROOT not in sys.path:
21+
sys.path.append(PYTHON_ROOT) # noqa: E402
22+
23+
24+
class TestApplication(unittest.TestCase):
25+
"""Test application."""
26+
27+
def test_application_does_not_crash(self):
28+
"""Test that the application returns a value."""
29+
30+
# pylint: disable=broad-exception-caught
31+
prompts = (
32+
"Show me a list of available courses.",
33+
"I would like to register for the CS107 Algorithms course. My name is John Doe and my email is john.doe@example.com",
34+
"Thank you, that's all for now. Goodbye!",
35+
"Goodbye!",
36+
"Go away!",
37+
)
38+
try:
39+
main(prompts=prompts)
40+
except Exception as e:
41+
self.fail(f"main raised an exception: {e}")
42+
43+
@patch("app.agent.completion")
44+
@patch("builtins.input", return_value="no")
45+
def test_main_goodbye(self, mock_input, mock_completion):
46+
"""Test that the application exits on "Goodbye!"."""
47+
mock_completion.return_value = (None, [])
48+
agent.main(prompts=("no",))
49+
50+
@patch("app.agent.completion")
51+
@patch("builtins.input", side_effect=["yes", "no"])
52+
def test_main_followup_question(self, mock_input, mock_completion):
53+
"""Test that the application handles a follow-up question."""
54+
55+
class MockMessage:
56+
content = "Some info\nQUESTION: What would you like to do next?"
57+
58+
class MockResponse:
59+
choices = [type("obj", (), {"message": MockMessage})]
60+
61+
mock_completion.side_effect = [
62+
(MockResponse(), ["get_courses"]),
63+
(MockResponse(), ["register_course"]),
64+
(None, []),
65+
]
66+
agent.main(prompts=None)
67+
68+
@patch("app.agent.completion")
69+
@patch("builtins.input", side_effect=["something", "no"])
70+
def test_main_default_prompt(self, mock_input, mock_completion):
71+
"""Test that the application handles a default prompt."""
72+
73+
class MockMessage:
74+
content = "Just a message"
75+
76+
class MockResponse:
77+
choices = [type("obj", (), {"message": MockMessage})]
78+
79+
mock_completion.side_effect = [(MockResponse(), []), (None, [])]
80+
agent.main(prompts=None)

app/tests/test_application.py

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

app/tests/test_database.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# -*- coding: utf-8 -*-
2+
# pylint: disable=wrong-import-position
3+
# pylint: disable=R0801,W0613,W0719,W0718
4+
"""Test database connectivity."""
5+
6+
# python stuff
7+
import os
8+
import sys
9+
import unittest
10+
from pathlib import Path
11+
from unittest.mock import MagicMock, patch
12+
13+
import pymysql
14+
15+
from app.database import ConfigurationException, DatabaseConnection
16+
from app.logging_config import get_logger
17+
18+
19+
HERE = os.path.abspath(os.path.dirname(__file__))
20+
PROJECT_ROOT = str(Path(HERE).parent.parent)
21+
PYTHON_ROOT = str(Path(PROJECT_ROOT).parent)
22+
if PYTHON_ROOT not in sys.path:
23+
sys.path.append(PYTHON_ROOT) # noqa: E402
24+
25+
26+
logger = get_logger(__name__)
27+
28+
29+
class TestDatabase(unittest.TestCase):
30+
"""Test database."""
31+
32+
def test_connection_string(self):
33+
"""Test that the connection string is formed correctly."""
34+
db = DatabaseConnection()
35+
conn_str = db.connection_string
36+
self.assertIn("@", conn_str)
37+
self.assertIn("/", conn_str)
38+
39+
@patch("app.database.pymysql.connect")
40+
def test_get_connection_success(self, mock_connect):
41+
"""Test that a connection is returned on success."""
42+
db = DatabaseConnection()
43+
mock_connect.return_value = MagicMock()
44+
conn = db.get_connection()
45+
self.assertIsNotNone(conn)
46+
mock_connect.assert_called_once()
47+
48+
@patch("app.database.pymysql.connect", side_effect=Exception("fail"))
49+
def test_get_connection_failure(self, mock_connect):
50+
"""Test that an exception is raised on connection failure."""
51+
db = DatabaseConnection()
52+
with self.assertRaises(Exception):
53+
db.get_connection()
54+
55+
@patch("app.database.DatabaseConnection.get_connection")
56+
def test_get_cursor_success(self, mock_get_conn):
57+
"""Test that a cursor is returned on success."""
58+
db = DatabaseConnection()
59+
mock_conn = MagicMock()
60+
mock_cursor = MagicMock()
61+
mock_conn.cursor.return_value = mock_cursor
62+
mock_get_conn.return_value = mock_conn
63+
with db.get_cursor() as cursor:
64+
self.assertEqual(cursor, mock_cursor)
65+
mock_conn.commit.assert_called_once()
66+
mock_conn.close.assert_called_once()
67+
68+
@patch("app.database.DatabaseConnection.get_connection")
69+
def test_get_cursor_exception(self, mock_get_conn):
70+
"""Test that an exception during cursor usage rolls back and closes."""
71+
db = DatabaseConnection()
72+
mock_conn = MagicMock()
73+
mock_cursor = MagicMock()
74+
mock_conn.cursor.return_value = mock_cursor
75+
mock_get_conn.return_value = mock_conn
76+
77+
def raise_exc(*args, **kwargs):
78+
raise Exception("fail")
79+
80+
mock_cursor.execute.side_effect = raise_exc
81+
try:
82+
with db.get_cursor() as cursor:
83+
cursor.execute("SELECT 1")
84+
except Exception:
85+
mock_conn.rollback.assert_called_once()
86+
mock_conn.close.assert_called_once()
87+
88+
@patch("app.database.DatabaseConnection.get_cursor")
89+
def test_execute_query(self, mock_get_cursor):
90+
"""Test that a query returns results."""
91+
db = DatabaseConnection()
92+
mock_cursor = MagicMock()
93+
mock_cursor.fetchall.return_value = [{"a": 1}]
94+
mock_get_cursor.return_value.__enter__.return_value = mock_cursor
95+
result = db.execute_query("SELECT * FROM test")
96+
self.assertEqual(result, [{"a": 1}])
97+
98+
@patch("app.database.DatabaseConnection.get_cursor")
99+
def test_execute_update(self, mock_get_cursor):
100+
"""Test that an update returns affected row count."""
101+
db = DatabaseConnection()
102+
mock_cursor = MagicMock()
103+
mock_cursor.rowcount = 2
104+
mock_get_cursor.return_value.__enter__.return_value = mock_cursor
105+
result = db.execute_update("UPDATE test SET a=1")
106+
self.assertEqual(result, 2)
107+
108+
@patch("app.database.DatabaseConnection.get_cursor")
109+
def test_test_connection_success(self, mock_get_cursor):
110+
"""Test that the database connection is successful."""
111+
db = DatabaseConnection()
112+
mock_cursor = MagicMock()
113+
mock_get_cursor.return_value.__enter__.return_value = mock_cursor
114+
mock_cursor.execute.return_value = None
115+
self.assertTrue(db.test_connection())
116+
117+
@patch("app.database.DatabaseConnection.get_cursor", side_effect=Exception("fail"))
118+
def test_test_connection_failure(self, mock_get_cursor):
119+
"""Test that the database connection failure is handled."""
120+
db = DatabaseConnection()
121+
self.assertFalse(db.test_connection())
122+
123+
def test_missing_config_raises(self):
124+
"""Test that missing configuration raises ConfigurationException."""
125+
with (
126+
patch("app.database.MYSQL_HOST", ""),
127+
patch("app.database.MYSQL_USER", ""),
128+
patch("app.database.MYSQL_PASSWORD", ""),
129+
patch("app.database.MYSQL_DATABASE", ""),
130+
):
131+
with self.assertRaises(ConfigurationException):
132+
DatabaseConnection()
133+
134+
@patch("app.database.pymysql.connect", side_effect=pymysql.Error("connection failed"))
135+
def test_get_connection_raises(self, mock_connect):
136+
"""Test that a pymysql.Error during connection raises the appropriate exception."""
137+
db = DatabaseConnection()
138+
with self.assertRaises(pymysql.Error) as ctx:
139+
db.get_connection()
140+
self.assertIn("Failed to connect to MySQL database", str(ctx.exception))

0 commit comments

Comments
 (0)