Repository: gammasim/simtools (language: python)

Issue:
Move functions from `test_applications_from_config.py` to `simtools.testing`
The main function in `test_applications_from_config.py` was split into smaller functions. We should move those to `simtools.testing` and write unit tests for them.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/CI-schema-validation.yml b/.github/workflows/CI-schema-validation.yml
index 906e6d387..2cbf601a5 100644
--- a/.github/workflows/CI-schema-validation.yml
+++ b/.github/workflows/CI-schema-validation.yml
@@ -53,7 +53,7 @@ jobs:
             --log_level DEBUG
           done
 
-      - name: Integration config file validation
+      - name: Integration test config file validation
         run: |
           for file in tests/integration_tests/config/*.yml; do
             echo "Testing config file $file"
diff --git a/docs/changes/1238.maintenance.md b/docs/changes/1238.maintenance.md
new file mode 100644
index 000000000..021fa6d82
--- /dev/null
+++ b/docs/changes/1238.maintenance.md
@@ -0,0 +1,1 @@
+Refactoring of integration test routines into `simtools.testing` modules.
diff --git a/pyproject.toml b/pyproject.toml
index c416f6b5d..5c0decc94 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -39,6 +39,7 @@ dependencies = [
   "particle",
   "pymongo",
   "pyproj",
+  "pytest",
   "python-dotenv",
   "pyyaml",
   "scipy",
@@ -57,7 +58,6 @@ optional-dependencies."doc" = [
   "towncrier",
 ]
 optional-dependencies."tests" = [
-  "pytest",
   "pytest-cov",
   "pytest-mock",
   "pytest-profiling",


Test patch:
diff --git a/docs/source/api-reference/testing.md b/docs/source/api-reference/testing.md
index 49045af2e..19232f93a 100644
--- a/docs/source/api-reference/testing.md
+++ b/docs/source/api-reference/testing.md
@@ -14,11 +14,30 @@ The Testing module provides tools for testing the code. This might be part of th
 
 ```
 
-(compare_outputmodule)=
+(testconfiguration_module)=
 
-## compare_output
+## configuration
 
 ```{eval-rst}
-.. automodule:: testing.compare_output
+.. automodule:: testing.configuration
+   :members:
+
+```
+
+(validate_outputmodule)=
+
+## validate_output
+
+```{eval-rst}
+.. automodule:: testing.validate_output
+   :members:
+```
+
+(helpersmodule)=
+
+## helpers
+
+```{eval-rst}
+.. automodule:: testing.helpers
    :members:
 ```
diff --git a/simtools/testing/configuration.py b/simtools/testing/configuration.py
new file mode 100644
index 000000000..bce8f5c8a
--- /dev/null
+++ b/simtools/testing/configuration.py
@@ -0,0 +1,226 @@
+"""Integration test configuration."""
+
+import logging
+from pathlib import Path
+
+import pytest
+import yaml
+
+import simtools.utils.general as gen
+
+_logger = logging.getLogger(__name__)
+
+
+def get_list_of_test_configurations(config_files):
+    """
+    Return list of test configuration dictionaries or test names.
+
+    Read all configuration files for testing.
+    Add "--help" and "--version" calls for all applications.
+
+    Parameters
+    ----------
+    config_files: list
+        List of integration test configuration files.
+
+    Returns
+    -------
+    list:
+        list of test names or of configuration dictionaries.
+
+    """
+    _logger.debug(f"Configuration files: {config_files}")
+
+    configs = _read_configs_from_files(config_files)
+
+    # list of all applications
+    # (needs to be sorted for pytest-xdist, see Known Limitations in their website)
+    _applications = sorted({item["APPLICATION"] for item in configs if "APPLICATION" in item})
+    for app in _applications:
+        configs.extend(
+            [
+                {"APPLICATION": app, "TEST_NAME": "auto-help", "CONFIGURATION": {"HELP": True}},
+                {
+                    "APPLICATION": app,
+                    "TEST_NAME": "auto-version",
+                    "CONFIGURATION": {"VERSION": True},
+                },
+                {"APPLICATION": app, "TEST_NAME": "auto-no_config"},
+            ]
+        )
+
+    return (
+        configs,
+        [
+            f"{item.get('APPLICATION', 'no-app-name')}_{item.get('TEST_NAME', 'no-test-name')}"
+            for item in configs
+        ],
+    )
+
+
+def _read_configs_from_files(config_files):
+    """Read test configuration from files."""
+    configs = []
+    for config_file in config_files:
+        # read config file
+        # remove new line characters from config - otherwise issues
+        # with especially long file names
+        _dict = gen.remove_substring_recursively_from_dict(
+            gen.collect_data_from_file_or_dict(file_name=config_file, in_dict=None), substring="\n"
+        )
+        configs.append(_dict.get("CTA_SIMPIPE", None))
+    return configs
+
+
+def configure(config, tmp_test_directory, request):
+    """
+    Prepare configuration and command for integration tests.
+
+    Parameters
+    ----------
+    config: dict
+        Configuration dictionary.
+    tmp_test_directory: str
+        Temporary test directory (from pytest fixture).
+    request: request
+        Request object.
+
+    Returns
+    -------
+    str: command to run the application test.
+    str: config file model version.
+
+    """
+    tmp_output_path = create_tmp_output_path(tmp_test_directory, config)
+    model_version_requested = request.config.getoption("--model_version")
+
+    if "CONFIGURATION" in config:
+        _skip_test_for_model_version(config, model_version_requested)
+
+        config_file, config_string, config_file_model_version = _prepare_test_options(
+            config["CONFIGURATION"],
+            output_path=tmp_output_path,
+            model_version=model_version_requested,
+        )
+    else:
+        config_file = None
+        config_string = None
+        config_file_model_version = None
+
+    cmd = get_application_command(
+        app=config.get("APPLICATION", None),
+        config_file=config_file,
+        config_string=config_string,
+    )
+    return cmd, config_file_model_version
+
+
+def _skip_test_for_model_version(config, model_version_requested):
+    """Skip test if model version requested is not supported."""
+    if config.get("MODEL_VERSION_USE_CURRENT") is None or model_version_requested is None:
+        return
+    model_version_config = config["CONFIGURATION"]["MODEL_VERSION"]
+    if model_version_requested != model_version_config:
+        pytest.skip(
+            f"Model version requested {model_version_requested} not supported for this test"
+        )
+
+
+def _prepare_test_options(config, output_path, model_version=None):
+    """
+    Prepare test configuration.
+
+    This means either to write a temporary config file
+    or to return a single string for single boolean options (e.g., --version).
+    Change output path and file to paths provided with output_path.
+
+    Parameters
+    ----------
+    config: dict
+        Dictionary with the configuration for the application test.
+    output_path: str
+        Output path.
+    model_version: str
+        Model versions (default: use those given in config files)
+
+    Returns
+    -------
+    config_file: str
+        Path to the temporary configuration file.
+    config_string: str
+        Command line configuration as single string.
+    config_file_model_version: str
+        Configuration file model version
+
+    """
+    if len(config) == 1 and next(iter(config.values())) is True:
+        return None, "--" + next(iter(config.keys())).lower(), None
+
+    tmp_config_file = output_path / "tmp_config.yml"
+    config_file_model_version = config.get("MODEL_VERSION")
+    if model_version is not None and "MODEL_VERSION" in config:
+        config.update({"MODEL_VERSION": model_version})
+    if "OUTPUT_PATH" in config:
+        config.update({"OUTPUT_PATH": str(Path(output_path).joinpath(config["OUTPUT_PATH"]))})
+        config.update({"USE_PLAIN_OUTPUT_PATH": True})
+    if "DATA_DIRECTORY" in config:
+        config.update({"DATA_DIRECTORY": str(Path(output_path).joinpath(config["DATA_DIRECTORY"]))})
+
+    _logger.info(f"Writing config file: {tmp_config_file}")
+    with open(tmp_config_file, "w", encoding="utf-8") as file:
+        yaml.safe_dump(config, file, sort_keys=False)
+
+    return tmp_config_file, None, config_file_model_version
+
+
+def get_application_command(app, config_file=None, config_string=None):
+    """
+    Return the command to run the application with the given config file.
+
+    Parameters
+    ----------
+    app: str
+        Name of the application.
+    config_file: str
+        Configuration file.
+    config_string: str
+        Configuration string (e.g., '--version')
+
+    Returns
+    -------
+    str: command to run the application test.
+
+    """
+    cmd = app if "simtools-" in app else f"python simtools/applications/{app}.py"
+    if config_string:
+        return f"{cmd} {config_string}"
+    if config_file is not None:
+        return f"{cmd} --config {config_file}"
+    return cmd
+
+
+def create_tmp_output_path(tmp_test_directory, config):
+    """
+    Create temporary output path.
+
+    Parameters
+    ----------
+    tmp_test_directory: str
+        Temporary directory.
+    config: dict
+        Configuration dictionary.
+
+    Returns
+    -------
+    str: path to the temporary output directory.
+
+    """
+    try:
+        tmp_output_path = Path(tmp_test_directory).joinpath(
+            config["APPLICATION"] + "-" + config["TEST_NAME"]
+        )
+    except KeyError as exc:
+        raise KeyError(f"No application defined in configuration {config}.") from exc
+    tmp_output_path.mkdir(parents=True, exist_ok=True)
+    _logger.info(f"Temporary output path: {tmp_output_path}")
+    return tmp_output_path
diff --git a/simtools/testing/helpers.py b/simtools/testing/helpers.py
new file mode 100644
index 000000000..c786ffea1
--- /dev/null
+++ b/simtools/testing/helpers.py
@@ -0,0 +1,42 @@
+"""Helper functions for integration testing."""
+
+import os
+
+import pytest
+
+
+def skip_camera_efficiency(config):
+    """Skip camera efficiency tests if the old version of testeff is used."""
+    if "camera-efficiency" in config["APPLICATION"]:
+        if not _new_testeff_version():
+            pytest.skip(
+                "Any applications calling the old version of testeff are skipped "
+                "due to a limitation of the old testeff not allowing to specify "
+                "the include directory. Please update your sim_telarray tarball."
+            )
+        full_test_name = f"{config['APPLICATION']}_{config['TEST_NAME']}"
+        if "simtools-validate-camera-efficiency_SSTS" == full_test_name:
+            pytest.skip(
+                "The test simtools-validate-camera-efficiency_SSTS is skipped "
+                "since the fake SST mirrors are not yet implemented (#1155)"
+            )
+
+
+def _new_testeff_version():
+    """
+    Testeff has been updated to allow to specify the include directory.
+
+    This test checks if the new version is used.
+    """
+    testeff_path = os.path.join(os.getenv("SIMTOOLS_SIMTEL_PATH"), "sim_telarray/testeff.c")
+    try:
+        with open(testeff_path, encoding="utf-8") as file:
+            file_content = file.read()
+            if (
+                "/* Combine the include paths such that those from '-I...' options */"
+                in file_content
+            ):
+                return True
+            return False
+    except FileNotFoundError as exc:
+        raise FileNotFoundError("The testeff executable could not be found.") from exc
diff --git a/simtools/testing/compare_output.py b/simtools/testing/validate_output.py
similarity index 57%
rename from simtools/testing/compare_output.py
rename to simtools/testing/validate_output.py
index 8a84337c5..8ce4828e6 100644
--- a/simtools/testing/compare_output.py
+++ b/simtools/testing/validate_output.py
@@ -7,10 +7,105 @@
 from astropy.table import Table
 
 import simtools.utils.general as gen
