Skip to content

Commit f0e6d87

Browse files
Initial commit of mutation test.
Updated log and LLM call.
1 parent d894997 commit f0e6d87

File tree

8 files changed

+264
-9
lines changed

8 files changed

+264
-9
lines changed

cover_agent/CoverAgent.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ def __init__(self, args):
3939
llm_model=args.model,
4040
api_base=args.api_base,
4141
use_report_coverage_feature_flag=args.use_report_coverage_feature_flag,
42+
mutation_testing=args.mutation_testing,
43+
more_mutation_logging=args.more_mutation_logging,
4244
)
4345

4446
def _validate_paths(self):
@@ -151,6 +153,9 @@ def run(self):
151153
# Run the coverage tool again if the desired coverage hasn't been reached
152154
self.test_gen.run_coverage()
153155

156+
if self.args.mutation_testing:
157+
self.test_gen.run_mutations()
158+
154159
# Log the final coverage
155160
if self.test_gen.current_coverage >= (self.test_gen.desired_coverage / 100):
156161
self.logger.info(

cover_agent/PromptBuilder.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def __init__(
4242
additional_instructions: str = "",
4343
failed_test_runs: str = "",
4444
language: str = "python",
45+
mutation_testing: bool = False,
4546
):
4647
"""
4748
The `PromptBuilder` class is responsible for building a formatted prompt string by replacing placeholders with the actual content of files read during initialization. It takes in various paths and settings as parameters and provides a method to generate the prompt.
@@ -72,6 +73,7 @@ def __init__(
7273
self.test_file = self._read_file(test_file_path)
7374
self.code_coverage_report = code_coverage_report
7475
self.language = language
76+
self.mutation_testing = mutation_testing
7577
# add line numbers to each line in 'source_file'. start from 1
7678
self.source_file_numbered = "\n".join(
7779
[f"{i + 1} {line}" for i, line in enumerate(self.source_file.split("\n"))]
@@ -141,12 +143,20 @@ def build_prompt(self) -> dict:
141143
}
142144
environment = Environment(undefined=StrictUndefined)
143145
try:
144-
system_prompt = environment.from_string(
145-
get_settings().test_generation_prompt.system
146-
).render(variables)
147-
user_prompt = environment.from_string(
148-
get_settings().test_generation_prompt.user
149-
).render(variables)
146+
if self.mutation_testing:
147+
system_prompt = environment.from_string(
148+
get_settings().mutation_test_prompt.system
149+
).render(variables)
150+
user_prompt = environment.from_string(
151+
get_settings().mutation_test_prompt.user
152+
).render(variables)
153+
else:
154+
system_prompt = environment.from_string(
155+
get_settings().test_generation_prompt.system
156+
).render(variables)
157+
user_prompt = environment.from_string(
158+
get_settings().test_generation_prompt.user
159+
).render(variables)
150160
except Exception as e:
151161
logging.error(f"Error rendering prompt: {e}")
152162
return {"system": "", "user": ""}

cover_agent/UnitTestGenerator.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
import logging
55
import os
66
import re
7+
import json
8+
9+
from wandb.sdk.data_types.trace_tree import Trace
710

811
from cover_agent.AICaller import AICaller
912
from cover_agent.CoverageProcessor import CoverageProcessor
@@ -14,6 +17,10 @@
1417
from cover_agent.settings.config_loader import get_settings
1518
from cover_agent.utils import load_yaml
1619

20+
import subprocess
21+
22+
from shlex import split
23+
1724

1825
class UnitTestGenerator:
1926
def __init__(
@@ -30,6 +37,8 @@ def __init__(
3037
desired_coverage: int = 90, # Default to 90% coverage if not specified
3138
additional_instructions: str = "",
3239
use_report_coverage_feature_flag: bool = False,
40+
mutation_testing: bool = False,
41+
more_mutation_logging: bool = False,
3342
):
3443
"""
3544
Initialize the UnitTestGenerator class with the provided parameters.
@@ -65,6 +74,8 @@ def __init__(
6574
self.additional_instructions = additional_instructions
6675
self.language = self.get_code_language(source_file_path)
6776
self.use_report_coverage_feature_flag = use_report_coverage_feature_flag
77+
self.mutation_testing = mutation_testing
78+
self.more_mutation_logging = more_mutation_logging
6879
self.last_coverage_percentages = {}
6980
self.llm_model = llm_model
7081

@@ -91,6 +102,7 @@ def get_coverage_and_build_prompt(self):
91102
Returns:
92103
None
93104
"""
105+
94106
# Run coverage and build the prompt
95107
self.run_coverage()
96108
self.prompt = self.build_prompt()
@@ -213,7 +225,7 @@ def run_coverage(self):
213225
"Will default to using the full coverage report. You will need to check coverage manually for each passing test."
214226
)
215227
with open(self.code_coverage_report_path, "r") as f:
216-
self.code_coverage_report = f.read()
228+
self.code_coverage_report = f.read()
217229

218230
@staticmethod
219231
def get_included_files(included_files):
@@ -761,6 +773,109 @@ def to_dict(self):
761773
def to_json(self):
762774
return json.dumps(self.to_dict())
763775

776+
def run_mutations(self):
777+
self.logger.info("Running mutation tests")
778+
779+
# Run mutation tests
780+
781+
mutation_prompt_builder = PromptBuilder(
782+
source_file_path=self.source_file_path,
783+
test_file_path=self.test_file_path,
784+
code_coverage_report=self.code_coverage_report,
785+
included_files=self.included_files,
786+
additional_instructions=self.additional_instructions,
787+
failed_test_runs=self.failed_test_runs,
788+
language=self.language,
789+
mutation_testing=True
790+
)
791+
792+
mutation_prompt = mutation_prompt_builder.build_prompt()
793+
794+
response, prompt_token_count, response_token_count = (
795+
self.ai_caller.call_model(prompt=mutation_prompt)
796+
)
797+
798+
mutation_dict = load_yaml(response)
799+
800+
for mutation in mutation_dict["mutations"]:
801+
result = self.run_mutation(mutation)
802+
803+
# Prepare the log message with banners
804+
log_message = f"Mutation result (return code: {result.returncode}):\n"
805+
if result.returncode == 0:
806+
log_message += "Mutation survived.\n"
807+
else:
808+
log_message += "Mutation caught.\n"
809+
810+
# Add STDOUT to the log message if it's not empty
811+
if result.stdout.strip() and self.more_mutation_logging:
812+
log_message += "\n" + "="*10 + " STDOUT " + "="*10 + "\n"
813+
log_message += result.stdout
814+
815+
# Add STDERR to the log message if it's not empty
816+
if result.stderr.strip() and self.more_mutation_logging:
817+
log_message += "\n" + "="*10 + " STDERR " + "="*10 + "\n"
818+
log_message += result.stderr
819+
820+
821+
self.logger.info(log_message)
822+
823+
824+
def run_mutation(self, mutation):
825+
mutated_code = mutation.get("mutated_version", None)
826+
line_number = mutation.get("location", None)
827+
828+
829+
# Read the original content
830+
with open(self.source_file_path, "r") as source_file:
831+
original_content = source_file.readlines()
832+
833+
# Determine the indentation level of the line at line_number
834+
indentation = len(original_content[line_number]) - len(original_content[line_number].lstrip())
835+
836+
# Adjust the indentation of the mutated code
837+
adjusted_mutated_code = [
838+
' ' * indentation + line if line.strip() else line
839+
for line in mutated_code.split("\n")
840+
]
841+
842+
# Insert the mutated code at the specified spot
843+
modified_content = (
844+
original_content[:line_number - 1]
845+
+ adjusted_mutated_code
846+
+ original_content[line_number:]
847+
)
848+
849+
# Write the modified content back to the file
850+
with open(self.source_file_path, "w") as source_file:
851+
source_file.writelines(modified_content)
852+
source_file.flush()
853+
854+
# Step 2: Run the test using the Runner class
855+
self.logger.info(
856+
f'Running test with the following command: "{self.test_command}"'
857+
)
858+
stdout, stderr, exit_code, time_of_test_command = Runner.run_command(
859+
command=self.test_command, cwd=self.test_command_dir
860+
)
861+
862+
try:
863+
result = subprocess.run(
864+
split(self.test_command),
865+
text=True,
866+
capture_output=True,
867+
cwd=self.test_command_dir,
868+
timeout=30,
869+
)
870+
except Exception as e:
871+
logging.error(f"Error running test command: {e}")
872+
result = None
873+
finally:
874+
# Write the modified content back to the file
875+
with open(self.source_file_path, "w") as source_file:
876+
source_file.writelines(original_content)
877+
source_file.flush()
878+
return result
764879

765880
def extract_error_message_python(fail_message):
766881
"""

cover_agent/main.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,16 @@ def parse_args():
101101
default="",
102102
help="Path to optional log database. Default: %(default)s.",
103103
)
104+
parser.add_argument(
105+
"--mutation-testing",
106+
action="store_true",
107+
help="Setting this to True enables mutation testing. Default: False.",
108+
)
109+
parser.add_argument(
110+
"--more-mutation-logging",
111+
action="store_true",
112+
help="Setting this to True enables more logging. Default: False.",
113+
)
104114
return parser.parse_args()
105115

106116

cover_agent/settings/config_loader.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"language_extensions.toml",
88
"analyze_suite_test_headers_indentation.toml",
99
"analyze_suite_test_insert_line.toml",
10+
"mutation_test_prompt.toml",
1011
]
1112

