Skip to content
Merged
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
41 changes: 29 additions & 12 deletions src/pynguin/testcase/testfactory.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,9 @@ def __init__(
if constant_provider is None:
constant_provider = EmptyConstantProvider()
self._constant_provider: ConstantProvider = constant_provider
# Smallest depth at which creating a type failed, kept only while the
# outermost _create_var_of_type call is running.
self._failed_creation_depths: dict[ProperType, int] | None = None

@property
def test_cluster(self) -> ModuleTestCluster:
Expand Down Expand Up @@ -2804,22 +2807,36 @@ def _create_var_of_type(
"""
if depth >= config.configuration.test_creation.max_recursion:
return None, position
# All candidate generators are tried in turn, so a type that no generator can
# build, e.g., an abstract class whose generators need an instance of itself,
# would be retried exponentially often. With the same or less depth left, a
# failed type fails again, so remember failures within the outermost call.
failed_depths = self._failed_creation_depths
if failed_depths is not None and depth >= failed_depths.get(param_type, depth + 1):
return None, position
generators = list(self._test_cluster.get_generators_for(param_type))
if not generators:
return None, position

for gen in self._select_candidate_generators(param_type, generators):
pre_size = test_case.size()
new_pos = self._emit_accessible(test_case, gen, position, depth + 1)
if new_pos < 0:
continue
total_added = test_case.size() - pre_size
matching_var = self._find_matching_variable(
test_case, position, total_added, raw, new_pos
)
return matching_var, position + total_added

return None, position
outermost = failed_depths is None
if failed_depths is None:
failed_depths = self._failed_creation_depths = {}
try:
for gen in self._select_candidate_generators(param_type, generators):
pre_size = test_case.size()
new_pos = self._emit_accessible(test_case, gen, position, depth + 1)
if new_pos < 0:
continue
total_added = test_case.size() - pre_size
matching_var = self._find_matching_variable(
test_case, position, total_added, raw, new_pos
)
return matching_var, position + total_added
failed_depths[param_type] = depth
return None, position
finally:
if outermost:
self._failed_creation_depths = None

@staticmethod
def _find_any_variable(test_case: tc.TestCase, position: int) -> str | None:
Expand Down
24 changes: 24 additions & 0 deletions tests/analyses/test_subtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
GenericConstructor,
)
from tests.fixtures.examples.abstract_classes import Animal, Bird, Circle, Shape
from tests.fixtures.examples.abstract_self_generators import Expression


def test_abstract_class_has_subclass_generators():
Expand Down Expand Up @@ -107,6 +108,29 @@ def test_testfactory_reuses_existing_subclass_var():
assert reused_var == var_circle


def test_testfactory_does_not_retry_failed_type_exponentially():
"""A type that no generator can build is given up on after linearly many attempts.

Every generator of ``Expression`` needs an ``Expression`` receiver. Without
remembering failed creations, each depth level multiplied the attempts by the
number of generators.
"""
config.configuration.test_creation.max_recursion = 6
cluster = generate_test_cluster("tests.fixtures.examples.abstract_self_generators")
expression_type = cluster.type_system.convert_type_hint(Expression)
factory = TestFactory(cluster)

with mock.patch.object(factory, "_emit_accessible", wraps=factory._emit_accessible) as emit:
var_name, cursor = factory._create_or_reuse_var(
TestCase(), expression_type, Expression, 0, 0
)

assert (var_name, cursor) == (None, 0)
num_generators = len(cluster.get_generators_for(expression_type))
assert emit.call_count <= num_generators * 6
assert factory._failed_creation_depths is None


def test_random_generator_provider_subtypes():
"""Verify that RandomGeneratorProvider also resolves subtypes for abstract classes."""
config.configuration.generator_selection.generator_selection_algorithm = (
Expand Down
47 changes: 47 additions & 0 deletions tests/fixtures/examples/abstract_self_generators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# This file is part of Pynguin.
#
# SPDX-FileCopyrightText: 2019–2026 Pynguin Contributors
#
# SPDX-License-Identifier: MIT
#
"""Fixture with an abstract class that only its own abstract methods can generate.

Mirrors the shape of the ``numbers`` ABCs: no concrete subclass exists, so every
generator of ``Expression`` needs an ``Expression`` receiver.
"""

from __future__ import annotations

from abc import ABC, abstractmethod


class Expression(ABC):
"""Abstract expression without any concrete subclass."""

@abstractmethod
def simplify(self) -> Expression:
"""Simplify the expression."""

@abstractmethod
def negate(self) -> Expression:
"""Negate the expression."""

@abstractmethod
def expand(self) -> Expression:
"""Expand the expression."""

@abstractmethod
def derive(self) -> Expression:
"""Derive the expression."""


def evaluate(expression: Expression) -> int:
"""Evaluate the expression.

Args:
expression: The expression to evaluate.

Returns:
The value of the expression.
"""
return 0 if expression is None else 1
Loading