Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions besser/generators/testgen/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .test_generator import TestGenerator as TestGenerator
281 changes: 281 additions & 0 deletions besser/generators/testgen/templates/hypothesis_tests_template.py.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
import inspect
import pytest
from hypothesis import given, assume, settings
import hypothesis.strategies as st
import copy
from datetime import date

from classes import (
{%- for class in domain.classes_sorted_by_inheritance() %}
{{ class.name }},
{%- endfor %}
{%- if domain.get_enumerations() %}
{%- for enum in domain.get_enumerations() %}
{{ enum.name }},
{%- endfor %}
{%- endif %}
)

# =============================================================================
# SECTION 1 — STRUCTURAL TESTS
# =============================================================================

{%- for class in domain.classes_sorted_by_inheritance() %}



def test_{{ class.name | lower }}_is_not_abstract():
assert not inspect.isabstract({{ class.name }})


def test_{{ class.name | lower }}_constructor_exists():
assert callable({{ class.name }}.__init__)


def test_{{ class.name | lower }}_constructor_args():
sig = inspect.signature({{ class.name }}.__init__)
params = list(sig.parameters.keys())
{%- for attr in class.all_attributes() %}
assert "{{ attr.name }}" in params, "Missing parameter '{{ attr.name }}'"
{%- endfor %}


{%- for attr in class.all_attributes() %}

def test_{{ class.name | lower }}_has_{{ attr.name }}():
assert hasattr({{ class.name }}, "{{ attr.name }}")
descriptor = None
for klass in {{ class.name }}.__mro__:
if "{{ attr.name }}" in klass.__dict__:
descriptor = klass.__dict__["{{ attr.name }}"]
break
assert isinstance(descriptor, property)

{%- endfor %}

{%- endfor %}
{%- for enumeration in domain.get_enumerations() %}

def test_{{ enumeration.name | lower }}_exists():
# Check that the Enumeration exists
assert {{ enumeration.name }} is not None

def test_{{ enumeration.name | lower }}_has_all_literals():
# Collect the names of literals in this Enumeration
enum_literals = [lit.name for lit in {{ enumeration.name }}]
expected_literals = [
{%- for lit in enumeration.literals %}
"{{ lit.name }}",
{%- endfor %}
]
# Check that all expected literals exist
for lit_name in expected_literals:
assert lit_name in enum_literals, f"Literal '{{lit_name}}' missing in {{ enumeration.name }}"

{%- endfor %}


# =============================================================================
# HYPOTHESIS STRATEGIES
# =============================================================================

safe_text = st.text(
alphabet=st.characters(
whitelist_categories=("Ll", "Lu", "Nd"),
whitelist_characters="_",
),
min_size=1,
).filter(lambda s: s[0].isalpha())


{%- for class in domain.classes_sorted_by_inheritance() %}
{{ class.name }}_strategy = st.builds(
{{ class.name }},
{%- for attr in class.all_attributes() %}
{{ attr.name }}=
{%- if attr.type.name == "str" %}
safe_text
{%- elif attr.type.name == "int" %}
st.integers()
{%- elif attr.type.name == "float" %}
st.floats(min_value=0, max_value=1000,allow_nan=False, allow_infinity=False)
{%- elif attr.type.name == "bool" %}
st.booleans()
{%- elif attr.type.name.lower() in ["date", "datetime"] %}
st.dates()
{%- else %}
st.none()
{%- endif %}
{%- if not loop.last %},{% endif %}
{%- endfor %}
)

{%- endfor %}


{%- for class in domain.classes_sorted_by_inheritance() %}

@given(instance={{ class.name }}_strategy)
@settings(max_examples=50)
def test_{{ class.name | lower }}_instantiation(instance):
assert isinstance(instance, {{ class.name }})


{%- for attr in class.all_attributes() %}


@given(instance={{ class.name }}_strategy)
def test_{{ class.name | lower }}_{{ attr.name }}_setter(instance):
original = instance.{{ attr.name }}
instance.{{ attr.name }} = original
assert instance.{{ attr.name }} == original

{%- endfor %}


{%- for assoc in class.association_ends() %}
{%- if assoc.multiplicity in ["0..*", "*", "0..1", "1"] %}

@given(instance={{ class.name }}_strategy)
def test_{{ class.name | lower }}_{{ assoc.name }}_multiplicity(instance):
value = instance.{{ assoc.name }}

{%- if assoc.multiplicity in ["0..*", "*"] %}
assert isinstance(value, set)
{%- elif assoc.multiplicity == "0..1" %}
assert value is None or isinstance(value, {{ assoc.target_class.name }})
{%- elif assoc.multiplicity == "1" %}
assert isinstance(value, {{ assoc.target_class.name }})
{%- endif %}

{%- endif %}
{%- endfor %}
{%- for operation in class.methods %}
{%- if 'get' not in operation.name.lower() %}

