diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index b8ce57f418905a3890762c7bdcf2ac14310d91e3..7dea24a3e3ed1baa20658a103d8cd17c9ab38ba4 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,5 +1,6 @@
 stages:
   - build
+  - test
 
 variables:
   BUILD_ENV_DOCKER_IMAGE: docker-registry.ebrains.eu/esd/tmp:latest
@@ -7,7 +8,7 @@ variables:
 build-spack-env-on-runner:
   stage: build
   tags:
-    - esd_image
+    - docker-runner # esd_image
   image: $BUILD_ENV_DOCKER_IMAGE
   script:
     - /usr/sbin/sysctl user.max_user_namespaces
@@ -18,3 +19,19 @@ build-spack-env-on-runner:
     #  mkdir -p apptainer-install/
     #  curl -s https://raw.githubusercontent.com/apptainer/apptainer/main/tools/install-unprivileged.sh | bash -s - apptainer-install/
     - apptainer version
+
+testing:
+  stage: test
+  tags:
+    - docker-runner
+  image: python:latest # $BUILD_ENV_DOCKER_IMAGE
+  script:
+    - pip install -e .
+    - pytest ./esd/tests/ --junitxml=test-results.xml
+  artifacts:
+    when: always
+    reports:
+      junit: test-results.xml
+    paths:
+      - test-results.xml
+    expire_in: 1 week
\ No newline at end of file
diff --git a/esd/cli/__init__.py b/esd/cli/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/esd/fetch_cached_buildresults.py b/esd/cli/fetch_cached_buildresults.py
similarity index 100%
rename from esd/fetch_cached_buildresults.py
rename to esd/cli/fetch_cached_buildresults.py
diff --git a/esd/tests/__init__.py b/esd/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/esd/tests/utils_test.py b/esd/tests/utils_test.py
new file mode 100644
index 0000000000000000000000000000000000000000..8bec6c5880b3bdbc36e548477b6f9434162822ff
--- /dev/null
+++ b/esd/tests/utils_test.py
@@ -0,0 +1,63 @@
+import pytest
+from pathlib import Path
+
+from esd.utils.utils import clean_up
+
+
+@pytest.fixture
+def temp_directories(tmp_path):
+    """
+    Create temporary directories with files and subdirectories for testing.
+    """
+    test_dirs = []
+
+    for i in range(3):
+        dir_path = tmp_path / f"test_dir_{i}"
+        dir_path.mkdir()
+        test_dirs.append(str(dir_path))
+
+        # Add a file to the directory
+        file_path = dir_path / f"file_{i}.txt"
+        file_path.write_text(f"This is a test file in {dir_path}")
+
+        # Add a subdirectory with a file
+        sub_dir = dir_path / f"subdir_{i}"
+        sub_dir.mkdir()
+        sub_file = sub_dir / f"sub_file_{i}.txt"
+        sub_file.write_text(f"This is a sub file in {sub_dir}")
+
+    return test_dirs
+
+
+def test_clean_up(temp_directories, mocker):
+    """
+    Test the clean_up function to ensure directories and contents are removed.
+    """
+    # Mock the logger using pytest-mock's mocker fixture
+    mock_logger = mocker.MagicMock()
+
+    # Ensure directories exist before calling clean_up
+    for dir_path in temp_directories:
+        assert Path(dir_path).exists()
+
+    clean_up(temp_directories, mock_logger)
+
+    for dir_path in temp_directories:
+        assert not Path(dir_path).exists()
+
+    for dir_path in temp_directories:
+        mock_logger.info.assert_any_call(f"Removing {Path(dir_path).resolve()}")
+
+
+def test_clean_up_nonexistent_dirs(mocker):
+    """
+    Test the clean_up function with nonexistent directories.
+    """
+    # Mock the logger using pytest-mock's mocker fixture
+    mock_logger = mocker.MagicMock()
+    nonexistent_dirs = ["nonexistent_dir_1", "nonexistent_dir_2"]
+
+    clean_up(nonexistent_dirs, mock_logger)
+
+    for dir_path in nonexistent_dirs:
+        mock_logger.info.assert_any_call(f"{Path(dir_path).resolve()} does not exist")
diff --git a/esd/wscript b/esd/wscript
deleted file mode 100644
index 7db8e31e5eaf3d25509eccfa89b7989ee004f682..0000000000000000000000000000000000000000
--- a/esd/wscript
+++ /dev/null
@@ -1,21 +0,0 @@
-def depends(ctx):
-    ctx("spack", branch="visionary")
-
-def options(opt):
-    pass
-
-def configure(cfg):
-    pass
-
-def build(bld):
-    # install /bin
-    for bin in bld.path.ant_glob('bin/**/*'):
-        bld.install_as('${PREFIX}/%s' % bin.path_from(bld.path), bin)
-
-    # install /lib
-    for lib in bld.path.ant_glob('lib/**/*'):
-        bld.install_as('${PREFIX}/%s' % lib.path_from(bld.path), lib)
-
-    # install /share
-    for share in bld.path.ant_glob('share/**/*'):
-        bld.install_as('${PREFIX}/%s' % share.path_from(bld.path), share)
diff --git a/pyproject.toml b/pyproject.toml
index dd9ac1021b7091a7d1d4bb8066b7c5d8300cb76e..cd4afe5443ff7233f219c8cd69341955ac672f36 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,5 +15,7 @@ requires-python = ">=3.10"
 dependencies = [
     "oras",
     "spack",
-    "ruamel.yaml"
+    "ruamel.yaml",
+    "pytest",
+    "pytest-mock",
 ]
\ No newline at end of file