Skip to content

Commit 48be8a1

Browse files
committed
feat: improve mutant report with more detailed information
1 parent c553212 commit 48be8a1

File tree

7 files changed

+290
-410
lines changed

7 files changed

+290
-410
lines changed

README.md

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,20 @@ $ mutahunter run --test-command "pytest tests/unit" --code-coverage-report-path
6262

6363
# Run mutation testing on modified files based on the latest commit
6464
$ mutahunter run --test-command "pytest tests/unit" --code-coverage-report-path "coverage.xml" --modified-files-only
65+
66+
. . . . .-. .-. . . . . . . .-. .-. .-.
67+
|\/| | | | |-| |-| | | |\| | |- |(
68+
' ` `-' ' ` ' ' ` `-' ' ` ' `-' ' '
69+
70+
2024-07-05 00:26:13,420 INFO: 🦠 Total Mutants: 13 🦠
71+
2024-07-05 00:26:13,420 INFO: 🛡️ Survived Mutants: 5 🛡️
72+
2024-07-05 00:26:13,420 INFO: 🗡️ Killed Mutants: 8 🗡️
73+
2024-07-05 00:26:13,421 INFO: 🕒 Timeout Mutants: 0 🕒
74+
2024-07-05 00:26:13,421 INFO: 🔥 Compile Error Mutants: 0 🔥
75+
2024-07-05 00:26:13,421 INFO: 🎯 Mutation Coverage: 61.54% 🎯
76+
2024-07-05 00:26:13,421 INFO: Report saved to logs/_latest/mutation_coverage.json
77+
2024-07-05 00:26:13,421 INFO: Report saved to logs/_latest/mutation_coverage_detail.json
78+
2024-07-05 00:26:13,421 INFO: Mutation Testing Ended. Took 43s
6579
```
6680
6781
### Examples
@@ -127,24 +141,9 @@ Options:
127141
128142
Check the logs directory to view the report:
129143
130-
- `mutants_killed.json` - Contains the list of mutants that were killed by the test suite.
131-
- `mutants_survived.json` - Contains the list of mutants that survived the test suite.
132-
- `mutation_coverage.json` - Contains the mutation coverage report.
133-
134-
```json
135-
[
136-
{
137-
"id": "4",
138-
"source_path": "src/mutahunter/core/analyzer.py",
139-
"mutant_path": "/Users/taikorind/Documents/personal/codeintegrity/mutahunter/logs/_latest/mutants/4_analyzer.py",
140-
"status": "SURVIVED",
141-
"error_msg": "",
142-
"diff": "for line in range(start_line, end_line + 1):
143-
- function_executed_lines.append(line - start_line + 1)
144-
+ function_executed_lines.append(line - start_line) # Mutation: Change the calculation of executed lines to start from 0 instead of 1.\n"
145-
},
146-
]
147-
```
144+
- `mutants.json` - Contains the list of mutants generated.
145+
- `mutation_coverage.json` - Contains the mutation coverage percentage.
146+
- `mutation_coverage_detail.json` - Contains detailed information per source file.
148147
149148
## Cash Bounty Program
150149

src/mutahunter/core/entities/mutant.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class Mutant:
1515
id (str): The unique identifier of the mutant.
1616
source_path (str): The path to the original source file.
1717
mutant_path (str): The path to the file containing the mutant.
18-
status (Union[None, str]): The status of the mutant (e.g., "SURVIVED", "KILLED").
18+
status (Union[None, str]): The status of the mutant (e.g., "SURVIVED", "KILLED", "COMPILE_ERROR").
1919
error_msg (str): The error message associated with the mutant, if any.
2020
mutant_code (str): The code of the mutant.
2121
type (str): The type of mutation applied to the code.
@@ -24,7 +24,7 @@ class Mutant:
2424

2525
id: str
2626
source_path: str
27-
mutant_path: str
27+
mutant_path: Union[None, str] = None
2828
status: Union[None, str] = None
2929
error_msg: str = ""
3030
mutant_code: str = ""

src/mutahunter/core/hunter.py

Lines changed: 39 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os
22
import subprocess
33
import time
4-
from typing import Any, Dict, Generator, List
4+
from typing import Any, Dict, List
55

66
from tqdm import tqdm
77

@@ -17,15 +17,6 @@ class MutantHunter:
1717
def __init__(self, config: Dict[str, Any]) -> None:
1818
"""
1919
Initializes the MutantHunter class with the given configuration.
20-
21-
Args:
22-
config (Dict[str, Any]): Configuration dictionary containing various settings.
23-
- model (str): LLM model to use for mutation testing.
24-
- api_base (str): Base URL for self-hosted LLM models.
25-
- test_command (str): Command to run the tests.
26-
- code_coverage_report_path (Optional[str]): Path to the code coverage report file.
27-
- exclude_files (List[str]): List of files to exclude from analysis.
28-
- only_mutate_file_paths (List[str]): List of specific files to mutate.
2920
"""
3021
self.config: Dict[str, Any] = config
3122
self.mutants: List[Mutant] = []
@@ -52,40 +43,28 @@ def run(self) -> None:
5243
self.mutant_report.generate_report(self.mutants)
5344
logger.info(f"Mutation Testing Ended. Took {round(time.time() - start)}s")
5445
except Exception as e:
55-
import traceback
56-
5746
logger.error(
5847
"Error during mutation testing. Please report this issue.",
5948
exc_info=True,
6049
)
61-
print(traceback.format_exc())
6250

6351
def should_skip_file(self, filename: str) -> bool:
6452
"""
6553
Determines if a file should be skipped based on various conditions.
66-
67-
Args:
68-
filename (str): The filename to check.
69-
70-
Returns:
71-
bool: True if the file should be skipped, False otherwise.
7254
"""
7355
logger.debug(f"Checking if file should be skipped: {filename}")
7456
if self.config["only_mutate_file_paths"]:
75-
# NOTE: Check if the file exists before proceeding.
7657
for file_path in self.config["only_mutate_file_paths"]:
7758
if not os.path.exists(file_path):
7859
logger.error(f"File {file_path} does not exist.")
7960
raise FileNotFoundError(f"File {file_path} does not exist.")
80-
# NOTE: Only mutate the files specified in the config.
8161
return all(
8262
file_path != filename
8363
for file_path in self.config["only_mutate_file_paths"]
8464
)
8565
if filename in self.config["exclude_files"]:
8666
return True
8767

88-
# NOTE: Line coverage may contains test files. Exclue them from mutation testing.
8968
TEST_FILE_PATTERNS = [
9069
"test_",
9170
"_test",
@@ -138,9 +117,7 @@ def run_mutation_testing_on_modified_files(self) -> None:
138117

139118
self.generate_mutations(file_path, modified_lines)
140119

141-
def generate_mutations(
142-
self, file_path: str, executed_lines: List[int]
143-
) -> Generator[Dict[str, Any], None, None]:
120+
def generate_mutations(self, file_path: str, executed_lines: List[int]) -> None:
144121
"""
145122
Generates mutations for a single file based on the executed lines.
146123
"""
@@ -179,29 +156,26 @@ def generate_mutations(
179156
self.process_mutant(mutant_data, file_path, start_byte, end_byte)
180157

181158
def process_mutant(
182-
self, mutant_data: Dict[str, Any], source_file_path, start_byte, end_byte
159+
self,
160+
mutant_data: Dict[str, Any],
161+
source_file_path: str,
162+
start_byte: int,
163+
end_byte: int,
183164
) -> None:
184165
"""
185166
Processes a single mutant data dictionary.
186167
"""
187-
try:
188-
logger.info(f"Processing mutant for file: {source_file_path}")
189-
mutant_id = str(len(self.mutants) + 1)
190-
mutant_path = self.prepare_mutant_file(
191-
mutant_id=mutant_id,
192-
source_file_path=source_file_path,
193-
start_byte=start_byte,
194-
end_byte=end_byte,
195-
mutant_code=mutant_data["mutant_code"],
196-
)
197-
mutant = Mutant(
198-
id=mutant_id,
199-
source_path=source_file_path,
200-
mutant_path=mutant_path,
201-
mutant_code=mutant_data["mutant_code"],
202-
type=mutant_data["type"],
203-
description=mutant_data["description"],
204-
)
168+
mutant = Mutant(
169+
id=str(len(self.mutants) + 1),
170+
source_path=source_file_path,
171+
mutant_code=mutant_data["mutant_code"],
172+
type=mutant_data["type"],
173+
description=mutant_data["description"],
174+
)
175+
mutant_path = self.prepare_mutant_file(mutant, start_byte, end_byte)
176+
177+
if mutant_path: # Only run tests if the mutant file is prepared successfully
178+
mutant.mutant_path = mutant_path
205179
result = self.run_test(
206180
{
207181
"module_path": source_file_path,
@@ -210,72 +184,47 @@ def process_mutant(
210184
}
211185
)
212186
self.process_test_result(result, mutant)
213-
except Exception as e:
214-
logger.error(
215-
f"Error processing mutant for file: {source_file_path}",
216-
exc_info=True,
217-
)
187+
else:
188+
mutant.status = "COMPILE_ERROR"
189+
190+
self.mutants.append(mutant)
218191

219192
def prepare_mutant_file(
220-
self,
221-
mutant_id: str,
222-
source_file_path: str,
223-
start_byte: int,
224-
end_byte: int,
225-
mutant_code: str,
193+
self, mutant: Mutant, start_byte: int, end_byte: int
226194
) -> str:
227195
"""
228196
Prepares the mutant file for testing.
229-
230-
Args:
231-
mutant_id (str): The ID of the mutant.
232-
source_path (str): The path to the original source file.
233-
start_byte (int): The start byte position of the mutation.
234-
end_byte (int): The end byte position of the mutation.
235-
mutant_code (str): The mutated code snippet.
236-
237-
Returns:
238-
str: The path to the mutant file.
239-
240-
Raises:
241-
Exception: If the mutant code has syntax errors.
242197
"""
243-
mutant_file_name = f"{mutant_id}_{os.path.basename(source_file_path)}"
198+
mutant_file_name = f"{mutant.id}_{os.path.basename(mutant.source_path)}"
244199
mutant_path = os.path.join(
245200
os.getcwd(), f"logs/_latest/mutants/{mutant_file_name}"
246201
)
247202
logger.debug(f"Preparing mutant file: {mutant_path}")
248203

249-
with open(source_file_path, "rb") as f:
204+
with open(mutant.source_path, "rb") as f:
250205
source_code = f.read()
251206

252207
modified_byte_code = (
253208
source_code[:start_byte]
254-
+ bytes(mutant_code, "utf-8")
209+
+ bytes(mutant.mutant_code, "utf-8")
255210
+ source_code[end_byte:]
256211
)
257212

258213
if self.analyzer.check_syntax(
259-
source_file_path=source_file_path,
214+
source_file_path=mutant.source_path,
260215
source_code=modified_byte_code.decode("utf-8"),
261216
):
262217
with open(mutant_path, "wb") as f:
263218
f.write(modified_byte_code)
264219
logger.info(f"Mutant file prepared: {mutant_path}")
265220
return mutant_path
266221
else:
267-
logger.error(f"Syntax error in mutant code for file: {source_file_path}")
268-
raise SyntaxError("Mutant code has syntax errors.")
222+
logger.error(f"Syntax error in mutant code for file: {mutant.source_path}")
223+
return ""
269224

270225
def run_test(self, params: Dict[str, str]) -> Any:
271226
"""
272227
Runs the test command on the given parameters.
273-
274-
Args:
275-
params (Dict[str, str]): Dictionary containing test command parameters.
276-
277-
Returns:
278-
Any: The result of the test command execution.
279228
"""
280229
logger.info(
281230
f"Running test command: {params['test_command']} for mutant file: {params['replacement_module_path']}"
@@ -295,12 +244,18 @@ def process_test_result(self, result: Any, mutant: Mutant) -> None:
295244
mutant.status = "SURVIVED"
296245
elif result.returncode == 1:
297246
logger.info(f"🗡️ Mutant {mutant.id} killed 🗡️\n")
298-
mutant.error_msg = result.stderr + result.stdout
247+
error_output = result.stderr + result.stdout.lower()
248+
mutant.error_msg = error_output
299249
mutant.status = "KILLED"
250+
elif result.returncode == 2:
251+
logger.info(f"⏱️ Mutant {mutant.id} timed out ⏱️\n")
252+
mutant.error_msg = result.stderr
253+
mutant.status = "TIMEOUT"
300254
else:
301-
logger.error(f"Mutant {mutant.id} failed to run tests.")
302-
return
303-
self.mutants.append(mutant)
255+
error_output = result.stderr + result.stdout
256+
logger.info(f"🔧 Mutant {mutant.id} caused a compile error 🔧\n")
257+
mutant.error_msg = error_output
258+
mutant.status = "COMPILE_ERROR"
304259

305260
def get_modified_files(self) -> List[str]:
306261
"""

0 commit comments

Comments
 (0)