diff --git a/src/pynguin/configuration.py b/src/pynguin/configuration.py index c34a01c55..2da19d86f 100644 --- a/src/pynguin/configuration.py +++ b/src/pynguin/configuration.py @@ -294,6 +294,10 @@ class TestCaseOutputConfiguration: Only works when running in a subprocess. If set to "", the are stored in the output_path.""" + store_best_population: bool = True + """Whether to store and update the current best population as pytest in the + output_path during the search.""" + export_strategy: ExportStrategy = ExportStrategy.PY_TEST """The export strategy determines for which test-runner system the generated tests should fit.""" diff --git a/src/pynguin/ga/generationalgorithmfactory.py b/src/pynguin/ga/generationalgorithmfactory.py index edf2c5fac..eee6d870c 100644 --- a/src/pynguin/ga/generationalgorithmfactory.py +++ b/src/pynguin/ga/generationalgorithmfactory.py @@ -277,6 +277,12 @@ def get_search_algorithm(self) -> GenerationAlgorithm: if stop.observes_execution: self._executor.add_observer(stop) strategy.add_search_observer(so.LogSearchObserver()) + + if config.configuration.test_case_output.store_best_population: + strategy.add_search_observer( + so.BestPopulationObserver(config.configuration.test_case_output.output_path) + ) + strategy.add_search_observer(sso.SequenceStartTimeObserver()) strategy.add_search_observer(sso.IterationObserver()) strategy.add_search_observer(sso.BestIndividualObserver()) diff --git a/src/pynguin/ga/searchobserver.py b/src/pynguin/ga/searchobserver.py index 7e0fd0e54..ba163f203 100644 --- a/src/pynguin/ga/searchobserver.py +++ b/src/pynguin/ga/searchobserver.py @@ -17,12 +17,17 @@ from abc import ABC from abc import abstractmethod +from pathlib import Path from typing import TYPE_CHECKING +from pynguin.testcase import export + if TYPE_CHECKING: import pynguin.ga.testsuitechromosome as tsc +BEST_POPULATION_FILE_NAME = "best_population.py" + class SearchObserver(ABC): """Observes the execution of a search algorithm.""" @@ -85,3 +90,65 @@ def after_search_iteration( # noqa: D102 def after_search_finish(self) -> None: """Not used.""" + + +class BestPopulationObserver(SearchObserver): + """Observes the search and stores the best individuals in a file.""" + + _logger = logging.getLogger(__name__) + + def __init__(self, store_path: str): + """Initialize the observer with the path to store the best population. + + Args: + store_path: Path to the file where the best individuals will be stored. + """ + self.store_path = Path(store_path + "/" + BEST_POPULATION_FILE_NAME) + self.best_coverage = -1.0 + self.iteration = 0 + self.store_path.parent.mkdir(parents=True, exist_ok=True) + + def before_search_start(self, start_time_ns: int) -> None: # noqa: D102 + self.iteration = 0 + self.best_coverage = -1.0 + self._logger.debug("Best population observer started, storing to: %s", self.store_path) + + def before_first_search_iteration(self, initial: tsc.TestSuiteChromosome) -> None: # noqa: D102 + current_coverage = initial.get_coverage() + if current_coverage > self.best_coverage: + self.best_coverage = current_coverage + self._store_best_population(initial) + + def after_search_iteration(self, best: tsc.TestSuiteChromosome) -> None: # noqa: D102 + self.iteration += 1 + current_coverage = best.get_coverage() + + # Only store if this is better than our current best + if current_coverage > self.best_coverage: + self.best_coverage = current_coverage + self._store_best_population(best) + self._logger.debug( + "New best individual found at iteration %d with coverage %5f, stored to %s", + self.iteration, + current_coverage, + self.store_path, + ) + + def after_search_finish(self) -> None: # noqa: D102 + self._logger.debug("Best population observer finished") + + def _store_best_population(self, chromosome: tsc.TestSuiteChromosome) -> None: + """Store the best population to the file. + + Args: + chromosome: The chromosome containing the best individuals. + """ + try: + export_visitor = export.PyTestChromosomeToAstVisitor(store_call_return=False) + chromosome.accept(export_visitor) + export.save_module_to_file( + export_visitor.to_module(), self.store_path, format_with_black=False + ) + + except Exception as e: # noqa: BLE001 + self._logger.warning("Failed to store best population: %s", e) diff --git a/tests/ga/algorithms/test_randomalgorithm.py b/tests/ga/algorithms/test_randomalgorithm.py index 917b393a0..4fc87a540 100644 --- a/tests/ga/algorithms/test_randomalgorithm.py +++ b/tests/ga/algorithms/test_randomalgorithm.py @@ -56,6 +56,7 @@ def _combine_current_individual(*_): config.configuration.stopping.maximum_search_time = 1 config.configuration.algorithm = config.Algorithm.RANDOM + config.configuration.test_case_output.store_best_population = False logger = MagicMock(Logger) algorithm = gaf.TestSuiteGenerationAlgorithmFactory( executor, MagicMock(ModuleTestCluster) diff --git a/tests/utils/test_configuration_writer.py b/tests/utils/test_configuration_writer.py index 0a7bd7de3..cf2b4e706 100644 --- a/tests/utils/test_configuration_writer.py +++ b/tests/utils/test_configuration_writer.py @@ -35,6 +35,7 @@ def expected_toml(tmp_path): [test_case_output] output_path = "" crash_path = "" +store_best_population = true export_strategy = "PY_TEST" max_length_test_case = 2500 assertion_generation = "MUTATION_ANALYSIS" @@ -220,8 +221,8 @@ def expected_txt(tmp_path): expected_txt = Path(tmp_path) / f"expected-{PYNGUIN_CONFIG_TXT}" expected = """("Configuration(project_path='', module_name='', " "test_case_output=TestCaseOutputConfiguration(output_path='', crash_path='', " - "export_strategy=, " - 'max_length_test_case=2500, ' + 'store_best_population=True, export_strategy=, max_length_test_case=2500, " 'assertion_generation=, allow_stale_assertions=False, " 'mutation_strategy= str: FIRST_ORDER_MUTANTS --test_case_output.post_process True +--test_case_output.store_best_population +True --test_creation.any_weight 0 --test_creation.bytes_length