1213

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
[mutation_test_prompt]
2+
system="""\
3+
"""
4+
5+
user="""\
6+
7+
You are an AI mutation testing agent tasked with mutating {{ language }} code to evaluate its robustness.
8+
9+
Mutation Strategy:
10+
11+
1. Logic Tweaks:
12+
Modify conditions (e.g., 'if (a < b)' to 'if (a <= b)')
13+
Adjust loop boundaries
14+
Introduce minor calculation errors
15+
Avoid drastic changes or infinite loops.
16+
17+
2. Output Modifications:
18+
Change return types or formats
19+
Alter response structures
20+
Return corrupted or incorrect data
21+
22+
3. Method Interference:
23+
Alter function parameters
24+
Replace or omit key method calls
25+
26+
4. Failure Injection:
27+
Introduce exceptions or error states
28+
Simulate system or resource failures
29+
30+
5.Data Handling Faults:
31+
Inject parsing errors
32+
Bypass data validation
33+
Corrupt object states
34+
35+
6. Boundary Condition Testing:
36+
Use out-of-bounds indices
37+
Test extreme or edge-case parameters
38+
39+
7. Concurrency Issues:
40+
Simulate race conditions or deadlocks
41+
Introduce timeouts or delays
42+
43+
8. Security Vulnerabilities:
44+
Replicate common vulnerabilities (e.g., buffer overflow, SQL injection, XSS)
45+
Introduce authentication or authorization bypasses
46+
47+
48+
Focus on subtle, realistic mutations that challenge the code's resilience while keeping core functionality intact. Prioritize scenarios likely to arise from programming errors or edge cases.
49+
50+
51+
## Source Code to add Mutations to: {{ source_file_name }}
52+
```{{language}}
53+
{{ source_file_numbered }}
54+
```
55+
56+
## Task
57+
1. Conduct a line-by-line analysis of the source code.
58+
2. Generate mutations for each test case.
59+
3. Prioritize mutating function blocks and critical code sections.
60+
4. Ensure the mutations offer meaningful insights into code quality and test coverage.
61+
5. Present the output in order of ascending line numbers.
62+
6. Avoid including manually inserted line numbers in the response.
63+
7. Limit mutations to single-line changes only.
64+
65+
Example output:
66+
```yaml
67+
file: {{source_file}}
68+
mutations:
69+
- method: <function name>
70+
category: <mutation type>
71+
summary: <brief mutation description>
72+
location: <line number>
73+
original: |
74+
<original code>
75+
mutated_version: |
76+
<mutated code with {{language}} comment explaining the change>
77+
```
78+
79+
Use block scalar('|') to format each YAML output.
80+
81+
Response (should be a valid YAML, and nothing else):
82+
```yaml
83+
84+
Generate mutants that test the code’s resilience while preserving core functionality. Output only in YAML format, with no additional explanations or comments.
85+
"""

