testgenie-py 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- testgenie_py-0.1.0/PKG-INFO +24 -0
- testgenie_py-0.1.0/README.md +0 -0
- testgenie_py-0.1.0/pyproject.toml +28 -0
- testgenie_py-0.1.0/testgen/__init__.py +0 -0
- testgenie_py-0.1.0/testgen/analyzer/__init__.py +0 -0
- testgenie_py-0.1.0/testgen/analyzer/ast_analyzer.py +149 -0
- testgenie_py-0.1.0/testgen/analyzer/contracts/__init__.py +0 -0
- testgenie_py-0.1.0/testgen/analyzer/contracts/contract.py +13 -0
- testgenie_py-0.1.0/testgen/analyzer/contracts/no_exception_contract.py +16 -0
- testgenie_py-0.1.0/testgen/analyzer/contracts/nonnull_contract.py +15 -0
- testgenie_py-0.1.0/testgen/analyzer/fuzz_analyzer.py +106 -0
- testgenie_py-0.1.0/testgen/analyzer/random_feedback_analyzer.py +291 -0
- testgenie_py-0.1.0/testgen/analyzer/reinforcement_analyzer.py +75 -0
- testgenie_py-0.1.0/testgen/analyzer/test_case_analyzer.py +46 -0
- testgenie_py-0.1.0/testgen/analyzer/test_case_analyzer_context.py +58 -0
- testgenie_py-0.1.0/testgen/controller/__init__.py +0 -0
- testgenie_py-0.1.0/testgen/controller/cli_controller.py +194 -0
- testgenie_py-0.1.0/testgen/controller/docker_controller.py +169 -0
- testgenie_py-0.1.0/testgen/docker/Dockerfile +22 -0
- testgenie_py-0.1.0/testgen/docker/poetry.lock +361 -0
- testgenie_py-0.1.0/testgen/docker/pyproject.toml +22 -0
- testgenie_py-0.1.0/testgen/generator/__init__.py +0 -0
- testgenie_py-0.1.0/testgen/generator/code_generator.py +66 -0
- testgenie_py-0.1.0/testgen/generator/doctest_generator.py +208 -0
- testgenie_py-0.1.0/testgen/generator/generator.py +55 -0
- testgenie_py-0.1.0/testgen/generator/pytest_generator.py +77 -0
- testgenie_py-0.1.0/testgen/generator/test_generator.py +26 -0
- testgenie_py-0.1.0/testgen/generator/unit_test_generator.py +84 -0
- testgenie_py-0.1.0/testgen/inspector/__init__.py +0 -0
- testgenie_py-0.1.0/testgen/inspector/inspector.py +61 -0
- testgenie_py-0.1.0/testgen/main.py +13 -0
- testgenie_py-0.1.0/testgen/models/__init__.py +0 -0
- testgenie_py-0.1.0/testgen/models/analysis_context.py +56 -0
- testgenie_py-0.1.0/testgen/models/function_metadata.py +61 -0
- testgenie_py-0.1.0/testgen/models/generator_context.py +63 -0
- testgenie_py-0.1.0/testgen/models/test_case.py +8 -0
- testgenie_py-0.1.0/testgen/presentation/__init__.py +0 -0
- testgenie_py-0.1.0/testgen/presentation/cli_view.py +12 -0
- testgenie_py-0.1.0/testgen/q_table/global_q_table.json +1 -0
- testgenie_py-0.1.0/testgen/reinforcement/__init__.py +0 -0
- testgenie_py-0.1.0/testgen/reinforcement/abstract_state.py +7 -0
- testgenie_py-0.1.0/testgen/reinforcement/agent.py +153 -0
- testgenie_py-0.1.0/testgen/reinforcement/environment.py +215 -0
- testgenie_py-0.1.0/testgen/reinforcement/statement_coverage_state.py +33 -0
- testgenie_py-0.1.0/testgen/service/__init__.py +0 -0
- testgenie_py-0.1.0/testgen/service/analysis_service.py +260 -0
- testgenie_py-0.1.0/testgen/service/cfg_service.py +55 -0
- testgenie_py-0.1.0/testgen/service/generator_service.py +169 -0
- testgenie_py-0.1.0/testgen/service/service.py +389 -0
- testgenie_py-0.1.0/testgen/sqlite/__init__.py +0 -0
- testgenie_py-0.1.0/testgen/sqlite/db.py +84 -0
- testgenie_py-0.1.0/testgen/sqlite/db_service.py +219 -0
- testgenie_py-0.1.0/testgen/tree/__init__.py +0 -0
- testgenie_py-0.1.0/testgen/tree/node.py +7 -0
- testgenie_py-0.1.0/testgen/tree/tree_utils.py +79 -0
- testgenie_py-0.1.0/testgen/util/__init__.py +0 -0
- testgenie_py-0.1.0/testgen/util/coverage_utils.py +168 -0
- testgenie_py-0.1.0/testgen/util/coverage_visualizer.py +154 -0
- testgenie_py-0.1.0/testgen/util/file_utils.py +110 -0
- testgenie_py-0.1.0/testgen/util/randomizer.py +122 -0
- testgenie_py-0.1.0/testgen/util/utils.py +143 -0
- testgenie_py-0.1.0/testgen/util/z3_utils/__init__.py +0 -0
- testgenie_py-0.1.0/testgen/util/z3_utils/ast_to_z3.py +99 -0
- testgenie_py-0.1.0/testgen/util/z3_utils/branch_condition.py +72 -0
- testgenie_py-0.1.0/testgen/util/z3_utils/constraint_extractor.py +36 -0
- testgenie_py-0.1.0/testgen/util/z3_utils/variable_finder.py +10 -0
- testgenie_py-0.1.0/testgen/util/z3_utils/z3_test_case.py +94 -0
@@ -0,0 +1,24 @@
|
|
1
|
+
Metadata-Version: 2.3
|
2
|
+
Name: testgenie-py
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary:
|
5
|
+
Author: cjseitz
|
6
|
+
Author-email: charlesjseitz@gmail.com
|
7
|
+
Requires-Python: >=3.10,<4.0
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
13
|
+
Requires-Dist: ConfigArgParse (==1.7)
|
14
|
+
Requires-Dist: astor (==0.8.1)
|
15
|
+
Requires-Dist: atheris (==2.3.0)
|
16
|
+
Requires-Dist: coverage (==7.6.4)
|
17
|
+
Requires-Dist: klara (==0.6.3)
|
18
|
+
Requires-Dist: pytest (>=8.3.5,<9.0.0)
|
19
|
+
Requires-Dist: staticfg (>=0.9.5,<0.10.0)
|
20
|
+
Requires-Dist: typed-ast (==1.5.5)
|
21
|
+
Requires-Dist: z3-solver (==4.13.3.0)
|
22
|
+
Description-Content-Type: text/markdown
|
23
|
+
|
24
|
+
|
File without changes
|
@@ -0,0 +1,28 @@
|
|
1
|
+
[tool.poetry]
|
2
|
+
name = "testgenie-py"
|
3
|
+
version = "0.1.0"
|
4
|
+
description = ""
|
5
|
+
authors = ["cjseitz <charlesjseitz@gmail.com>"]
|
6
|
+
readme = "README.md"
|
7
|
+
|
8
|
+
[[tool.poetry.packages]]
|
9
|
+
include = "testgen"
|
10
|
+
|
11
|
+
[tool.poetry.scripts]
|
12
|
+
testgenie = "main:main"
|
13
|
+
|
14
|
+
[tool.poetry.dependencies]
|
15
|
+
python = "^3.10"
|
16
|
+
astor = "0.8.1"
|
17
|
+
atheris = "2.3.0"
|
18
|
+
ConfigArgParse = "1.7"
|
19
|
+
coverage = "7.6.4"
|
20
|
+
klara = "0.6.3"
|
21
|
+
typed-ast = "1.5.5"
|
22
|
+
z3-solver = "4.13.3.0"
|
23
|
+
staticfg = "^0.9.5"
|
24
|
+
pytest = "^8.3.5"
|
25
|
+
|
26
|
+
[build-system]
|
27
|
+
requires = ["poetry-core"]
|
28
|
+
build-backend = "poetry.core.masonry.api"
|
File without changes
|
File without changes
|
@@ -0,0 +1,149 @@
|
|
1
|
+
import ast
|
2
|
+
from typing import List
|
3
|
+
|
4
|
+
import testgen.util.file_utils
|
5
|
+
from testgen.models.test_case import TestCase
|
6
|
+
from testgen.analyzer.test_case_analyzer import TestCaseAnalyzerStrategy
|
7
|
+
from abc import ABC
|
8
|
+
from testgen.models.function_metadata import FunctionMetadata
|
9
|
+
|
10
|
+
class ASTAnalyzer(TestCaseAnalyzerStrategy, ABC):
|
11
|
+
def collect_test_cases(self, function_metadata: FunctionMetadata) -> List[TestCase]:
|
12
|
+
"""Collect test cases by analyzing AST conditions and return statements"""
|
13
|
+
|
14
|
+
if function_metadata and function_metadata.params:
|
15
|
+
param_names = list(function_metadata.params.keys())
|
16
|
+
else:
|
17
|
+
param_names = [arg.arg for arg in function_metadata.func_def.args.args
|
18
|
+
if arg.arg != 'self']
|
19
|
+
|
20
|
+
test_cases: List[TestCase] = self.get_conditions_recursively(
|
21
|
+
function_metadata,
|
22
|
+
function_metadata.function_name,
|
23
|
+
function_metadata.func_def.body,
|
24
|
+
param_names)
|
25
|
+
return test_cases
|
26
|
+
|
27
|
+
def get_conditions_recursively(self, function_metadata: FunctionMetadata, func_name: str, func_node_body: list, param_names, test_cases=None, conditions=None) -> List[TestCase]:
|
28
|
+
if conditions is None:
|
29
|
+
conditions = []
|
30
|
+
|
31
|
+
if test_cases is None:
|
32
|
+
test_cases = []
|
33
|
+
|
34
|
+
for node in func_node_body:
|
35
|
+
if isinstance(node, ast.If):
|
36
|
+
condition_str = self.parse_condition(node.test)
|
37
|
+
print(f"Condition found in function: {condition_str}")
|
38
|
+
self.get_conditions_recursively(function_metadata, func_name, node.body, param_names, test_cases, conditions + [condition_str])
|
39
|
+
if node.orelse:
|
40
|
+
self.get_conditions_recursively(function_metadata, func_name, node.orelse, param_names, test_cases, conditions)
|
41
|
+
|
42
|
+
elif isinstance(node, ast.Return):
|
43
|
+
inputs = self.generate_inputs_from_conditions(conditions, param_names)
|
44
|
+
|
45
|
+
input_exists = False
|
46
|
+
for existing_test in test_cases:
|
47
|
+
if existing_test.func_name == func_name and existing_test.inputs == inputs:
|
48
|
+
input_exists = True
|
49
|
+
break
|
50
|
+
|
51
|
+
if not input_exists:
|
52
|
+
try:
|
53
|
+
generated_file = function_metadata.filename
|
54
|
+
module = testgen.util.file_utils.load_module(generated_file)
|
55
|
+
|
56
|
+
# Check if we're dealing with a class
|
57
|
+
if function_metadata.class_name:
|
58
|
+
cls = getattr(module, function_metadata.class_name)
|
59
|
+
instance = cls()
|
60
|
+
func = getattr(instance, func_name)
|
61
|
+
output = func(*inputs)
|
62
|
+
else:
|
63
|
+
func = getattr(module, func_name)
|
64
|
+
output = func(*inputs)
|
65
|
+
|
66
|
+
except Exception as e:
|
67
|
+
print(f"Error executing function: {e}")
|
68
|
+
output = self._try_to_determine_output(node)
|
69
|
+
|
70
|
+
test_case = TestCase(func_name, inputs, output)
|
71
|
+
test_cases.append(test_case)
|
72
|
+
|
73
|
+
return test_cases
|
74
|
+
|
75
|
+
def _try_to_determine_output(self, return_node):
|
76
|
+
"""Try different strategies to determine the output value"""
|
77
|
+
try:
|
78
|
+
# Try direct evaluation
|
79
|
+
return ast.literal_eval(return_node.value)
|
80
|
+
except (ValueError, SyntaxError):
|
81
|
+
# Check for constants
|
82
|
+
if isinstance(return_node.value, ast.Constant):
|
83
|
+
return return_node.value.value
|
84
|
+
# Check for name values
|
85
|
+
elif isinstance(return_node.value, ast.Name):
|
86
|
+
if return_node.value.id == 'True':
|
87
|
+
return True
|
88
|
+
elif return_node.value.id == 'False':
|
89
|
+
return False
|
90
|
+
# Default fallback
|
91
|
+
return None
|
92
|
+
|
93
|
+
@staticmethod
|
94
|
+
def parse_condition(condition_node):
|
95
|
+
"""Parse an AST condition node into a string representation."""
|
96
|
+
try:
|
97
|
+
if isinstance(condition_node, ast.Compare):
|
98
|
+
left = condition_node.left.id if isinstance(condition_node.left, ast.Name) else str(condition_node.left)
|
99
|
+
op = type(condition_node.ops[0]).__name__
|
100
|
+
right = condition_node.comparators[0].value if isinstance(condition_node.comparators[0], ast.Constant) else str(condition_node.comparators[0])
|
101
|
+
return f"{left} {op} {right}"
|
102
|
+
elif isinstance(condition_node, ast.BoolOp):
|
103
|
+
# Use a simple representation for boolean operations
|
104
|
+
return f"BoolOp({type(condition_node.op).__name__})"
|
105
|
+
elif isinstance(condition_node, ast.UnaryOp):
|
106
|
+
# Use a simple representation for unary operations
|
107
|
+
return f"UnaryOp({type(condition_node.op).__name__})"
|
108
|
+
elif isinstance(condition_node, ast.Name):
|
109
|
+
return condition_node.id
|
110
|
+
elif isinstance(condition_node, ast.Constant):
|
111
|
+
return str(condition_node.value)
|
112
|
+
|
113
|
+
return ast.dump(condition_node)
|
114
|
+
except Exception as e:
|
115
|
+
print(f"Error parsing condition: {e}")
|
116
|
+
return "UnknownCondition"
|
117
|
+
|
118
|
+
@staticmethod
|
119
|
+
def generate_inputs_from_conditions(conditions, param_names):
|
120
|
+
inputs = []
|
121
|
+
|
122
|
+
# Create parameter values with more variety based on condition path
|
123
|
+
param_values = {}
|
124
|
+
|
125
|
+
# If no meaningful conditions were found, create varied inputs
|
126
|
+
if not any(param in cond for cond in conditions for param in param_names):
|
127
|
+
for i, param in enumerate(param_names):
|
128
|
+
# Alternate True/False to create diverse test cases
|
129
|
+
param_values[param] = (i % 2 == 0)
|
130
|
+
else:
|
131
|
+
# Start with all False
|
132
|
+
param_values = {param: False for param in param_names}
|
133
|
+
|
134
|
+
# Process each condition to extract parameter values
|
135
|
+
for cond in conditions:
|
136
|
+
for param in param_names:
|
137
|
+
if param in cond:
|
138
|
+
if "Eq True" in cond or "== True" in cond:
|
139
|
+
param_values[param] = True
|
140
|
+
elif "Eq False" in cond or "== False" in cond:
|
141
|
+
param_values[param] = False
|
142
|
+
elif f"Not({param})" in cond:
|
143
|
+
param_values[param] = False
|
144
|
+
|
145
|
+
# Build the input tuple
|
146
|
+
for param in param_names:
|
147
|
+
inputs.append(param_values[param])
|
148
|
+
|
149
|
+
return tuple(inputs)
|
File without changes
|
@@ -0,0 +1,13 @@
|
|
1
|
+
from abc import ABC, abstractmethod
|
2
|
+
|
3
|
+
class Contract(ABC):
|
4
|
+
@abstractmethod
|
5
|
+
def check_preconditions(self, args) -> bool:
|
6
|
+
"""Check preconditions before the method executes."""
|
7
|
+
pass
|
8
|
+
|
9
|
+
@abstractmethod
|
10
|
+
def check_postconditions(self, args, output, exception) -> bool:
|
11
|
+
"""Check postconditions before the method executes."""
|
12
|
+
pass
|
13
|
+
|
@@ -0,0 +1,16 @@
|
|
1
|
+
from testgen.analyzer.contracts.contract import Contract
|
2
|
+
from abc import ABC
|
3
|
+
|
4
|
+
class NoExceptionContract(Contract, ABC):
|
5
|
+
"""Ensures function does not raise an exception"""
|
6
|
+
|
7
|
+
def check_preconditions(self, args) -> bool:
|
8
|
+
"""Returns False if there is a violation"""
|
9
|
+
return True
|
10
|
+
|
11
|
+
"""Returns False if there is an exception"""
|
12
|
+
def check_postconditions(self, args, output, exception) -> bool:
|
13
|
+
if exception is not None:
|
14
|
+
return True
|
15
|
+
else:
|
16
|
+
return False
|
@@ -0,0 +1,15 @@
|
|
1
|
+
from abc import ABC
|
2
|
+
|
3
|
+
from testgen.analyzer.contracts.contract import Contract
|
4
|
+
|
5
|
+
class NonNullContract(Contract, ABC):
|
6
|
+
"""Ensures the function does not contain null or None input values."""
|
7
|
+
def check_preconditions(self, args) -> bool:
|
8
|
+
return all(arg is not None for arg in args)
|
9
|
+
|
10
|
+
"""Ensures the return value is not None"""
|
11
|
+
def check_postconditions(self, args, output, exception) -> bool:
|
12
|
+
if exception:
|
13
|
+
return False
|
14
|
+
else:
|
15
|
+
return output is not None
|
@@ -0,0 +1,106 @@
|
|
1
|
+
from abc import ABC
|
2
|
+
import os
|
3
|
+
import traceback
|
4
|
+
from typing import List
|
5
|
+
|
6
|
+
from atheris.native import FuzzedDataProvider
|
7
|
+
|
8
|
+
import coverage
|
9
|
+
|
10
|
+
from testgen.models.test_case import TestCase
|
11
|
+
from testgen.analyzer.test_case_analyzer import TestCaseAnalyzerStrategy
|
12
|
+
|
13
|
+
from testgen.models.function_metadata import FunctionMetadata
|
14
|
+
|
15
|
+
class FuzzAnalyzer(TestCaseAnalyzerStrategy, ABC):
|
16
|
+
|
17
|
+
def __init__(self, analysis_context=None):
|
18
|
+
super().__init__(analysis_context)
|
19
|
+
self.coverage_tracker = coverage.Coverage(branch=True)
|
20
|
+
self.executed_branches = set()
|
21
|
+
|
22
|
+
# TODO: Use getattr() to get function in FunctionMetadata
|
23
|
+
def collect_test_cases(self, function_metadata: FunctionMetadata) -> List[TestCase]:
|
24
|
+
"""Collect test cases using fuzzing techniques"""
|
25
|
+
|
26
|
+
# Use module from function_metadata if available
|
27
|
+
if function_metadata and function_metadata.module:
|
28
|
+
module = self.analysis_context.module
|
29
|
+
else:
|
30
|
+
raise ValueError("Module not set in function metadata. Cannot perform fuzzing without a module.")
|
31
|
+
|
32
|
+
class_name = function_metadata.class_name if function_metadata.class_name else None
|
33
|
+
try:
|
34
|
+
if not class_name is None:
|
35
|
+
cls = getattr(module, class_name, None)
|
36
|
+
func = getattr(cls(), function_metadata.function_name, None) if cls else None
|
37
|
+
else:
|
38
|
+
func = getattr(module, function_metadata.function_name, None)
|
39
|
+
if func:
|
40
|
+
return self.run_fuzzing(func, function_metadata.function_name, function_metadata.params, module, 10)
|
41
|
+
except Exception as e:
|
42
|
+
print(f"[FUZZ ANALYZER ERROR]: {e}")
|
43
|
+
traceback.print_exc()
|
44
|
+
return []
|
45
|
+
|
46
|
+
def run_fuzzing(self, func, func_name, param_types, module, iterations=10) -> List[TestCase]:
|
47
|
+
"""Run the function with fuzzed inputs and collect failing test cases."""
|
48
|
+
print(f"Running fuzzing {func_name}")
|
49
|
+
test_cases = []
|
50
|
+
|
51
|
+
for _ in range(iterations):
|
52
|
+
fdp = FuzzedDataProvider(os.urandom(1024))
|
53
|
+
inputs = self.generate_inputs_from_fuzz_data(fdp, param_types)
|
54
|
+
|
55
|
+
self.coverage_tracker.erase()
|
56
|
+
self.coverage_tracker.start()
|
57
|
+
|
58
|
+
try:
|
59
|
+
output = func(*inputs)
|
60
|
+
self.coverage_tracker.stop()
|
61
|
+
covered_branches = self.get_branch_coverage(module)
|
62
|
+
covered_branches_tuple = tuple(covered_branches)
|
63
|
+
|
64
|
+
print(f"[COVERED_BRANCHES]: {covered_branches}")
|
65
|
+
print(f"[EXECUTED BRANCHES]: {self.executed_branches}")
|
66
|
+
|
67
|
+
for branch in covered_branches:
|
68
|
+
if branch[1] < 0:
|
69
|
+
if covered_branches_tuple not in self.executed_branches:
|
70
|
+
self.executed_branches.add(covered_branches_tuple)
|
71
|
+
test_cases.append(TestCase(func_name, inputs, output))
|
72
|
+
|
73
|
+
except Exception as e:
|
74
|
+
self.coverage_tracker.stop()
|
75
|
+
test_cases.append(TestCase(func_name, inputs, (type(e), str(e) + " EXCEPTION")))
|
76
|
+
self.executed_branches.clear()
|
77
|
+
return test_cases
|
78
|
+
|
79
|
+
|
80
|
+
# TODO: Look into FuzzDataProvider.instrumentall()
|
81
|
+
@staticmethod
|
82
|
+
def generate_inputs_from_fuzz_data(fdp: FuzzedDataProvider, param_types):
|
83
|
+
"""Generate fuzzed inputs based on parameter types."""
|
84
|
+
inputs = []
|
85
|
+
for param_type in param_types.values():
|
86
|
+
if param_type == "int":
|
87
|
+
inputs.append(fdp.ConsumeInt(4))
|
88
|
+
elif param_type == "bool":
|
89
|
+
inputs.append(fdp.ConsumeBool())
|
90
|
+
elif param_type == "float":
|
91
|
+
inputs.append(fdp.ConsumeFloat())
|
92
|
+
elif param_type == "str":
|
93
|
+
inputs.append(fdp.ConsumeString(10))
|
94
|
+
elif param_type == "bytes":
|
95
|
+
inputs.append(fdp.ConsumeBytes(10))
|
96
|
+
else:
|
97
|
+
inputs.append(None)
|
98
|
+
return tuple(inputs)
|
99
|
+
|
100
|
+
def get_branch_coverage(self, module):
|
101
|
+
data = self.coverage_tracker.get_data()
|
102
|
+
try:
|
103
|
+
return data.arcs(module.__file__)
|
104
|
+
except Exception as e:
|
105
|
+
print(f"[ERROR: FUZZ ANALYZER] Couldn't get arcs Exception: {e}")
|
106
|
+
return set()
|
@@ -0,0 +1,291 @@
|
|
1
|
+
import ast
|
2
|
+
import importlib
|
3
|
+
import random
|
4
|
+
import time
|
5
|
+
import traceback
|
6
|
+
from typing import List, Dict, Set
|
7
|
+
|
8
|
+
import testgen.util.randomizer
|
9
|
+
import testgen.util.utils as utils
|
10
|
+
import testgen.util.coverage_utils as coverage_utils
|
11
|
+
from testgen.analyzer.contracts.contract import Contract
|
12
|
+
from testgen.analyzer.contracts.no_exception_contract import NoExceptionContract
|
13
|
+
from testgen.analyzer.contracts.nonnull_contract import NonNullContract
|
14
|
+
from testgen.models.test_case import TestCase
|
15
|
+
from testgen.analyzer.test_case_analyzer import TestCaseAnalyzerStrategy
|
16
|
+
from abc import ABC
|
17
|
+
|
18
|
+
from testgen.models.function_metadata import FunctionMetadata
|
19
|
+
|
20
|
+
|
21
|
+
# Citation in which this method and algorithm were taken from:
|
22
|
+
# C. Pacheco, S. K. Lahiri, M. D. Ernst and T. Ball, "Feedback-Directed Random Test Generation," 29th International
|
23
|
+
# Conference on Software Engineering (ICSE'07), Minneapolis, MN, USA, 2007, pp. 75-84, doi: 10.1109/ICSE.2007.37.
|
24
|
+
# keywords: {System testing;Contracts;Object oriented modeling;Law;Legal factors;Open source software;Software
|
25
|
+
# testing;Feedback;Filters;Error correction codes},
|
26
|
+
|
27
|
+
class RandomFeedbackAnalyzer(TestCaseAnalyzerStrategy, ABC):
|
28
|
+
def __init__(self, analysis_context=None):
|
29
|
+
super().__init__(analysis_context)
|
30
|
+
self.test_cases = []
|
31
|
+
self.covered_lines: Dict[str, Set[int]] = {}
|
32
|
+
|
33
|
+
# Algorithm described in above article
|
34
|
+
# Classes is the classes for which we want to generate sequences
|
35
|
+
# Contracts express invariant properties that hold both at entry and exit from a call
|
36
|
+
# Contract takes as input the current state of the system (runtime values created in the sequence so far, and any exception thrown by the last call), and returns satisfied or violated
|
37
|
+
# Output is the runtime values and boolean flag violated
|
38
|
+
# Filters determine which values of a sequence are extensible and should be used as inputs
|
39
|
+
def generate_sequences(self, function_metadata: List[FunctionMetadata], classes=None, contracts: List[Contract] = None, filters=None, time_limit=20):
|
40
|
+
contracts = [NonNullContract(), NoExceptionContract()]
|
41
|
+
error_seqs = [] # execution violates a contract
|
42
|
+
non_error_seqs = [] # execution does not violate a contract
|
43
|
+
|
44
|
+
functions = self._analysis_context.function_data
|
45
|
+
start_time = time.time()
|
46
|
+
while(time.time() - start_time) >= time_limit:
|
47
|
+
# Get random function
|
48
|
+
func = random.choice(functions)
|
49
|
+
param_types: dict = func.params
|
50
|
+
vals: dict = self.random_seqs_and_vals(param_types)
|
51
|
+
new_seq = (func.function_name, vals)
|
52
|
+
if new_seq in error_seqs or new_seq in non_error_seqs:
|
53
|
+
continue
|
54
|
+
outs_violated: tuple = self.execute_sequence(new_seq, contracts)
|
55
|
+
violated: bool = outs_violated[1]
|
56
|
+
# Create tuple of sequence ((func name, args), output)
|
57
|
+
new_seq_out = (new_seq, outs_violated[0])
|
58
|
+
if violated:
|
59
|
+
error_seqs.append(new_seq_out)
|
60
|
+
else:
|
61
|
+
# Question: Should I use the failed contract to be the assertion in unit test??
|
62
|
+
non_error_seqs.append(new_seq_out)
|
63
|
+
return error_seqs, non_error_seqs
|
64
|
+
|
65
|
+
def generate_sequences_new(self, contracts: List[Contract] = None, filters=None, time_limit=20):
|
66
|
+
contracts = [NonNullContract(), NoExceptionContract()]
|
67
|
+
error_seqs = [] # execution violates a contract
|
68
|
+
non_error_seqs = [] # execution does not violate a contract
|
69
|
+
|
70
|
+
functions = self._analysis_context.function_data.copy()
|
71
|
+
start_time = time.time()
|
72
|
+
|
73
|
+
while (time.time() - start_time) < time_limit:
|
74
|
+
# Get random function
|
75
|
+
func = random.choice(functions)
|
76
|
+
param_types: dict = func.params
|
77
|
+
vals: dict = self.random_seqs_and_vals(param_types)
|
78
|
+
new_seq = (func.function_name, vals)
|
79
|
+
|
80
|
+
if new_seq in [seq[0] for seq in error_seqs] or new_seq in [seq[0] for seq in non_error_seqs]:
|
81
|
+
continue
|
82
|
+
|
83
|
+
outs_violated: tuple = self.execute_sequence(new_seq, contracts)
|
84
|
+
violated: bool = outs_violated[1]
|
85
|
+
|
86
|
+
# Create tuple of sequence ((func name, args), output)
|
87
|
+
new_seq_out = (new_seq, outs_violated[0])
|
88
|
+
|
89
|
+
if violated:
|
90
|
+
error_seqs.append(new_seq_out)
|
91
|
+
|
92
|
+
else:
|
93
|
+
non_error_seqs.append(new_seq_out)
|
94
|
+
|
95
|
+
test_case = TestCase(new_seq_out[0][0], tuple(new_seq_out[0][1].values()), new_seq_out[1])
|
96
|
+
self.test_cases.append(test_case)
|
97
|
+
fully_covered = self.covered(func)
|
98
|
+
if fully_covered:
|
99
|
+
print(f"Function {func.function_name} is fully covered")
|
100
|
+
functions.remove(func)
|
101
|
+
|
102
|
+
if not functions:
|
103
|
+
self.test_cases.sort(key=lambda tc: tc.func_name)
|
104
|
+
print("All functions covered")
|
105
|
+
break
|
106
|
+
|
107
|
+
self.test_cases.sort(key=lambda tc: tc.func_name)
|
108
|
+
return error_seqs, non_error_seqs
|
109
|
+
|
110
|
+
|
111
|
+
def covered(self, func: FunctionMetadata) -> bool:
|
112
|
+
if func.function_name not in self.covered_lines:
|
113
|
+
self.covered_lines[func.function_name] = set()
|
114
|
+
|
115
|
+
for test_case in [tc for tc in self.test_cases if tc.func_name == func.function_name]:
|
116
|
+
analysis = coverage_utils.get_coverage_analysis(self._analysis_context.filepath,
|
117
|
+
func.function_name, test_case.inputs)
|
118
|
+
covered = coverage_utils.get_list_of_covered_statements(analysis)
|
119
|
+
self.covered_lines[func.function_name].update(covered)
|
120
|
+
|
121
|
+
executable_statements = self.get_all_executable_statements(func)
|
122
|
+
|
123
|
+
return self.covered_lines[func.function_name] == executable_statements
|
124
|
+
|
125
|
+
def execute_sequence(self, sequence, contracts: List[Contract]):
|
126
|
+
"""Execute a sequence and check contract violations"""
|
127
|
+
func_name, args_dict = sequence
|
128
|
+
args = tuple(args_dict.values()) # Convert dict values to tuple
|
129
|
+
|
130
|
+
try:
|
131
|
+
# Use module from analysis context if available
|
132
|
+
module = self.analysis_context.module
|
133
|
+
|
134
|
+
if self._analysis_context.class_name:
|
135
|
+
cls = getattr(module, self._analysis_context.class_name, None)
|
136
|
+
if cls is None:
|
137
|
+
raise AttributeError(f"Class '{self._analysis_context.class_name}' not found")
|
138
|
+
obj = cls() # Instantiate the class
|
139
|
+
func = getattr(obj, func_name, None)
|
140
|
+
|
141
|
+
import inspect
|
142
|
+
sig = inspect.signature(func)
|
143
|
+
param_names = [p.name for p in sig.parameters.values() if p.name != 'self']
|
144
|
+
else:
|
145
|
+
func = getattr(module, func_name, None)
|
146
|
+
|
147
|
+
import inspect
|
148
|
+
sig = inspect.signature(func)
|
149
|
+
param_names = [p.name for p in sig.parameters.values()]
|
150
|
+
|
151
|
+
# Create ordered arguments based on function signature
|
152
|
+
ordered_args = []
|
153
|
+
for name in param_names:
|
154
|
+
if name in args_dict:
|
155
|
+
ordered_args.append(args_dict[name])
|
156
|
+
|
157
|
+
# Check preconditions
|
158
|
+
for contract in contracts:
|
159
|
+
if not contract.check_preconditions(tuple(ordered_args)):
|
160
|
+
print(f"Preconditions failed for {func_name} with {tuple(ordered_args)}")
|
161
|
+
return None, True
|
162
|
+
|
163
|
+
# Execute function with properly ordered arguments
|
164
|
+
output = func(*ordered_args)
|
165
|
+
exception = None
|
166
|
+
|
167
|
+
except Exception as e:
|
168
|
+
print(f"EXCEPTION IN RANDOM FEEDBACK: {e}")
|
169
|
+
print(traceback.format_exc())
|
170
|
+
output = None
|
171
|
+
exception = e
|
172
|
+
|
173
|
+
# Check postconditions
|
174
|
+
for contract in contracts:
|
175
|
+
if not contract.check_postconditions(tuple(ordered_args), output, exception):
|
176
|
+
print(f"Postcondition failed for {func_name} with {tuple(ordered_args)}")
|
177
|
+
return output, True
|
178
|
+
|
179
|
+
return output, False
|
180
|
+
|
181
|
+
|
182
|
+
# TODO: Currently only getting random vals of primitives, extend to sequences
|
183
|
+
def random_seqs_and_vals(self, param_types, non_error_seqs=None):
|
184
|
+
return self.generate_random_inputs(param_types)
|
185
|
+
|
186
|
+
@staticmethod
|
187
|
+
def extract_parameter_types(func_node):
|
188
|
+
"""Extract parameter types from a function node."""
|
189
|
+
param_types = {}
|
190
|
+
for arg in func_node.args.args:
|
191
|
+
param_name = arg.arg
|
192
|
+
if arg.annotation:
|
193
|
+
param_type = ast.unparse(arg.annotation)
|
194
|
+
param_types[param_name] = param_type
|
195
|
+
else:
|
196
|
+
if param_name != 'self':
|
197
|
+
param_types[param_name] = None
|
198
|
+
return param_types
|
199
|
+
|
200
|
+
@staticmethod
|
201
|
+
def generate_random_inputs(param_types):
|
202
|
+
"""Generate inputs for fuzzing based on parameter types."""
|
203
|
+
inputs = {}
|
204
|
+
for param, param_type in param_types.items():
|
205
|
+
if param_type == "int":
|
206
|
+
random_integer = random.randint(1, 100)
|
207
|
+
inputs[param] = random_integer
|
208
|
+
if param_type == "bool":
|
209
|
+
random_choice = random.choice([True, False])
|
210
|
+
inputs[param] = random_choice
|
211
|
+
if param_type == "float":
|
212
|
+
random_float = random.random()
|
213
|
+
inputs[param] = random_float
|
214
|
+
# TODO: Random String and Random bytes; Random objects?
|
215
|
+
if param_type == "str":
|
216
|
+
inputs[param] = "abc"
|
217
|
+
#elif param_type == "bytes":
|
218
|
+
# inputs[param] = fdp.ConsumeBytes(10)
|
219
|
+
#else:
|
220
|
+
# inputs[param] = None
|
221
|
+
return inputs
|
222
|
+
|
223
|
+
def collect_test_cases(self, function_metadata: FunctionMetadata) -> List[TestCase]:
|
224
|
+
"""Collect test cases using random feedback technique"""
|
225
|
+
error_seqs, non_error_seqs = self.generate_sequences_new()
|
226
|
+
test_cases = []
|
227
|
+
|
228
|
+
# Process error sequences
|
229
|
+
if error_seqs:
|
230
|
+
for error_seq in error_seqs:
|
231
|
+
print("ERROR SEQ OUTPUT:", error_seq[1])
|
232
|
+
test_cases.append(TestCase(error_seq[0][0], tuple(error_seq[0][1].values()), error_seq[1]))
|
233
|
+
|
234
|
+
# Process non-error sequences
|
235
|
+
if non_error_seqs:
|
236
|
+
for non_error_seq in non_error_seqs:
|
237
|
+
print("NON ERROR SEQ OUTPUT:", non_error_seq[1])
|
238
|
+
test_cases.append(TestCase(non_error_seq[0][0], tuple(non_error_seq[0][1].values()), non_error_seq[1]))
|
239
|
+
|
240
|
+
return self.test_cases
|
241
|
+
|
242
|
+
def get_all_executable_statements(self, func: FunctionMetadata):
|
243
|
+
"""Get all executable statements including else branches"""
|
244
|
+
import ast
|
245
|
+
|
246
|
+
test_cases = [tc for tc in self.test_cases if tc.func_name == func.function_name]
|
247
|
+
|
248
|
+
if not test_cases:
|
249
|
+
print("Warning: No test cases available to determine executable statements")
|
250
|
+
from testgen.util.randomizer import new_random_test_case
|
251
|
+
temp_case = new_random_test_case(self._analysis_context.filepath, func.func_def)
|
252
|
+
analysis = coverage_utils.get_coverage_analysis(self._analysis_context.filepath, func.function_name,
|
253
|
+
temp_case.inputs)
|
254
|
+
else:
|
255
|
+
analysis = coverage_utils.get_coverage_analysis(self._analysis_context.filepath, func.function_name, test_cases[0].inputs)
|
256
|
+
|
257
|
+
# Get standard executable lines from coverage.py
|
258
|
+
executable_lines = list(analysis[1])
|
259
|
+
|
260
|
+
# Parse the source file to find else branches
|
261
|
+
with open(self._analysis_context.filepath, 'r') as f:
|
262
|
+
source = f.read()
|
263
|
+
|
264
|
+
# Parse the code
|
265
|
+
tree = ast.parse(source)
|
266
|
+
|
267
|
+
# Find our specific function
|
268
|
+
for node in ast.walk(tree):
|
269
|
+
if isinstance(node, ast.FunctionDef) and node.name == func.func_def.name:
|
270
|
+
# Find all if statements in this function
|
271
|
+
for if_node in ast.walk(node):
|
272
|
+
if isinstance(if_node, ast.If) and if_node.orelse:
|
273
|
+
# There's an else branch
|
274
|
+
if isinstance(if_node.orelse[0], ast.If):
|
275
|
+
# This is an elif - already counted
|
276
|
+
continue
|
277
|
+
|
278
|
+
# Get the line number of the first statement in the else block
|
279
|
+
# and subtract 1 to get the 'else:' line
|
280
|
+
else_line = if_node.orelse[0].lineno - 1
|
281
|
+
|
282
|
+
# Check if this is actually an else line (not a nested if)
|
283
|
+
with open(self._analysis_context.filepath, 'r') as f:
|
284
|
+
lines = f.readlines()
|
285
|
+
if else_line <= len(lines):
|
286
|
+
line_content = lines[else_line - 1].strip()
|
287
|
+
if line_content == "else:":
|
288
|
+
if else_line not in executable_lines:
|
289
|
+
executable_lines.append(else_line)
|
290
|
+
|
291
|
+
return sorted(executable_lines)
|