+from simtools.testing import assertions
 
 _logger = logging.getLogger(__name__)
 
 
+def validate_all_tests(config, request, config_file_model_version):
+    """
+    Validate test output for all integration tests.
+
+    Parameters
+    ----------
+    config: dict
+        Integration test configuration dictionary.
+    request: request
+        Request object.
+    config_file_model_version: str
+        Model version from the configuration file.
+
+    """
+    if request.config.getoption("--model_version") is None:
+        validate_application_output(config)
+    elif config_file_model_version is not None:
+        _from_command_line = request.config.getoption("--model_version")
+        _from_config_file = config_file_model_version
+        if _from_command_line == _from_config_file:
+            validate_application_output(config)
+
+
+def validate_application_output(config):
+    """
+    Validate application output against expected output.
+
+    Expected output is defined in configuration file.
+
+    Parameters
+    ----------
+    config: dict
+        dictionary with the configuration for the application test.
+
+    """
+    if "INTEGRATION_TESTS" not in config:
+        return
+
+    for integration_test in config["INTEGRATION_TESTS"]:
+        _logger.info(f"Testing application output: {integration_test}")
+        if "REFERENCE_OUTPUT_FILE" in integration_test:
+            _validate_reference_output_file(config, integration_test)
+
+        if "OUTPUT_FILE" in integration_test:
+            _validate_output_path_and_file(config, integration_test)
+
+        if "FILE_TYPE" in integration_test:
+            assert assertions.assert_file_type(
+                integration_test["FILE_TYPE"],
+                Path(config["CONFIGURATION"]["OUTPUT_PATH"]).joinpath(
+                    config["CONFIGURATION"]["OUTPUT_FILE"]
+                ),
+            )
+
+
+def _validate_reference_output_file(config, integration_test):
+    """Compare with reference output file."""
+    assert compare_files(
+        integration_test["REFERENCE_OUTPUT_FILE"],
+        Path(config["CONFIGURATION"]["OUTPUT_PATH"]).joinpath(
+            config["CONFIGURATION"]["OUTPUT_FILE"]
+        ),
+        integration_test.get("TOLERANCE", 1.0e-5),
+        integration_test.get("TEST_COLUMNS", None),
+    )
+
+
+def _validate_output_path_and_file(config, integration_test):
+    """Check if output path and file exist."""
+    _logger.info(f"PATH {config['CONFIGURATION']['OUTPUT_PATH']}")
+    _logger.info(f"File {integration_test['OUTPUT_FILE']}")
+
+    data_path = config["CONFIGURATION"].get(
+        "DATA_DIRECTORY", config["CONFIGURATION"]["OUTPUT_PATH"]
+    )
+    output_file_path = Path(data_path) / integration_test["OUTPUT_FILE"]
+
+    _logger.info(f"Checking path: {output_file_path}")
+    assert output_file_path.exists()
+
+    expected_output = [
+        d["EXPECTED_OUTPUT"] for d in config["INTEGRATION_TESTS"] if "EXPECTED_OUTPUT" in d
+    ]
+    if expected_output and "log_hist" not in integration_test["OUTPUT_FILE"]:
+        # Get the expected output from the configuration file
+        expected_output = expected_output[0]
+        _logger.info(
+            f"Checking the output of {integration_test['OUTPUT_FILE']} "
+            "complies with the expected output: "
+            f"{expected_output}"
+        )
+        assert assertions.check_output_from_sim_telarray(output_file_path, expected_output)
+
+
 def compare_files(file1, file2, tolerance=1.0e-5, test_columns=None):
     """
     Compare two files of file type ecsv, json or yaml.
diff --git a/tests/integration_tests/test_applications_from_config.py b/tests/integration_tests/test_applications_from_config.py
index 9b06604f2..69b57653b 100644
--- a/tests/integration_tests/test_applications_from_config.py
+++ b/tests/integration_tests/test_applications_from_config.py
@@ -1,6 +1,5 @@
 #!/usr/bin/python3
 # Integration tests for applications from config file
-#
 
 import copy
 import logging
@@ -9,221 +8,18 @@
 from pathlib import Path
 
 import pytest
-import yaml
 from dotenv import load_dotenv
 
-import simtools.utils.general as gen
-from simtools.testing import assertions, compare_output
+from simtools.testing import configuration, helpers, validate_output
 
 logger = logging.getLogger()
 load_dotenv(".env")
 
+config_files = sorted(Path(__file__).parent.glob("config/*.yml"))
+test_configs, test_ids = configuration.get_list_of_test_configurations(config_files)
 
-def get_application_command(app, config_file=None, config_string=None):
-    """
-    Return the command to run the application with the given config file.
-
-    Parameters
-    ----------
-    app: str
-        Name of the application.
-    config_file: str
-        Configuration file.
-    config_string: str
-        Configuration string (e.g., '--version')
-
-    Returns
-    -------
-    str: command to run the application test.
-
-    """
-    if app.find("simtools-") < 0:
-        cmd = "python simtools/applications/" + app + ".py"
-    else:
-        cmd = app
-    if config_string is not None:
-        cmd += f" {config_string}"
-    elif config_file is not None:
-        cmd += f" --config {config_file}"
-    return cmd
-
-
-def get_list_of_test_configurations(get_test_names=False):
-    """
-    Read all config files in the config directory and return a list
-    of configuration dicts (equivalent to list of application tests).
-
-    Parameters:
-    -----------
-    get_test_names: bool
-        If True, return a list of test names instead of a list of configuration dictionaries.
-
-    Returns
-    -------
-    list:
-        of test names or of configuration dictionaries.
-
-    """
-
-    # (needs to be sorted for pytest-xdist, see Known Limitations in their website)
-    config_files = sorted(Path(__file__).parent.glob("config/*.yml"))
-    logger.debug(f"Configuration files: {config_files}")
-
-    configs = []
-    for config_file in config_files:
-        # read config file
-        # remove new line characters from config - otherwise issues
-        # with especially long file names
-        _dict = gen.remove_substring_recursively_from_dict(
-            gen.collect_data_from_file_or_dict(file_name=config_file, in_dict=None), substring="\n"
-        )
-        configs.append(_dict.get("CTA_SIMPIPE", None))
-
-    # list of all applications
-    # (needs to be sorted for pytest-xdist, see Known Limitations in their website)
-    _applications = sorted({item["APPLICATION"] for item in configs if "APPLICATION" in item})
-    for _app in _applications:
-        # add for all applications "--help" call
-        configs.append(
-            {"APPLICATION": _app, "TEST_NAME": "auto-help", "CONFIGURATION": {"HELP": True}}
-        )
-        # add for all applications "--version" call
-        configs.append(
-            {"APPLICATION": _app, "TEST_NAME": "auto-version", "CONFIGURATION": {"VERSION": True}}
-        )
-        # add for all applications call without config file
-        configs.append({"APPLICATION": _app, "TEST_NAME": "auto-no_config"})
-
-    if get_test_names:
-        return [
-            f"{item.get('APPLICATION', 'no-app-name')}_{item.get('TEST_NAME', 'no-test-name')}"
-            for item in configs
-        ]
-
-    return configs
-
-
-def validate_application_output(config):
-    """
-    Validate application output against expected output.
-    Expected output is defined in configuration file.
-
-    Parameters
-    ----------
-    config: dict
-        dictionary with the configuration for the application test.
-
-    """
-
-    if "INTEGRATION_TESTS" not in config:
-        return
-
-    for integration_test in config["INTEGRATION_TESTS"]:
-        logger.info(f"Testing application output: {integration_test}")
-        if "REFERENCE_OUTPUT_FILE" in integration_test:
-            assert compare_output.compare_files(
-                integration_test["REFERENCE_OUTPUT_FILE"],
-                Path(config["CONFIGURATION"]["OUTPUT_PATH"]).joinpath(
-                    config["CONFIGURATION"]["OUTPUT_FILE"]
-                ),
-                integration_test.get("TOLERANCE", 1.0e-5),
-                integration_test.get("TEST_COLUMNS", None),
-            )
-
-        if "OUTPUT_FILE" in integration_test:
-            # In this case the output file is the file generated by the application
-            # First check if the output is in the data directory (simtel_array related),
-            # Then check if the file is in the output directory (remaining tools).
-            logger.info(f"PATH {config['CONFIGURATION']['OUTPUT_PATH']}")
-            logger.info(f"File {integration_test['OUTPUT_FILE']}")
-            try:
-                assert (
-                    Path(config["CONFIGURATION"]["DATA_DIRECTORY"])
-                    .joinpath(integration_test["OUTPUT_FILE"])
-                    .exists()
-                )
-            except KeyError:
-                assert (
-                    Path(config["CONFIGURATION"]["OUTPUT_PATH"])
-                    .joinpath(integration_test["OUTPUT_FILE"])
-                    .exists()
-                )
-
-            expected_output = [
-                d["EXPECTED_OUTPUT"] for d in config["INTEGRATION_TESTS"] if "EXPECTED_OUTPUT" in d
-            ]
-            if expected_output and "log_hist" not in integration_test["OUTPUT_FILE"]:
-                # Get the expected output from the configuration file
-                expected_output = expected_output[0]
-                logger.info(
-                    f"Checking the output of {integration_test['OUTPUT_FILE']} "
-                    "complies with the expected output: "
-                    f"{expected_output}"
-                )
-                assert assertions.check_output_from_sim_telarray(
-                    Path(config["CONFIGURATION"]["DATA_DIRECTORY"]).joinpath(
-                        integration_test["OUTPUT_FILE"]
-                    ),
-                    expected_output,
-                )
-
-        if "FILE_TYPE" in integration_test:
-            assert assertions.assert_file_type(
-                integration_test["FILE_TYPE"],
-                Path(config["CONFIGURATION"]["OUTPUT_PATH"]).joinpath(
-                    config["CONFIGURATION"]["OUTPUT_FILE"]
-                ),
-            )
-
-
-def prepare_configuration(config, output_path, model_version=None):
-    """
-    Prepare configuration. This means either to write a temporary config file
-    or to return a single string for single boolean options (e.g., --version).
-    Change output path and file to paths provided with output_path.
-
-    Parameters
-    ----------
-    config: dict
-        Dictionary with the configuration for the application test.
-    output_path: str
-        Output path.
-    model_version: str
-        Model versions (default: use those given in config files)
-
-    Returns
-    -------
-    str: path to the temporary config file.
-    str: configuration string.
-    str: config file model version
-
-    """
-
-    if len(config) == 1 and next(iter(config.values())) is True:
-        return None, "--" + next(iter(config.keys())).lower(), None
-
-    tmp_config_file = output_path / "tmp_config.yml"
-    config_file_model_version = config.get("MODEL_VERSION")
-    if model_version is not None and "MODEL_VERSION" in config:
-        config.update({"MODEL_VERSION": model_version})
-    if "OUTPUT_PATH" in config:
-        config.update({"OUTPUT_PATH": str(Path(output_path).joinpath(config["OUTPUT_PATH"]))})
-        config.update({"USE_PLAIN_OUTPUT_PATH": True})
-    if "DATA_DIRECTORY" in config:
-        config.update({"DATA_DIRECTORY": str(Path(output_path).joinpath(config["DATA_DIRECTORY"]))})
-
-    logger.info(f"Writing config file: {tmp_config_file}")
-    with open(tmp_config_file, "w", encoding="utf-8") as file:
-        yaml.safe_dump(config, file, sort_keys=False)
-
-    return tmp_config_file, None, config_file_model_version
 
-
-@pytest.mark.parametrize(
-    "config",
-    get_list_of_test_configurations(),
-    ids=get_list_of_test_configurations(get_test_names=True),
-)
+@pytest.mark.parametrize("config", test_configs, ids=test_ids)
 def test_applications_from_config(tmp_test_directory, config, monkeypatch, request, model_version):
     """
     Test all applications from config files found in the config directory.
