Skip to content

Commit c8a1e8d

Browse files
authored
feat: add tests for code graders (#166)
1 parent 0c78373 commit c8a1e8d

7 files changed

Lines changed: 1306 additions & 0 deletions
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
"""
4+
Unit tests for CodeBugDetectionGrader.
5+
6+
Tests code bug detection evaluation with mocked LLM responses.
7+
8+
Example:
9+
Run all tests:
10+
```bash
11+
pytest tests/graders/code/test_code_bug_detection.py -v
12+
```
13+
"""
14+
15+
from unittest.mock import AsyncMock, patch
16+
17+
import pytest
18+
19+
from openjudge.graders.code.code_bug_detection import (
20+
DEFAULT_CODE_BUG_DETECTION_TEMPLATE,
21+
CodeBugDetectionGrader,
22+
)
23+
from openjudge.models.schema.prompt_template import LanguageEnum
24+
25+
26+
@pytest.mark.unit
27+
class TestCodeBugDetectionGraderUnit:
28+
"""Unit tests for CodeBugDetectionGrader - testing isolated functionality"""
29+
30+
def test_initialization(self):
31+
"""Test successful initialization"""
32+
mock_model = AsyncMock()
33+
grader = CodeBugDetectionGrader(model=mock_model)
34+
assert grader.name == "code_bug_detection"
35+
assert grader.threshold == 3
36+
assert grader.model == mock_model
37+
38+
def test_initialization_with_custom_threshold(self):
39+
"""Test initialization with custom threshold"""
40+
mock_model = AsyncMock()
41+
grader = CodeBugDetectionGrader(model=mock_model, threshold=4)
42+
assert grader.threshold == 4
43+
44+
def test_initialization_invalid_threshold(self):
45+
"""Test initialization with invalid threshold raises ValueError"""
46+
mock_model = AsyncMock()
47+
with pytest.raises(ValueError, match="threshold must be in range"):
48+
CodeBugDetectionGrader(model=mock_model, threshold=0)
49+
50+
with pytest.raises(ValueError, match="threshold must be in range"):
51+
CodeBugDetectionGrader(model=mock_model, threshold=6)
52+
53+
def test_initialization_with_language(self):
54+
"""Test initialization with different languages"""
55+
mock_model = AsyncMock()
56+
grader_zh = CodeBugDetectionGrader(model=mock_model, language=LanguageEnum.ZH)
57+
assert grader_zh.language == LanguageEnum.ZH
58+
59+
def test_default_template_exists(self):
60+
"""Test that default template is properly defined"""
61+
assert DEFAULT_CODE_BUG_DETECTION_TEMPLATE is not None
62+
# Should have both EN and ZH prompts
63+
assert LanguageEnum.EN in DEFAULT_CODE_BUG_DETECTION_TEMPLATE.messages
64+
assert LanguageEnum.ZH in DEFAULT_CODE_BUG_DETECTION_TEMPLATE.messages
65+
66+
@pytest.mark.asyncio
67+
async def test_successful_evaluation_no_bugs(self):
68+
"""Test evaluation of bug-free code"""
69+
mock_response = AsyncMock()
70+
mock_response.parsed = {
71+
"score": 5,
72+
"reason": "No bugs detected. The code correctly handles all typical and edge cases.",
73+
}
74+
75+
with patch("openjudge.graders.llm_grader.BaseChatModel.achat", new_callable=AsyncMock) as mock_achat:
76+
mock_achat.return_value = mock_response
77+
78+
mock_model = AsyncMock()
79+
grader = CodeBugDetectionGrader(model=mock_model)
80+
grader.model.achat = mock_achat
81+
82+
result = await grader.aevaluate(
83+
query="Return the second largest element in a list.",
84+
response="def second_largest(nums):\n if len(nums) < 2:\n raise ValueError('Need at least 2 elements')\n unique = sorted(set(nums), reverse=True)\n if len(unique) < 2:\n raise ValueError('Need at least 2 distinct elements')\n return unique[1]",
85+
)
86+
87+
assert result.score == 5
88+
assert "No bugs" in result.reason
89+
assert result.metadata["threshold"] == 3
90+
91+
@pytest.mark.asyncio
92+
async def test_successful_evaluation_with_bugs(self):
93+
"""Test evaluation of buggy code"""
94+
mock_response = AsyncMock()
95+
mock_response.parsed = {
96+
"score": 2,
97+
"reason": "Off-by-one on empty list: IndexError when len < 2. Incorrect result for duplicate values.",
98+
}
99+
100+
with patch("openjudge.graders.llm_grader.BaseChatModel.achat", new_callable=AsyncMock) as mock_achat:
101+
mock_achat.return_value = mock_response
102+
103+
mock_model = AsyncMock()
104+
grader = CodeBugDetectionGrader(model=mock_model)
105+
grader.model.achat = mock_achat
106+
107+
result = await grader.aevaluate(
108+
query="Return the second largest element in a list.",
109+
response="def second_largest(nums):\n nums.sort()\n return nums[-2]",
110+
)
111+
112+
assert result.score == 2
113+
assert "Off-by-one" in result.reason
114+
assert result.metadata["threshold"] == 3
115+
116+
@pytest.mark.asyncio
117+
async def test_critical_bugs(self):
118+
"""Test evaluation of code with critical bugs (score=1)"""
119+
mock_response = AsyncMock()
120+
mock_response.parsed = {
121+
"score": 1,
122+
"reason": "Critical bugs: crashes on empty input, infinite loop for negative numbers, returns wrong type.",
123+
}
124+
125+
with patch("openjudge.graders.llm_grader.BaseChatModel.achat", new_callable=AsyncMock) as mock_achat:
126+
mock_achat.return_value = mock_response
127+
128+
mock_model = AsyncMock()
129+
grader = CodeBugDetectionGrader(model=mock_model)
130+
grader.model.achat = mock_achat
131+
132+
result = await grader.aevaluate(
133+
query="Implement a stack with push, pop, and peek.",
134+
response="class Stack:\n def pop(self): return self.data.pop()",
135+
)
136+
137+
assert result.score == 1
138+
assert "Critical" in result.reason
139+
140+
@pytest.mark.asyncio
141+
async def test_metadata_contains_threshold(self):
142+
"""Test that metadata contains the threshold value"""
143+
mock_response = AsyncMock()
144+
mock_response.parsed = {"score": 4, "reason": "Minor potential issues."}
145+
146+
with patch("openjudge.graders.llm_grader.BaseChatModel.achat", new_callable=AsyncMock) as mock_achat:
147+
mock_achat.return_value = mock_response
148+
149+
mock_model = AsyncMock()
150+
grader = CodeBugDetectionGrader(model=mock_model, threshold=4)
151+
grader.model.achat = mock_achat
152+
153+
result = await grader.aevaluate(
154+
query="Write a function.",
155+
response="def func(): pass",
156+
)
157+
158+
assert result.metadata["threshold"] == 4
159+
160+
@pytest.mark.asyncio
161+
async def test_error_handling(self):
162+
"""Test graceful error handling when LLM fails"""
163+
with patch("openjudge.graders.llm_grader.BaseChatModel.achat", new_callable=AsyncMock) as mock_achat:
164+
mock_achat.side_effect = Exception("API Error")
165+
166+
mock_model = AsyncMock()
167+
grader = CodeBugDetectionGrader(model=mock_model)
168+
grader.model.achat = mock_achat
169+
170+
result = await grader.aevaluate(
171+
query="Write a function.",
172+
response="def func(): pass",
173+
)
174+
175+
assert "Evaluation error: API Error" in result.error
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
"""
4+
Unit tests for CodeComplexityGrader.
5+
6+
Tests code complexity / over-engineering evaluation with mocked LLM responses.
7+
8+
Example:
9+
Run all tests:
10+
```bash
11+
pytest tests/graders/code/test_code_complexity.py -v
12+
```
13+
"""
14+
15+
from unittest.mock import AsyncMock, patch
16+
17+
import pytest
18+
19+
from openjudge.graders.code.code_complexity import (
20+
DEFAULT_CODE_COMPLEXITY_TEMPLATE,
21+
CodeComplexityGrader,
22+
)
23+
from openjudge.models.schema.prompt_template import LanguageEnum
24+
25+
26+
@pytest.mark.unit
27+
class TestCodeComplexityGraderUnit:
28+
"""Unit tests for CodeComplexityGrader - testing isolated functionality"""
29+
30+
def test_initialization(self):
31+
"""Test successful initialization"""
32+
mock_model = AsyncMock()
33+
grader = CodeComplexityGrader(model=mock_model)
34+
assert grader.name == "code_complexity"
35+
assert grader.threshold == 3
36+
assert grader.model == mock_model
37+
38+
def test_initialization_with_custom_threshold(self):
39+
"""Test initialization with custom threshold"""
40+
mock_model = AsyncMock()
41+
grader = CodeComplexityGrader(model=mock_model, threshold=4)
42+
assert grader.threshold == 4
43+
44+
def test_initialization_invalid_threshold(self):
45+
"""Test initialization with invalid threshold raises ValueError"""
46+
mock_model = AsyncMock()
47+
with pytest.raises(ValueError, match="threshold must be in range"):
48+
CodeComplexityGrader(model=mock_model, threshold=0)
49+
50+
with pytest.raises(ValueError, match="threshold must be in range"):
51+
CodeComplexityGrader(model=mock_model, threshold=6)
52+
53+
def test_initialization_with_language(self):
54+
"""Test initialization with different languages"""
55+
mock_model = AsyncMock()
56+
grader_zh = CodeComplexityGrader(model=mock_model, language=LanguageEnum.ZH)
57+
assert grader_zh.language == LanguageEnum.ZH
58+
59+
def test_default_template_exists(self):
60+
"""Test that default template is properly defined"""
61+
assert DEFAULT_CODE_COMPLEXITY_TEMPLATE is not None
62+
assert LanguageEnum.EN in DEFAULT_CODE_COMPLEXITY_TEMPLATE.messages
63+
assert LanguageEnum.ZH in DEFAULT_CODE_COMPLEXITY_TEMPLATE.messages
64+
65+
@pytest.mark.asyncio
66+
async def test_simple_code_high_score(self):
67+
"""Test evaluation of simple, appropriately complex code"""
68+
mock_response = AsyncMock()
69+
mock_response.parsed = {
70+
"score": 5,
71+
"reason": "Complexity perfectly matches the task. The code is clean and concise.",
72+
}
73+
74+
with patch("openjudge.graders.llm_grader.BaseChatModel.achat", new_callable=AsyncMock) as mock_achat:
75+
mock_achat.return_value = mock_response
76+
77+
mock_model = AsyncMock()
78+
grader = CodeComplexityGrader(model=mock_model)
79+
grader.model.achat = mock_achat
80+
81+
result = await grader.aevaluate(
82+
query="Write a function that returns the sum of a list of numbers.",
83+
response="def sum_numbers(numbers):\n return sum(numbers)",
84+
)
85+
86+
assert result.score == 5
87+
assert "perfectly matches" in result.reason
88+
assert result.metadata["threshold"] == 3
89+
90+
@pytest.mark.asyncio
91+
async def test_over_engineered_code_low_score(self):
92+
"""Test evaluation of over-engineered code"""
93+
mock_response = AsyncMock()
94+
mock_response.parsed = {
95+
"score": 1,
96+
"reason": "Extremely over-engineered. Unnecessary ABC, factory pattern, and class wrapper for a simple sum function.",
97+
}
98+
99+
with patch("openjudge.graders.llm_grader.BaseChatModel.achat", new_callable=AsyncMock) as mock_achat:
100+
mock_achat.return_value = mock_response
101+
102+
mock_model = AsyncMock()
103+
grader = CodeComplexityGrader(model=mock_model)
104+
grader.model.achat = mock_achat
105+
106+
result = await grader.aevaluate(
107+
query="Write a function that returns the sum of a list of numbers.",
108+
response="""from abc import ABC, abstractmethod
109+
from typing import List, Union
110+
111+
class BaseAggregator(ABC):
112+
@abstractmethod
113+
def aggregate(self, values): pass
114+
115+
class SumAggregator(BaseAggregator):
116+
def aggregate(self, values):
117+
result = 0
118+
for value in values:
119+
result = result + value
120+
return result
121+
122+
class AggregatorFactory:
123+
@staticmethod
124+
def create(strategy="sum"):
125+
if strategy == "sum":
126+
return SumAggregator()
127+
raise ValueError(f"Unknown strategy: {strategy}")
128+
129+
def sum_numbers(numbers):
130+
factory = AggregatorFactory()
131+
aggregator = factory.create("sum")
132+
return aggregator.aggregate(numbers)""",
133+
)
134+
135+
assert result.score == 1
136+
assert "over-engineered" in result.reason.lower()
137+
138+
@pytest.mark.asyncio
139+
async def test_moderate_complexity(self):
140+
"""Test evaluation of moderately over-engineered code"""
141+
mock_response = AsyncMock()
142+
mock_response.parsed = {
143+
"score": 3,
144+
"reason": "Noticeably over-engineered. Class wrapper unnecessary for a one-liner function.",
145+
}
146+
147+
with patch("openjudge.graders.llm_grader.BaseChatModel.achat", new_callable=AsyncMock) as mock_achat:
148+
mock_achat.return_value = mock_response
149+
150+
mock_model = AsyncMock()
151+
grader = CodeComplexityGrader(model=mock_model)
152+
grader.model.achat = mock_achat
153+
154+
result = await grader.aevaluate(
155+
query="Check if a string is a palindrome.",
156+
response="""class PalindromeChecker:
157+
def __init__(self, strategy='default'):
158+
self.strategy = strategy
159+
def check(self, s):
160+
cleaned = self._preprocess(s)
161+
return cleaned == cleaned[::-1]
162+
def _preprocess(self, s):
163+
return s.lower().replace(' ', '')""",
164+
)
165+
166+
assert result.score == 3
167+
168+
@pytest.mark.asyncio
169+
async def test_metadata_contains_threshold(self):
170+
"""Test that metadata contains the threshold value"""
171+
mock_response = AsyncMock()
172+
mock_response.parsed = {"score": 4, "reason": "Minor redundancy."}
173+
174+
with patch("openjudge.graders.llm_grader.BaseChatModel.achat", new_callable=AsyncMock) as mock_achat:
175+
mock_achat.return_value = mock_response
176+
177+
mock_model = AsyncMock()
178+
grader = CodeComplexityGrader(model=mock_model, threshold=4)
179+
grader.model.achat = mock_achat
180+
181+
result = await grader.aevaluate(
182+
query="Write a function.",
183+
response="def func(): pass",
184+
)
185+
186+
assert result.metadata["threshold"] == 4
187+
188+
@pytest.mark.asyncio
189+
async def test_error_handling(self):
190+
"""Test graceful error handling when LLM fails"""
191+
with patch("openjudge.graders.llm_grader.BaseChatModel.achat", new_callable=AsyncMock) as mock_achat:
192+
mock_achat.side_effect = Exception("API Error")
193+
194+
mock_model = AsyncMock()
195+
grader = CodeComplexityGrader(model=mock_model)
196+
grader.model.achat = mock_achat
197+
198+
result = await grader.aevaluate(
199+
query="Write a function.",
200+
response="def func(): pass",
201+
)
202+
203+
assert "Evaluation error: API Error" in result.error

0 commit comments

Comments
 (0)