import warnings
import copy
import inspect
import ast
from hypothesis import given, settings

@given(instance={{ class.name }}_strategy)
@settings(max_examples=30)
def test_{{ class.name | lower }}_{{ operation.name | lower }}_changes_state(instance):
before = copy.deepcopy(instance)
try:
# Call operation with dummy parameters
{%- if operation.parameters %}
instance.{{ operation.name }}(
{%- for param in operation.parameters %}
{%- if param.type.name == "str" %}
"test"
{%- elif param.type.name == "int" %}
1
{%- elif param.type.name == "float" %}
1.0
{%- elif param.type.name == "bool" %}
True
{%- else %}
None
{%- endif %}
{%- if not loop.last %}, {% endif %}
{%- endfor %}
)
{%- else %}
instance.{{ operation.name }}()
{%- endif %}
if instance.__dict__ != before.__dict__:
return # test passes
# Check that function exists and is non-empty (FAIL if empty)
source = inspect.getsource(instance.{{ operation.name }}).strip()
tree = ast.parse(source)
body = tree.body[0].body # function body
has_statements = len(body) > 0 and not all(isinstance(stmt, ast.Pass) for stmt in body)
assert has_statements, f"Function '{{operation.name}}' in {{ class.name }} is empty"

# Check for state change (WARN if no change)
if instance.__dict__ == before.__dict__:
warnings.warn(f"Operation '{{operation.name}}' in {{ class.name }} did not change state; check implementation")

except (AttributeError, NotImplementedError, TypeError):
warnings.warn(f"Operation '{{operation.name}}' in {{ class.name }} is not implemented or raised an error")

{%- endif %}
{%- endfor %}
{%- endfor %}

# =============================================================================
# SECTION 3 — OCL TESTS
# =============================================================================
{%- for class in domain.classes_sorted_by_inheritance() %}
{%- for method in class.methods %}
{%- for constraint in method.post %}