@@ -238,7 +34,7 @@ def test_applications_from_config(tmp_test_directory, config, monkeypatch, reque
     """
 
     tmp_config = copy.deepcopy(config)
-    skip_camera_efficiency(tmp_config)
+    helpers.skip_camera_efficiency(tmp_config)
 
     # The db_add_file_to_db.py application requires a user confirmation.
     # With this line we mock the user confirmation to be y for the test
@@ -246,152 +42,13 @@ def test_applications_from_config(tmp_test_directory, config, monkeypatch, reque
     monkeypatch.setattr("sys.stdin", StringIO("y\n"))
 
     logger.info(f"Test configuration from config file: {tmp_config}")
-
-    tmp_output_path = create_tmp_output_path(tmp_test_directory, tmp_config)
-
     logger.info(f"Model version: {request.config.getoption('--model_version')}")
     logger.info(f"Application configuration: {tmp_config}")
-    cmd, config_file_model_version = prepare_config_and_command(
-        tmp_config, tmp_output_path, request
+    cmd, config_file_model_version = configuration.configure(
+        tmp_config, tmp_test_directory, request
     )
 
-    execute_and_validate(cmd, tmp_config, request, config_file_model_version)
-
-
-def create_tmp_output_path(tmp_test_directory, config):
-    """
-    Create temporary output path.
-
-    Parameters
-    ----------
-    tmp_test_directory: str
-        Temporary directory.
-    config: dict
-        Configuration dictionary.
-
-    Returns
-    -------
-    str: path to the temporary output directory.
-
-    """
-
-    try:
-        tmp_output_path = Path(tmp_test_directory).joinpath(
-            config["APPLICATION"] + "-" + config["TEST_NAME"]
-        )
-    except KeyError as exc:
-        logger.error(f"No application defined in config file {config}.")
-        raise exc
-    tmp_output_path.mkdir(parents=True, exist_ok=True)
-    logger.info(f"Temporary output path: {tmp_output_path}")
-    return tmp_output_path
-
-
-def prepare_config_and_command(config, tmp_output_path, request):
-    """
-    Prepare configuration and command for the application test.
-
-    Parameters
-    ----------
-    config: dict
-        Configuration dictionary.
-    tmp_output_path: str
-        Temporary output path.
-    request: request
-        Request object.
-
-    Returns
-    -------
-    str: command to run the application test.
-    str: config file model version.
-
-    """
-
-    model_version_requested = request.config.getoption("--model_version")
-    if "CONFIGURATION" in config:
-        if "MODEL_VERSION_USE_CURRENT" in config:
-            model_version_config = config["CONFIGURATION"]["MODEL_VERSION"]
-            if (
-                model_version_requested != model_version_config
-                and model_version_requested is not None
-            ):
-                pytest.skip(
-                    f"Model version requested {model_version_requested} not supported for this test"
-                )
-        config_file, config_string, config_file_model_version = prepare_configuration(
-            config["CONFIGURATION"],
-            output_path=tmp_output_path,
-            model_version=model_version_requested,
-        )
-    else:
-        config_file = None
-        config_string = None
-        config_file_model_version = None
-
-    cmd = get_application_command(
-        app=config.get("APPLICATION", None),
-        config_file=config_file,
-        config_string=config_string,
-    )
-    return cmd, config_file_model_version
-
-
-def execute_and_validate(cmd, config, request, config_file_model_version):
-    """
-    Execute application and validate output.
-
-    Parameters
-    ----------
-    cmd: str
-        Command to run the application test.
-    config: dict
-        Configuration dictionary.
-    request: request
-        Request object.
-    config_file_model_version: str
-        Model version from the configuration file.
-
-    """
-
     logger.info(f"Running application: {cmd}")
     assert os.system(cmd) == 0, f"Application failed: {cmd}"
 
-    if request.config.getoption("--model_version") is None:
-        validate_application_output(config)
-    elif config_file_model_version is not None:
-        _from_command_line = request.config.getoption("--model_version")
-        _from_config_file = config_file_model_version
-        if _from_command_line == _from_config_file:
-            validate_application_output(config)
-
-
-def skip_camera_efficiency(config):
-    """
-    Skip camera efficiency tests if the old version of testeff is used.
-    """
-    if "camera-efficiency" in config["APPLICATION"]:
-        if not new_testeff_version():
-            pytest.skip(
-                "Any applications calling the old version of testeff are skipped "
-                "due to a limitation of the old testeff not allowing to specify "
-                "the include directory. Please update your sim_telarray tarball."
-            )
-        full_test_name = f"{config['APPLICATION']}_{config['TEST_NAME']}"
-        if "simtools-validate-camera-efficiency_SSTS" == full_test_name:
-            pytest.skip(
-                "The test simtools-validate-camera-efficiency_SSTS is skipped "
-                "since the fake SST mirrors are not yet implemented (#1155)"
-            )
-
-
-def new_testeff_version():
-    """
-    Testeff has been updated to allow to specify the include directory.
-    This test checks if the new version is used.
-    """
-
-    with open(os.path.join(os.getenv("SIMTOOLS_SIMTEL_PATH"), "sim_telarray/testeff.c")) as file:
-        file_content = file.read()
-        if "/* Combine the include paths such that those from '-I...' options */" in file_content:
-            return True
-        return False
+    validate_output.validate_all_tests(tmp_config, request, config_file_model_version)
diff --git a/tests/unit_tests/testing/test_compare_output.py b/tests/unit_tests/testing/test_compare_output.py
deleted file mode 100644
index 8e717bd0b..000000000
--- a/tests/unit_tests/testing/test_compare_output.py
+++ /dev/null
@@ -1,209 +0,0 @@
-#!/usr/bin/python3
-
-import json
-import logging
-
-import pytest
-import yaml
-from astropy.table import Table
-
-from simtools.testing import compare_output
-
-logging.getLogger().setLevel(logging.DEBUG)
-
-
-@pytest.fixture
-def create_json_file(tmp_test_directory):
-    def _create_json_file(file_name, content):
-        file = tmp_test_directory / file_name
-        file.write_text(json.dumps(content), encoding="utf-8")
-        return file
-
-    return _create_json_file
-
-
-@pytest.fixture
-def create_yaml_file(tmp_path):
-    def _create_yaml_file(file_name, content):
-        file = tmp_path / file_name
-        with open(file, "w", encoding="utf-8") as f:
-            yaml.dump(content, f)
-        return file
-
-    return _create_yaml_file
-
-
-@pytest.fixture
-def create_ecsv_file(tmp_path):
-    def _create_ecsv_file(file_name, content):
-        table = Table(content)
-        file_path = tmp_path / file_name
-        table.write(file_path, format="ascii.ecsv")
-        return file_path
-
-    return _create_ecsv_file
-
-
-@pytest.fixture
-def file_name():
-    def _file_name(counter, suffix):
-        return f"file{counter}.{suffix}"
-
-    return _file_name
-
-
-def test_compare_json_files_float_strings(create_json_file, file_name):
-    content = {"key": 1, "value": "1.23 4.56 7.89"}
-    file1 = create_json_file(file_name(1, "json"), content)
-    file2 = create_json_file(file_name(2, "json"), content)
-
-    assert compare_output.compare_json_or_yaml_files(file1, file2)
-
-    content3 = {"key": 2, "value": "1.23 4.56 7.80"}
-    file3 = create_json_file(file_name(3, "json"), content3)
-    assert not compare_output.compare_json_or_yaml_files(file1, file3)
-
-
-def test_compare_json_files_equal_integers(create_json_file, file_name):
-    content = {"key": 1, "value": 5}
-    file1 = create_json_file(file_name(1, "json"), content)
-    file2 = create_json_file(file_name(2, "json"), content)
-
-    assert compare_output.compare_json_or_yaml_files(file1, file2)
-
-    content3 = {"key": 2, "value": 7}
-    file3 = create_json_file(file_name(3, "json"), content3)
-    assert not compare_output.compare_json_or_yaml_files(file1, file3)
-
-
-def test_compare_yaml_files_float_strings(create_yaml_file, file_name):
-    content = {"key": 1, "value": "1.23 4.56 7.89"}
-    file1 = create_yaml_file(file_name(1, "yaml"), content)
-    file2 = create_yaml_file(file_name(2, "yaml"), content)
-
-    assert compare_output.compare_json_or_yaml_files(file1, file2)
-
-    content3 = {"key": 2, "value": "1.23 4.56 7.80"}
-    file3 = create_yaml_file(file_name(3, "yaml"), content3)
-    assert not compare_output.compare_json_or_yaml_files(file1, file3)
-
-
-def test_compare_yaml_files_equal_integers(create_yaml_file, file_name):
-    content = {"key": 1, "value": 5}
-    file1 = create_yaml_file(file_name(1, "yaml"), content)
-    file2 = create_yaml_file(file_name(2, "yaml"), content)
-
-    assert compare_output.compare_json_or_yaml_files(file1, file2)
-
-    content3 = {"key": 2, "value": 7}
-    file3 = create_yaml_file(file_name(3, "yaml"), content3)
-    assert not compare_output.compare_json_or_yaml_files(file1, file3)
-
-
-def test_compare_ecsv_files_equal(create_ecsv_file, file_name):
-    content = {"col1": [1.1, 2.2, 3.3], "col2": [4.4, 5.5, 6.6]}
-    file1 = create_ecsv_file(file_name(1, "ecsv"), content)
-    file2 = create_ecsv_file(file_name(2, "ecsv"), content)
-
-    assert compare_output.compare_ecsv_files(file1, file2)
-
-
-def test_compare_ecsv_files_different_lengths(create_ecsv_file, file_name):
-    content1 = {"col1": [1.1, 2.2, 3.3], "col2": [4.4, 5.5, 6.6]}
-    content2 = {"col1": [1.1, 2.2], "col2": [4.4, 5.5]}
-    file1 = create_ecsv_file(file_name(1, "yaml"), content1)
-    file2 = create_ecsv_file(file_name(2, "ecsv"), content2)
-
-    assert not compare_output.compare_ecsv_files(file1, file2)
-
-
-def test_compare_ecsv_files_close_values(create_ecsv_file, file_name):
-    content1 = {"col1": [1.1001, 2.2001, 3.3001], "col2": [4.4001, 5.5001, 6.6001]}
-    content2 = {"col1": [1.1, 2.2, 3.3], "col2": [4.4, 5.5, 6.6]}
-    file1 = create_ecsv_file(file_name(1, "ecsv"), content1)
-    file2 = create_ecsv_file(file_name(2, "ecsv"), content2)
-
-    assert compare_output.compare_ecsv_files(file1, file2, tolerance=1.0e-3)
-
-
-def test_compare_ecsv_files_large_difference(create_ecsv_file, file_name):
-    content1 = {"col1": [1.1, 2.2, 3.3], "col2": [4.4, 5.5, 6.6]}
-    content2 = {"col1": [10.1, 20.2, 30.3], "col2": [40.4, 50.5, 60.6]}
-    file1 = create_ecsv_file(file_name(1, "ecsv"), content1)
-    file2 = create_ecsv_file(file_name(2, "ecsv"), content2)
-
-    assert not compare_output.compare_ecsv_files(file1, file2)
-
-
-def test_compare_files_ecsv(create_ecsv_file, file_name):
-    content = {"col1": [1.1, 2.2, 3.3], "col2": [4.4, 5.5, 6.6]}
-    file1 = create_ecsv_file(file_name(1, "ecsv"), content)
-    file2 = create_ecsv_file(file_name(2, "ecsv"), content)
-
-    assert compare_output.compare_files(file1, file2)
-
-
-def test_compare_files_ecsv_columns(create_ecsv_file, file_name):
-    content1 = {"col1": [1.1, 2.2, 3.3], "col2": [4.4, 5.5, 6.6]}
-    content2 = {"col1": [1.1, 2.21, 3.31], "col2": [4.47, 5.5, 6.6], "col3": [7.7, 8.8, 9.9]}
-    file1 = create_ecsv_file(file_name(1, "ecsv"), content1)
-    file2 = create_ecsv_file(file_name(2, "ecsv"), content2)
-
-    assert compare_output.compare_files(file1, file2, 0.5)
-    assert compare_output.compare_files(file1, file2, 0.005, [{"TEST_COLUMN_NAME": "col1"}])
-    assert not compare_output.compare_files(file1, file2, 0.005, None)
-    assert not compare_output.compare_files(file1, file2, 0.005, [{"TEST_COLUMN_NAME": "col2"}])
-    assert compare_output.compare_files(
-        file1,
-        file2,
-        0.005,
-        [{"TEST_COLUMN_NAME": "col1", "CUT_COLUMN_NAME": "col2", "CUT_CONDITION": "> 4.5"}],
-    )
-    # select first column only (same values)
-    assert compare_output.compare_files(
-        file1,
-        file2,
-        1.0e-3,
-        [{"TEST_COLUMN_NAME": "col1", "CUT_COLUMN_NAME": "col2", "CUT_CONDITION": "<5."}],
-    )
-    # select 2nd/3rd column with larger difference between values
-    assert not compare_output.compare_files(
-        file1,
-        file2,
-        1.0e-3,
-        [{"TEST_COLUMN_NAME": "col1", "CUT_COLUMN_NAME": "col2", "CUT_CONDITION": ">5."}],
-    )
-
-
-def test_compare_files_json(create_json_file, file_name):
-    content = {"key": 1, "value": "1.23 4.56 7.89"}
-    file1 = create_json_file(file_name(1, "json"), content)
-    file2 = create_json_file(file_name(2, "json"), content)
-
-    assert compare_output.compare_files(file1, file2)
-
-
-def test_compare_files_yaml(create_yaml_file, file_name):
-    content = {"key": 1, "value": "1.23 4.56 7.89"}
-    file1 = create_yaml_file(file_name(1, "yaml"), content)
-    file2 = create_yaml_file(file_name(2, "yaml"), content)
-
-    assert compare_output.compare_files(file1, file2)
-
-
-def test_compare_files_different_suffixes(create_json_file, create_yaml_file, file_name):
-    content = {"key": 1, "value": "1.23 4.56 7.89"}
-    file1 = create_json_file(file_name(1, "json"), content)
-    file2 = create_yaml_file(file_name(2, "yaml"), content)
-
-    with pytest.raises(ValueError, match="File suffixes do not match"):
-        compare_output.compare_files(file1, file2)
-
-
-def test_compare_files_unknown_type(tmp_test_directory, file_name):
-    file1 = tmp_test_directory / file_name(1, "txt")
-    file2 = tmp_test_directory / file_name(2, "txt")
-    file1.write_text("dummy content", encoding="utf-8")
-    file2.write_text("dummy content", encoding="utf-8")
-
-    assert not compare_output.compare_files(file1, file2)
diff --git a/tests/unit_tests/testing/test_configuration.py b/tests/unit_tests/testing/test_configuration.py
new file mode 100644
index 000000000..dfb97d6ed
--- /dev/null
+++ b/tests/unit_tests/testing/test_configuration.py
@@ -0,0 +1,291 @@
+#!/usr/bin/python3
+
+from pathlib import Path
+
+import pytest
+import yaml
+
+import simtools.testing.configuration as configuration
+
+
+@pytest.fixture
+def integration_test_config_files():
+    config_path = Path("tests/integration_tests/config")
+    return sorted(config_path.glob("*.yml"))
+
+
+@pytest.fixture
+def tmp_config_string():
+    return "tmp_config.yml"
+
+
+@pytest.fixture
+def mocker_pytest_skip(mocker):
+    return mocker.patch("pytest.skip")
+
+
+def test_get_list_of_test_configurations_test_names(integration_test_config_files):
+    _, test_names = configuration.get_list_of_test_configurations(integration_test_config_files)
+
+    for test_name in test_names:
+        assert isinstance(test_name, str)
+        assert "simtools" in test_name
+    assert "simtools-calculate-trigger-rate_file_list" in test_names
+
+
+def test_get_list_of_test_configurations(integration_test_config_files):
+    test_configs, test_names = configuration.get_list_of_test_configurations(
+        integration_test_config_files
+    )
+
+    list_test_with_help = []
+    list_test_with_version = []
+    list_test_without_config = []
+
+    for test_config in test_configs:
+        assert isinstance(test_config, dict)
+        assert "APPLICATION" in test_config
+        assert "TEST_NAME" in test_config
+        if "HELP" in test_config.get("CONFIGURATION", {}):
+            list_test_with_help.append(test_config)
+        if "VERSION" in test_config.get("CONFIGURATION", {}):
+            list_test_with_version.append(test_config)
+        if "no_config" in test_config["TEST_NAME"]:
+            list_test_without_config.append(test_config)
+
+    assert len(test_names) == len(test_configs)
+    assert len(list_test_with_help) == len(list_test_with_version)
+    assert len(list_test_without_config) == len(list_test_with_help)
+
+
+def test_read_configs_from_files(integration_test_config_files):
+    config_files = integration_test_config_files
+
+    configs = configuration._read_configs_from_files(config_files)
+    assert len(configs) == len(config_files)
+
+
+def test_create_tmp_output_path(tmp_test_directory):
+    config = {"APPLICATION": "test_app", "TEST_NAME": "test_name"}
+    tmp_output_path = configuration.create_tmp_output_path(tmp_test_directory, config)
+    expected_path = tmp_test_directory / "test_app-test_name"
+
+    assert tmp_output_path == expected_path
+    assert tmp_output_path.exists()
+    assert tmp_output_path.is_dir()
+
+    with pytest.raises(
+        KeyError, match="No application defined in configuration {'TEST_NAME': 'test_name'}."
+    ):
+        configuration.create_tmp_output_path(tmp_test_directory, {"TEST_NAME": "test_name"})
+
+
+def test_get_application_command_with_config_file():
+    app = "test_app"
+    config_file = "test_config.yml"
+    expected_command = "python simtools/applications/test_app.py --config test_config.yml"
+
+    command = configuration.get_application_command(app, config_file=config_file)
+
+    assert command == expected_command
+
+
+def test_get_application_command_with_config_string():
+    app = "test_app"
+    config_string = "--version"
+    expected_command = "python simtools/applications/test_app.py --version"
+
+    command = configuration.get_application_command(app, config_string=config_string)
+
+    assert command == expected_command
+
+
+def test_get_application_command_with_simtools_app():
+    app = "simtools-test_app"
+    config_file = "test_config.yml"
+    expected_command = "simtools-test_app --config test_config.yml"
+
+    command = configuration.get_application_command(app, config_file=config_file)
+
+    assert command == expected_command
+
+
+def test_get_application_command_with_no_config():
+    app = "test_app"
+    expected_command = "python simtools/applications/test_app.py"
+
+    command = configuration.get_application_command(app)
+
+    assert command == expected_command
+
+
+def test_prepare_test_options_with_single_boolean_option(tmp_test_directory):
+    config = {"VERSION": True}
+    model_version = None
+
+    config_file, config_string, config_file_model_version = configuration._prepare_test_options(
+        config, tmp_test_directory, model_version
+    )
+
+    assert config_file is None
+    assert config_string == "--version"
+    assert config_file_model_version is None
+
+
+def test_prepare_test_options_with_model_version(tmp_test_directory, tmp_config_string):
+    config = {"MODEL_VERSION": "v1.0"}
+    model_version = "v2.0"
+
+    config_file, config_string, config_file_model_version = configuration._prepare_test_options(
+        config, tmp_test_directory, model_version
+    )
+
+    assert config_file == tmp_test_directory / tmp_config_string
+    assert config_string is None
+    assert config_file_model_version == "v1.0"
+
+    with open(config_file, encoding="utf-8") as file:
+        written_config = yaml.safe_load(file)
+    assert written_config["MODEL_VERSION"] == "v2.0"
+
+
+def test_prepare_test_options_with_output_path(tmp_test_directory, tmp_config_string):
+    config = {"OUTPUT_PATH": "results"}
+    model_version = None
+
+    config_file, config_string, config_file_model_version = configuration._prepare_test_options(
+        config, tmp_test_directory, model_version
+    )
+
+    assert config_file == tmp_test_directory / tmp_config_string
+    assert config_string is None
+    assert config_file_model_version is None
+
+    with open(config_file, encoding="utf-8") as file:
+        written_config = yaml.safe_load(file)
+    assert written_config["OUTPUT_PATH"] == str(tmp_test_directory / "results")
+    assert written_config["USE_PLAIN_OUTPUT_PATH"] is True
+
+
+def test_prepare_test_options_with_data_directory(tmp_test_directory, tmp_config_string):
+    config = {"DATA_DIRECTORY": "data"}
+    model_version = None
+
+    config_file, config_string, config_file_model_version = configuration._prepare_test_options(
+        config, tmp_test_directory, model_version
+    )
+
+    assert config_file == tmp_test_directory / tmp_config_string
+    assert config_string is None
+    assert config_file_model_version is None
+
+    with open(config_file, encoding="utf-8") as file:
+        written_config = yaml.safe_load(file)
+    assert written_config["DATA_DIRECTORY"] == str(tmp_test_directory / "data")
+
+
+def test_prepare_test_options_with_full_config(tmp_test_directory, tmp_config_string):
+    config = {"MODEL_VERSION": "v1.0", "OUTPUT_PATH": "results", "DATA_DIRECTORY": "data"}
+    model_version = "v2.0"
+
+    config_file, config_string, config_file_model_version = configuration._prepare_test_options(
+        config, tmp_test_directory, model_version
+    )
+
+    assert config_file == tmp_test_directory / tmp_config_string
+    assert config_string is None
+    assert config_file_model_version == "v1.0"
+
+    with open(config_file, encoding="utf-8") as file:
+        written_config = yaml.safe_load(file)
+    assert written_config["MODEL_VERSION"] == "v2.0"
+    assert written_config["OUTPUT_PATH"] == str(tmp_test_directory / "results")
+    assert written_config["USE_PLAIN_OUTPUT_PATH"] is True
+    assert written_config["DATA_DIRECTORY"] == str(tmp_test_directory / "data")
+
+
+def test_configure_with_model_version_use_current(tmp_test_directory, mocker, tmp_config_string):
+    config = {
+        "APPLICATION": "test_app",
+        "TEST_NAME": "test_name",
+        "CONFIGURATION": {"MODEL_VERSION": "v1.0", "MODEL_VERSION_USE_CURRENT": True},
+    }
+    request = mocker.Mock()
+    request.config.getoption.return_value = "v1.0"
+
+    cmd, config_file_model_version = configuration.configure(config, tmp_test_directory, request)
+
+    expected_cmd = "python simtools/applications/test_app.py --config " + str(
+        tmp_test_directory / "test_app-test_name" / tmp_config_string
+    )
+    assert cmd == expected_cmd
+    assert config_file_model_version == "v1.0"
+
+
+def test_configure_without_configuration(tmp_test_directory, mocker):
+    config = {"APPLICATION": "test_app", "TEST_NAME": "test_name"}
+    request = mocker.Mock()
+    request.config.getoption.return_value = None
+
+    cmd, config_file_model_version = configuration.configure(config, tmp_test_directory, request)
+
+    expected_cmd = "python simtools/applications/test_app.py"
+    assert cmd == expected_cmd
+    assert config_file_model_version is None
+
+
+def test_configure_with_configuration(tmp_test_directory, mocker, tmp_config_string):
+    config = {
+        "APPLICATION": "test_app",
+        "TEST_NAME": "test_name",
+        "CONFIGURATION": {"OUTPUT_PATH": "results", "DATA_DIRECTORY": "data"},
+    }
+    request = mocker.Mock()
+    request.config.getoption.return_value = None
+
+    cmd, config_file_model_version = configuration.configure(config, tmp_test_directory, request)
+
+    expected_cmd = "python simtools/applications/test_app.py --config " + str(
+        tmp_test_directory / "test_app-test_name" / tmp_config_string
+    )
+    assert cmd == expected_cmd
+    assert config_file_model_version is None
+
+    with open(
+        tmp_test_directory / "test_app-test_name" / tmp_config_string, encoding="utf-8"
+    ) as file:
+        written_config = yaml.safe_load(file)
+    assert written_config["OUTPUT_PATH"] == str(
+        tmp_test_directory / "test_app-test_name" / "results"
+    )
+    assert written_config["DATA_DIRECTORY"] == str(
+        tmp_test_directory / "test_app-test_name" / "data"
+    )
+
+
+def test_skip_test_for_model_version_no_model_version_use_current(mocker_pytest_skip):
+    config = {"CONFIGURATION": {"MODEL_VERSION": "v1.0"}}
+    model_version_requested = "v1.0"
+    configuration._skip_test_for_model_version(config, model_version_requested)
+    pytest.skip.assert_not_called()
+
+
+def test_skip_test_for_model_version_no_model_version_requested(mocker_pytest_skip):
+    config = {"CONFIGURATION": {"MODEL_VERSION": "v1.0"}, "MODEL_VERSION_USE_CURRENT": True}
+    model_version_requested = None
+    configuration._skip_test_for_model_version(config, model_version_requested)
+    pytest.skip.assert_not_called()
+
+
+def test_skip_test_for_model_version_skip(mocker_pytest_skip):
+    config = {"CONFIGURATION": {"MODEL_VERSION": "v1.0"}, "MODEL_VERSION_USE_CURRENT": True}
+    model_version_requested = "v2.0"
+    configuration._skip_test_for_model_version(config, model_version_requested)
+    pytest.skip.assert_called_once_with("Model version requested v2.0 not supported for this test")
+
+
+def test_skip_test_for_model_version_no_skip(mocker_pytest_skip):
+    config = {"CONFIGURATION": {"MODEL_VERSION": "v1.0"}, "MODEL_VERSION_USE_CURRENT": True}
+    model_version_requested = "v1.0"
+    configuration._skip_test_for_model_version(config, model_version_requested)
+    pytest.skip.assert_not_called()
diff --git a/tests/unit_tests/testing/test_helpers.py b/tests/unit_tests/testing/test_helpers.py
new file mode 100644
index 000000000..88c50a400
--- /dev/null
+++ b/tests/unit_tests/testing/test_helpers.py
@@ -0,0 +1,79 @@
+#!/usr/bin/python3
+
+import os
+from unittest import mock
+
+import pytest
+
+from simtools.testing import helpers
+
+
+@pytest.fixture
+def new_testeff_version():
+    return "simtools.testing.helpers._new_testeff_version"
+
+
+@pytest.fixture
+def builtins_open():
+    return "builtins.open"
+
+
+def test_new_testeff_version_true(builtins_open):
+    with mock.patch.dict(os.environ, {"SIMTOOLS_SIMTEL_PATH": "/fake/path"}):
+        with mock.patch(
+            builtins_open,
+            mock.mock_open(
+                read_data="/* Combine the include paths such that those from '-I...' options */"
+            ),
+        ):
+            assert helpers._new_testeff_version() is True
+
+
+def test_new_testeff_version_false(builtins_open):
+    with mock.patch.dict(os.environ, {"SIMTOOLS_SIMTEL_PATH": "/fake/path"}):
+        with mock.patch(builtins_open, mock.mock_open(read_data="Some other content")):
+            assert helpers._new_testeff_version() is False
+
+
+def test_new_testeff_version_no_env_var():
+    if "SIMTOOLS_SIMTEL_PATH" in os.environ:
+        del os.environ["SIMTOOLS_SIMTEL_PATH"]
+    with pytest.raises(TypeError):
+        helpers._new_testeff_version()
+
+
+def test_skip_camera_efficiency_old_testeff(new_testeff_version):
+    config = {"APPLICATION": "camera-efficiency", "TEST_NAME": "some_test"}
+    with mock.patch(new_testeff_version, return_value=False):
+        with pytest.raises(pytest.skip.Exception):
+            helpers.skip_camera_efficiency(config)
+
+
+def test_skip_camera_efficiency_new_testeff(new_testeff_version):
+    config = {"APPLICATION": "camera-efficiency", "TEST_NAME": "some_test"}
+    with mock.patch(new_testeff_version, return_value=True):
+        helpers.skip_camera_efficiency(config)
+
+
+def test_skip_camera_efficiency_specific_test(new_testeff_version):
+    config = {
+        "APPLICATION": "simtools-validate-camera-efficiency",
+        "TEST_NAME": "SSTS",
+    }
+    with mock.patch(new_testeff_version, return_value=True):
+        with pytest.raises(pytest.skip.Exception):
+            helpers.skip_camera_efficiency(config)
+
+
+def test_skip_camera_efficiency_not_camera_efficiency():
+    config = {"APPLICATION": "other-application", "TEST_NAME": "some_test"}
+    helpers.skip_camera_efficiency(config)
+
+
+def test_new_testeff_version_file_not_found(builtins_open):
+    with mock.patch.dict(os.environ, {"SIMTOOLS_SIMTEL_PATH": "/fake_for_test/path"}):
+        with mock.patch(builtins_open, side_effect=FileNotFoundError):
+            with pytest.raises(
+                FileNotFoundError, match="The testeff executable could not be found."
+            ):
+                helpers._new_testeff_version()
diff --git a/tests/unit_tests/testing/test_validate_output.py b/tests/unit_tests/testing/test_validate_output.py
new file mode 100644
index 000000000..eba199574
--- /dev/null
+++ b/tests/unit_tests/testing/test_validate_output.py
@@ -0,0 +1,473 @@
+#!/usr/bin/python3
+
+import json
+import logging
+from pathlib import Path
+
+import pytest
+import yaml
+from astropy.table import Table
+
+from simtools.testing import validate_output
+
+logging.getLogger().setLevel(logging.DEBUG)
+
+
+@pytest.fixture
+def create_json_file(tmp_test_directory):
+    def _create_json_file(file_name, content):
+        file = tmp_test_directory / file_name
+        file.write_text(json.dumps(content), encoding="utf-8")
+        return file
+
+    return _create_json_file
+
+
+@pytest.fixture
+def create_yaml_file(tmp_path):
+    def _create_yaml_file(file_name, content):
+        file = tmp_path / file_name
+        with open(file, "w", encoding="utf-8") as f:
+            yaml.dump(content, f)
+        return file
+
+    return _create_yaml_file
+
+
+@pytest.fixture
+def create_ecsv_file(tmp_path):
+    def _create_ecsv_file(file_name, content):
+        table = Table(content)
+        file_path = tmp_path / file_name
+        table.write(file_path, format="ascii.ecsv")
+        return file_path
+
+    return _create_ecsv_file
+
+
+@pytest.fixture
+def file_name():
+    def _file_name(counter, suffix):
+        return f"file{counter}.{suffix}"
+
+    return _file_name
+
+
+@pytest.fixture
+def output_path():
+    return "/path/to/output"
+
+
+@pytest.fixture
+def mock_validate_application_output(mocker):
+    return mocker.patch("simtools.testing.validate_output.validate_application_output")
+
+
+@pytest.fixture
+def mock_path_exists(mocker):
+    return mocker.patch("simtools.testing.validate_output.Path.exists", return_value=True)
+
+
+@pytest.fixture
+def mock_check_output(mocker):
+    return mocker.patch("simtools.testing.assertions.check_output_from_sim_telarray")
+
+
+@pytest.fixture
+def mock_validate_reference_output_file(mocker):
+    return mocker.patch("simtools.testing.validate_output._validate_reference_output_file")
+
+
+@pytest.fixture
+def mock_validate_output_path_and_file(mocker):
+    return mocker.patch("simtools.testing.validate_output._validate_output_path_and_file")
+
+
+@pytest.fixture
+def mock_assert_file_type(mocker):
+    return mocker.patch("simtools.testing.assertions.assert_file_type")
+
+
+def test_compare_json_files_float_strings(create_json_file, file_name):
+    content = {"key": 1, "value": "1.23 4.56 7.89"}
+    file1 = create_json_file(file_name(1, "json"), content)
+    file2 = create_json_file(file_name(2, "json"), content)
+
+    assert validate_output.compare_json_or_yaml_files(file1, file2)
+
+    content3 = {"key": 2, "value": "1.23 4.56 7.80"}
+    file3 = create_json_file(file_name(3, "json"), content3)
+    assert not validate_output.compare_json_or_yaml_files(file1, file3)
+
+
+def test_compare_json_files_equal_integers(create_json_file, file_name):
+    content = {"key": 1, "value": 5}
+    file1 = create_json_file(file_name(1, "json"), content)
+    file2 = create_json_file(file_name(2, "json"), content)
+
+    assert validate_output.compare_json_or_yaml_files(file1, file2)
+
+    content3 = {"key": 2, "value": 7}
+    file3 = create_json_file(file_name(3, "json"), content3)
+    assert not validate_output.compare_json_or_yaml_files(file1, file3)
+
+
+def test_compare_yaml_files_float_strings(create_yaml_file, file_name):
+    content = {"key": 1, "value": "1.23 4.56 7.89"}
+    file1 = create_yaml_file(file_name(1, "yaml"), content)
+    file2 = create_yaml_file(file_name(2, "yaml"), content)
+
+    assert validate_output.compare_json_or_yaml_files(file1, file2)
+
+    content3 = {"key": 2, "value": "1.23 4.56 7.80"}
+    file3 = create_yaml_file(file_name(3, "yaml"), content3)
+    assert not validate_output.compare_json_or_yaml_files(file1, file3)
+
+
+def test_compare_yaml_files_equal_integers(create_yaml_file, file_name):
+    content = {"key": 1, "value": 5}
+    file1 = create_yaml_file(file_name(1, "yaml"), content)
+    file2 = create_yaml_file(file_name(2, "yaml"), content)
+
+    assert validate_output.compare_json_or_yaml_files(file1, file2)
+
+    content3 = {"key": 2, "value": 7}
+    file3 = create_yaml_file(file_name(3, "yaml"), content3)
+    assert not validate_output.compare_json_or_yaml_files(file1, file3)
+
+
+def test_compare_ecsv_files_equal(create_ecsv_file, file_name):
+    content = {"col1": [1.1, 2.2, 3.3], "col2": [4.4, 5.5, 6.6]}
+    file1 = create_ecsv_file(file_name(1, "ecsv"), content)
+    file2 = create_ecsv_file(file_name(2, "ecsv"), content)
+
+    assert validate_output.compare_ecsv_files(file1, file2)
+
+
+def test_compare_ecsv_files_different_lengths(create_ecsv_file, file_name):
+    content1 = {"col1": [1.1, 2.2, 3.3], "col2": [4.4, 5.5, 6.6]}
+    content2 = {"col1": [1.1, 2.2], "col2": [4.4, 5.5]}
+    file1 = create_ecsv_file(file_name(1, "yaml"), content1)
+    file2 = create_ecsv_file(file_name(2, "ecsv"), content2)
+
+    assert not validate_output.compare_ecsv_files(file1, file2)
+
+
+def test_compare_ecsv_files_close_values(create_ecsv_file, file_name):
+    content1 = {"col1": [1.1001, 2.2001, 3.3001], "col2": [4.4001, 5.5001, 6.6001]}
+    content2 = {"col1": [1.1, 2.2, 3.3], "col2": [4.4, 5.5, 6.6]}
+    file1 = create_ecsv_file(file_name(1, "ecsv"), content1)
+    file2 = create_ecsv_file(file_name(2, "ecsv"), content2)
+
+    assert validate_output.compare_ecsv_files(file1, file2, tolerance=1.0e-3)
+
+
+def test_compare_ecsv_files_large_difference(create_ecsv_file, file_name):
+    content1 = {"col1": [1.1, 2.2, 3.3], "col2": [4.4, 5.5, 6.6]}
+    content2 = {"col1": [10.1, 20.2, 30.3], "col2": [40.4, 50.5, 60.6]}
+    file1 = create_ecsv_file(file_name(1, "ecsv"), content1)
+    file2 = create_ecsv_file(file_name(2, "ecsv"), content2)
+
+    assert not validate_output.compare_ecsv_files(file1, file2)
+
+
+def test_compare_files_ecsv(create_ecsv_file, file_name):
+    content = {"col1": [1.1, 2.2, 3.3], "col2": [4.4, 5.5, 6.6]}
+    file1 = create_ecsv_file(file_name(1, "ecsv"), content)
+    file2 = create_ecsv_file(file_name(2, "ecsv"), content)
+
+    assert validate_output.compare_files(file1, file2)
+
+
+def test_compare_files_ecsv_columns(create_ecsv_file, file_name):
+    content1 = {"col1": [1.1, 2.2, 3.3], "col2": [4.4, 5.5, 6.6]}
+    content2 = {"col1": [1.1, 2.21, 3.31], "col2": [4.47, 5.5, 6.6], "col3": [7.7, 8.8, 9.9]}
+    file1 = create_ecsv_file(file_name(1, "ecsv"), content1)
+    file2 = create_ecsv_file(file_name(2, "ecsv"), content2)
+
+    assert validate_output.compare_files(file1, file2, 0.5)
+    assert validate_output.compare_files(file1, file2, 0.005, [{"TEST_COLUMN_NAME": "col1"}])
+    assert not validate_output.compare_files(file1, file2, 0.005, None)
+    assert not validate_output.compare_files(file1, file2, 0.005, [{"TEST_COLUMN_NAME": "col2"}])
+    assert validate_output.compare_files(
+        file1,
+        file2,
+        0.005,
+        [{"TEST_COLUMN_NAME": "col1", "CUT_COLUMN_NAME": "col2", "CUT_CONDITION": "> 4.5"}],
+    )
+    # select first column only (same values)
+    assert validate_output.compare_files(
+        file1,
+        file2,
+        1.0e-3,
+        [{"TEST_COLUMN_NAME": "col1", "CUT_COLUMN_NAME": "col2", "CUT_CONDITION": "<5."}],
+    )
+    # select 2nd/3rd column with larger difference between values
+    assert not validate_output.compare_files(
+        file1,
+        file2,
+        1.0e-3,
+        [{"TEST_COLUMN_NAME": "col1", "CUT_COLUMN_NAME": "col2", "CUT_CONDITION": ">5."}],
+    )
+
+
+def test_compare_files_json(create_json_file, file_name):
+    content = {"key": 1, "value": "1.23 4.56 7.89"}
+    file1 = create_json_file(file_name(1, "json"), content)
+    file2 = create_json_file(file_name(2, "json"), content)
+
+    assert validate_output.compare_files(file1, file2)
+
+
+def test_compare_files_yaml(create_yaml_file, file_name):
+    content = {"key": 1, "value": "1.23 4.56 7.89"}
+    file1 = create_yaml_file(file_name(1, "yaml"), content)
+    file2 = create_yaml_file(file_name(2, "yaml"), content)
+
+    assert validate_output.compare_files(file1, file2)
+
+
+def test_compare_files_different_suffixes(create_json_file, create_yaml_file, file_name):
+    content = {"key": 1, "value": "1.23 4.56 7.89"}
+    file1 = create_json_file(file_name(1, "json"), content)
+    file2 = create_yaml_file(file_name(2, "yaml"), content)
+
+    with pytest.raises(ValueError, match="File suffixes do not match"):
+        validate_output.compare_files(file1, file2)
+
+
+def test_compare_files_unknown_type(tmp_test_directory, file_name):
+    file1 = tmp_test_directory / file_name(1, "txt")
+    file2 = tmp_test_directory / file_name(2, "txt")
+    file1.write_text("dummy content", encoding="utf-8")
+    file2.write_text("dummy content", encoding="utf-8")
+
+    assert not validate_output.compare_files(file1, file2)
+
+
+def test_validate_all_tests_no_model_version(mocker, mock_validate_application_output):
+    config = {"key": "value"}
+    request = mocker.Mock()
+    request.config.getoption.return_value = None
+    config_file_model_version = None
+
+    validate_output.validate_all_tests(config, request, config_file_model_version)
+
+    mock_validate_application_output.assert_called_once_with(config)
+
+
+def test_validate_all_tests_matching_model_version(mocker, mock_validate_application_output):
+    config = {"key": "value"}
+    request = mocker.Mock()
+    request.config.getoption.return_value = "1.0"
+    config_file_model_version = "1.0"
+
+    validate_output.validate_all_tests(config, request, config_file_model_version)
+
+    mock_validate_application_output.assert_called_once_with(config)
+
+
+def test_validate_all_tests_non_matching_model_version(mocker, mock_validate_application_output):
+    config = {"key": "value"}
+    request = mocker.Mock()
+    request.config.getoption.return_value = "1.0"
+    config_file_model_version = "2.0"
+
+    validate_output.validate_all_tests(config, request, config_file_model_version)
+
+    mock_validate_application_output.assert_not_called()
+
+
+def test_validate_reference_output_file(mocker, output_path):
+    config = {"CONFIGURATION": {"OUTPUT_PATH": output_path, "OUTPUT_FILE": "output_file"}}
+    integration_test = {
+        "REFERENCE_OUTPUT_FILE": "/path/to/reference/file",
+        "TOLERANCE": 1.0e-5,
+        "TEST_COLUMNS": None,
+    }
+
+    mock_compare_files = mocker.patch(
+        "simtools.testing.validate_output.compare_files", return_value=True
+    )
+
+    validate_output._validate_reference_output_file(config, integration_test)
+
+    mock_compare_files.assert_called_once_with(
+        integration_test["REFERENCE_OUTPUT_FILE"],
+        Path(config["CONFIGURATION"]["OUTPUT_PATH"]).joinpath(
+            config["CONFIGURATION"]["OUTPUT_FILE"]
+        ),
+        integration_test.get("TOLERANCE", 1.0e-5),
+        integration_test.get("TEST_COLUMNS", None),
+    )
+
+
+def test_validate_output_path_and_file_exists(output_path, mock_path_exists, mock_check_output):
+    config = {
+        "CONFIGURATION": {"OUTPUT_PATH": output_path, "DATA_DIRECTORY": "/path/to/data"},
+        "INTEGRATION_TESTS": [{"EXPECTED_OUTPUT": "expected_output"}],
+    }
+    integration_test = {"OUTPUT_FILE": "output_file"}
+
+    validate_output._validate_output_path_and_file(config, integration_test)
+
+    mock_path_exists.assert_called()
+    mock_check_output.assert_called_once_with(
+        Path(config["CONFIGURATION"]["DATA_DIRECTORY"]).joinpath(integration_test["OUTPUT_FILE"]),
+        "expected_output",
+    )
+
+
+def test_validate_output_path_and_file_exists_no_data_directory(
+    output_path, mock_path_exists, mock_check_output
+):
+    config = {
+        "CONFIGURATION": {
+            "OUTPUT_PATH": output_path,
+        },
+        "INTEGRATION_TESTS": [{"EXPECTED_OUTPUT": "expected_output"}],
+    }
+    integration_test = {"OUTPUT_FILE": "output_file"}
+
+    validate_output._validate_output_path_and_file(config, integration_test)
+
+    mock_path_exists.assert_called()
+    mock_check_output.assert_called_once_with(
+        Path(config["CONFIGURATION"]["OUTPUT_PATH"]).joinpath(integration_test["OUTPUT_FILE"]),
+        "expected_output",
+    )
+
+
+def test_validate_output_path_and_file_expected_output(
+    output_path, mock_path_exists, mock_check_output
+):
+    config = {
+        "CONFIGURATION": {"OUTPUT_PATH": output_path, "DATA_DIRECTORY": "/path/to/data2"},
+        "INTEGRATION_TESTS": [{"EXPECTED_OUTPUT": "expected_output"}],
+    }
+    integration_test = {"OUTPUT_FILE": "output_file"}
+
+    validate_output._validate_output_path_and_file(config, integration_test)
+
+    mock_path_exists.assert_called()
+    mock_check_output.assert_called_once_with(
+        Path(config["CONFIGURATION"]["DATA_DIRECTORY"]).joinpath(integration_test["OUTPUT_FILE"]),
+        "expected_output",
+    )
+
+
+def test_validate_output_path_and_file_log_hist(output_path, mock_path_exists, mock_check_output):
+    config = {
+        "CONFIGURATION": {"OUTPUT_PATH": output_path, "DATA_DIRECTORY": "/path/to/data1"},
+        "INTEGRATION_TESTS": [{"EXPECTED_OUTPUT": "expected_output"}],
+    }
+    integration_test = {"OUTPUT_FILE": "log_hist_output_file"}
+
+    validate_output._validate_output_path_and_file(config, integration_test)
+
+    mock_path_exists.assert_called()
+    mock_check_output.assert_not_called()
+
+
+def test_validate_application_output_no_integration_tests(mocker, output_path):
+    config = {"CONFIGURATION": {"OUTPUT_PATH": output_path}}
+    mock_logger_info = mocker.patch("simtools.testing.validate_output._logger.info")
+
+    validate_output.validate_application_output(config)
+
+    mock_logger_info.assert_not_called()
+
+
+def test_validate_application_output_with_reference_output_file(
+    output_path,
+    mock_assert_file_type,
+    mock_validate_output_path_and_file,
+    mock_validate_reference_output_file,
+):
+    config = {
+        "CONFIGURATION": {"OUTPUT_PATH": output_path},
+        "INTEGRATION_TESTS": [{"REFERENCE_OUTPUT_FILE": "/path/to/reference/file"}],
+    }
+
+    validate_output.validate_application_output(config)
+
+    mock_validate_reference_output_file.assert_called_once_with(
+        config, config["INTEGRATION_TESTS"][0]
+    )
+    mock_validate_output_path_and_file.assert_not_called()
+    mock_assert_file_type.assert_not_called()
+
+
+def test_validate_application_output_with_output_file(
+    output_path,
+    mock_assert_file_type,
+    mock_validate_output_path_and_file,
+    mock_validate_reference_output_file,
+):
+    config = {
+        "CONFIGURATION": {"OUTPUT_PATH": output_path},
+        "INTEGRATION_TESTS": [{"OUTPUT_FILE": "output_file"}],
+    }
+    validate_output.validate_application_output(config)
+
+    mock_validate_reference_output_file.assert_not_called()
+    mock_validate_output_path_and_file.assert_called_once_with(
+        config, config["INTEGRATION_TESTS"][0]
+    )
+    mock_assert_file_type.assert_not_called()
+
+
+def test_validate_application_output_with_file_type(
+    output_path,
+    mock_assert_file_type,
+    mock_validate_output_path_and_file,
+    mock_validate_reference_output_file,
+):
+    config = {
+        "CONFIGURATION": {"OUTPUT_PATH": output_path, "OUTPUT_FILE": "output_file"},
+        "INTEGRATION_TESTS": [{"FILE_TYPE": "ecsv"}],
+    }
+
+    validate_output.validate_application_output(config)
+
+    mock_validate_reference_output_file.assert_not_called()
+    mock_validate_output_path_and_file.assert_not_called()
+    mock_assert_file_type.assert_called_once_with(
+        "ecsv",
+        Path(config["CONFIGURATION"]["OUTPUT_PATH"]).joinpath(
+            config["CONFIGURATION"]["OUTPUT_FILE"]
+        ),
+    )
+
+
+def test_validate_application_output_all_checks(
+    output_path,
+    mock_assert_file_type,
+    mock_validate_output_path_and_file,
+    mock_validate_reference_output_file,
+):
+    config = {
+        "CONFIGURATION": {"OUTPUT_PATH": output_path, "OUTPUT_FILE": "output_file"},
+        "INTEGRATION_TESTS": [
+            {
+                "REFERENCE_OUTPUT_FILE": "/path/to/reference/file1",
+                "OUTPUT_FILE": "output_file",
+                "FILE_TYPE": "ecsv",
+            }
+        ],
+    }
+
+    validate_output.validate_application_output(config)
+
+    mock_validate_reference_output_file.assert_called_once_with(
+        config, config["INTEGRATION_TESTS"][0]
+    )
+    mock_validate_output_path_and_file.assert_called_once_with(
+        config, config["INTEGRATION_TESTS"][0]
+    )
+    mock_assert_file_type.assert_called_once_with(
+        "ecsv",
+        Path(config["CONFIGURATION"]["OUTPUT_PATH"]).joinpath(
+            config["CONFIGURATION"]["OUTPUT_FILE"]
+        ),
+    )