templated_tests/python_fastapi/test_app.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from app import app
44
from datetime import date
55

6+
import math
67
client = TestClient(app)
78

89
def test_root():
@@ -13,3 +14,27 @@ def test_root():
1314
assert response.status_code == 200
1415
assert response.json() == {"message": "Welcome to the FastAPI application!"}
1516

17+
18+
def test_sqrt_negative_number():
19+
response = client.get("/sqrt/-4")
20+
assert response.status_code == 400
21+
assert response.json() == {"detail": "Cannot take square root of a negative number"}
22+
23+
24+
def test_divide_by_zero():
25+
response = client.get("/divide/10/0")
26+
assert response.status_code == 400
27+
assert response.json() == {"detail": "Cannot divide by zero"}
28+
29+
30+
def test_add():
31+
response = client.get("/add/3/5")
32+
assert response.status_code == 200
33+
assert response.json() == {"result": 8}
34+
35+
36+
def test_current_date():
37+
response = client.get("/current-date")
38+
assert response.status_code == 200
39+
assert response.json() == {"date": date.today().isoformat()}
40+

tests/test_CoverAgent.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,9 @@ def test_duplicate_test_file_with_output_path(self, mock_isfile, mock_copy):
119119
model="openai/test-model",
120120
api_base="openai/test-api",
121121
use_report_coverage_feature_flag=False,
122-
log_db_path=""
122+
log_db_path="",
123+
mutation_testing=False,
124+
more_mutation_logging=False,
123125
)
124126

125127
with pytest.raises(AssertionError) as exc_info:
@@ -154,7 +156,9 @@ def test_duplicate_test_file_without_output_path(self, mock_isfile):
154156
model="openai/test-model",
155157
api_base="openai/test-api",
156158
use_report_coverage_feature_flag=False,
157-
log_db_path=""
159+
log_db_path="",
160+
mutation_testing=False,
161+
more_mutation_logging=False,
158162
)
159163

160164
with pytest.raises(AssertionError) as exc_info:

0 commit comments

Comments
 (0)