{# ---- get expression body after the last colon ---- #}
{%- set expr = constraint.expression.split(':')[-1].strip() %}

{# ---- extract parameter name + type from the context signature ---- #}
{%- set ctx = constraint.expression | string %}
{%- set sig_inner = ctx.split('(')[1].split(')')[0] %}
{%- set param_name = sig_inner.split(':')[0].strip() %}
{%- set param_type = sig_inner.split(':')[-1].strip() | lower %}

{# ---- detect whether we need st.data() for object-type params ---- #}
{%- set needs_data = param_type not in ['integer', 'int', 'real', 'float', 'double', 'string', 'str', 'bool', 'boolean'] %}

@given(
instance={{ class.name }}_strategy
{%- if needs_data %}, data=st.data(){%- endif %}
)
def test_{{ class.name | lower }}_ocl_{{ constraint.name | lower }}(instance
{%- if needs_data %}, data{%- endif %}):

{# ---- capture @pre attributes before the call ---- #}
{%- set pres = expr | regex_findall('self\\.([a-zA-Z_][a-zA-Z0-9_]*)@pre') %}
{%- for attr in pres %}
before_{{ attr }} = instance.{{ attr }}
{%- endfor %}

{# ---- declare parameter with correct type ---- #}
{%- if param_type in ['integer', 'int'] %}
{{ param_name }} = 1
{%- elif param_type in ['real', 'float', 'double'] %}
{{ param_name }} = 1.0
{%- elif param_type in ['string', 'str'] %}
{{ param_name }} = "1"
{%- elif param_type in ['bool', 'boolean'] %}
{{ param_name }} = True
{%- else %}
{# object type — draw from the corresponding strategy via st.data() #}
{{ param_name }} = data.draw({{ param_type | capitalize }}_strategy)
{%- endif %}

# Call the operation
instance.{{ method.name }}({{ param_name }})

{# ---- substitute @pre references ---- #}
{%- for attr in pres %}
{%- set expr = expr | replace('self.' ~ attr ~ '@pre', 'before_' ~ attr) %}
{%- endfor %}
{%- set expr = expr | regex_replace('self\\.([a-zA-Z_]\\w*)@pre', 'before_\\1') %}

{# ---- translate OCL collection operations to Python ---- #}
{%- set expr = expr | regex_replace('self\\.([a-zA-Z_]\\w*)->size\\(\\)', 'len(instance.\\1)') %}
{%- set expr = expr | regex_replace('self\\.([a-zA-Z_]\\w*)->isEmpty\\(\\)', '(len(instance.\\1) == 0)') %}
{%- set expr = expr | regex_replace('self\\.([a-zA-Z_]\\w*)->notEmpty\\(\\)', '(len(instance.\\1) > 0)') %}
{%- set expr = expr | regex_replace('self\\.([a-zA-Z_]\\w*)->includes\\(([^)]+)\\)', '(\\2 in instance.\\1)') %}
{%- set expr = expr | regex_replace('self\\.([a-zA-Z_]\\w*)->excludes\\(([^)]+)\\)', '(\\2 not in instance.\\1)') %}
{%- set expr = expr | regex_replace('self\\.([a-zA-Z_]\\w*)->count\\(([^)]+)\\)', 'instance.\\1.count(\\2)') %}
{%- set expr = expr | regex_replace('self\\.([a-zA-Z_]\\w*)->sum\\(\\)', 'sum(instance.\\1)') %}

{# ---- translate remaining plain self.attr references ---- #}
{%- set expr = expr | regex_replace('self\\.', 'instance.') %}

{# ---- fix OCL = that is not already == ---- #}
{%- set expr = expr | regex_replace('(?<![<>!])=(?!=)', '==') %}

assert {{ expr }}
{%- endfor %}
{%- endfor %}
{%- endfor %}
79 changes: 79 additions & 0 deletions besser/generators/testgen/test_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Generator that emits a pytest + Hypothesis test suite from a B-UML DomainModel."""

import os
import re
from jinja2 import Environment, FileSystemLoader

from besser.BUML.metamodel.structural import DomainModel
from besser.generators import GeneratorInterface
from besser.utilities import sort_by_timestamp


def _regex_findall(value, pattern):
return re.findall(pattern, value)


def _regex_replace(value, find, replace):
return re.sub(find, replace, value)


class TestGenerator(GeneratorInterface):
"""
HypothesisTestGenerator implements GeneratorInterface and generates a
pytest + Hypothesis test suite from a B-UML DomainModel.

Produces:
- Section 1: Structural plain-pytest tests (class shape, attributes,
properties, methods, constructor signatures, basic instantiation,
setter round-trips, name constraint enforcement).
- Section 2: Hypothesis property-based tests (st.builds strategies,
instantiation invariants, type contracts, method @given tests,
association set tests).

Args:
model (DomainModel): The B-UML domain model to generate tests from.
output_dir (str, optional): Output directory. Defaults to None.
"""

def __init__(self, model: DomainModel, output_dir: str = None):
super().__init__(model, output_dir)

def generate(self) -> None:
"""Generate ``test_hypothesis.py`` in the configured output directory."""
file_path = self.build_generation_path(file_name="test_hypothesis.py")
templates_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "templates"
)
env = Environment(loader=FileSystemLoader(templates_path))
# Custom filters used by hypothesis_tests_template.py.j2:
# - to_strategy: maps B-UML primitive type names -> Hypothesis strategies
# - regex_findall / regex_replace: used by the OCL constraint section
env.filters["to_strategy"] = buml_type_to_hypothesis_strategy
env.filters["regex_findall"] = _regex_findall
env.filters["regex_replace"] = _regex_replace
template = env.get_template("hypothesis_tests_template.py.j2")
with open(file_path, mode="w", encoding="utf-8") as f:
generated_code = template.render(
domain=self.model,
sort_by_timestamp=sort_by_timestamp,
)
f.write(generated_code)


def buml_type_to_hypothesis_strategy(type_name: str) -> str:
"""
Maps a B-UML primitive type name to the matching Hypothesis strategy string.
Constrained to avoid nan/inf for floats. Falls back to st.none() for
unknown/complex types so the template stays valid.
"""
mapping = {
"str": "safe_text",
"int": "st.integers()",
"float": "st.floats(allow_nan=False, allow_infinity=False)",
"bool": "st.booleans()",
"date": "st.dates()",
"datetime": "st.datetimes()",
"time": "st.times()",
"timedelta": "st.timedeltas()",
}
return mapping.get(type_name, "st.none()")
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from besser.generators.react import ReactGenerator
from besser.generators.flutter import FlutterGenerator
from besser.generators.terraform import TerraformGenerator
from besser.generators.testgen import TestGenerator
try:
from besser.generators.nn.pytorch.pytorch_code_generator import PytorchGenerator
except ImportError:
Expand Down Expand Up @@ -71,6 +72,14 @@ class GeneratorInfo(NamedTuple):
requires_class_diagram=True
),

"test_case": GeneratorInfo(
generator_class=TestGenerator,
output_type="file",
file_extension=".py",
category="object_oriented",
requires_class_diagram=True
),

# Web framework generators (class diagram based)
"django": GeneratorInfo(
generator_class=DjangoGenerator,
Expand Down Expand Up @@ -252,9 +261,10 @@ def get_filename_for_generator(generator_type: str, base_name: str = "output") -
info = get_generator_info(generator_type)
if not info:
return f"{base_name}.txt"

if generator_type == "python":
return "classes.py"
elif generator_type == "test_case":
return "test_cases.py"
elif generator_type == "pydantic":
return "pydantic_classes.py"
elif generator_type == "sqlalchemy":
Expand Down
Loading