diff --git a/.gitignore b/.gitignore
index 6380b0dbcba11c8929393ab8d7603d571276e93a..6a8fbea7b1db968e5d15ebe71ceca7a0697a3b77 100644
--- a/.gitignore
+++ b/.gitignore
@@ -149,7 +149,10 @@ cython_debug/
 #  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
 #  and can be added to the global gitignore or merged into this file.  For a more nuclear
 #  option (not recommended) you can uncomment the following to ignore the entire idea folder.
-#.idea/
+.idea/
+
+# VSCode
+.vscode
 
 # Test artifacts
 tests/*.tif
\ No newline at end of file
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index c373802909a4316011c17fda2f5e558b3dd4a3d2..d37b36dfbfe4cca740828336e4a3f0cdaea032fb 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,50 +1,59 @@
 workflow:
   rules:
-    - if: $CI_MERGE_REQUEST_ID            # Execute jobs in merge request context
-    - if: $CI_COMMIT_BRANCH == 'develop'  # Execute jobs when a new commit is pushed to develop branch
+    - if: $CI_PIPELINE_SOURCE == "merge_request_event" || $CI_COMMIT_BRANCH == "develop"
 
-image: $CI_REGISTRY/orfeotoolbox/otb-build-env/otb-ubuntu-native-develop-headless:20.04
+default:
+  image: $CI_REGISTRY/orfeotoolbox/otb-build-env/otb-ubuntu-native-develop-headless:20.04
 
 stages:
   - Static Analysis
   - Documentation
-  - Test
+  - Tests
 
-# --------------------------------- Static analysis ---------------------------------
+#  -------------------------------- Static analysis --------------------------------
 
-.static_analysis_base:
+.static_analysis:
   stage: Static Analysis
+  rules:
+    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
+      changes:
+        - pyotb/*.py
+        - tests/*.py
   tags:
     - light
   allow_failure: true
 
-flake8:
-  extends: .static_analysis_base
-  script:
-    - pip install flake8
-    - python3 -m flake8 --max-line-length=120 $PWD/pyotb --ignore=F403,E402,F401,W503,W504
-
-pylint:
-  extends: .static_analysis_base
+codespell:
+  extends: .static_analysis
+  before_script:
+    - pip install codespell
   script:
-    - pip install pylint
-    - pylint --max-line-length=120 $PWD/pyotb --disable=too-many-nested-blocks,too-many-locals,too-many-statements,too-few-public-methods,too-many-instance-attributes,too-many-arguments,invalid-name,fixme,too-many-return-statements,too-many-lines,too-many-branches,import-outside-toplevel,wrong-import-position,wrong-import-order,import-error,missing-class-docstring
+    - codespell {pyotb,tests}
 
-codespell:
-  extends: .static_analysis_base
+flake8:
+  extends: .static_analysis
+  before_script:
+    - pip install flake8
   script:
-    - pip install codespell
-    - codespell --skip="*.png,*.jpg,*git/lfs*"
+    - flake8 --max-line-length=120 $PWD/pyotb --ignore=F403,E402,F401,W503,W504
 
 pydocstyle:
-  extends: .static_analysis_base
-  script:
+  extends: .static_analysis
+  before_script:
     - pip install pydocstyle
+  script:
     - pydocstyle $PWD/pyotb --convention=google
 
+pylint:
+  extends: .static_analysis
+  before_script:
+    - pip install pylint
+  script:
+    - pylint --max-line-length=120 $PWD/pyotb --disable=too-many-nested-blocks,too-many-locals,too-many-statements,too-few-public-methods,too-many-instance-attributes,too-many-arguments,invalid-name,fixme,too-many-return-statements,too-many-lines,too-many-branches,import-outside-toplevel,wrong-import-position,wrong-import-order,import-error,missing-class-docstring
+
+# ---------------------------------- Documentation  ----------------------------------
 
-# ------------------------------------------------------- Doc ----------------------------------------------------------
-.doc_base:
+.docs:
   stage: Documentation
   tags:
     - light
@@ -52,98 +61,70 @@ pydocstyle:
     - apt-get update && apt-get -y install virtualenv
     - virtualenv doc_env
     - source doc_env/bin/activate
-    - pip install --upgrade pip
-    - pip install mkdocstrings mkdocstrings[crystal,python] mkdocs-material mkdocs-gen-files mkdocs-section-index mkdocs-literate-nav mkdocs-mermaid2-plugin --upgrade
-  artifacts:
-    paths:
-      - public
-      - public_test
+    - pip install -U pip
+    - pip install -U -r doc/doc_requirements.txt
 
-test:
-  extends: .doc_base
-  rules:
-    - if: $CI_COMMIT_REF_NAME != $CI_DEFAULT_BRANCH
+pages_test:
+  extends: .docs
+  when: manual
   script:
     - mkdocs build --site-dir public_test
+  artifacts:
+    paths:
+      - public_test
 
 pages:
-  extends: .doc_base
+  extends: .docs
   rules:
     - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
   script:
     - mkdocs build --site-dir public
+  artifacts:
+    paths:
+      - public
 
+# -------------------------------------- Tests -------------------------------------- 
 
-# --------------------------------- Test ---------------------------------
-
-.test_base:
-  stage: Test
+.tests:
+  stage: Tests
+  rules:
+    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
+      changes:
+        - pyotb/*.py
+        - tests/*.py
   tags:
     - light
   allow_failure: false
   variables:
     OTB_ROOT: /opt/otb
     LD_LIBRARY_PATH: /opt/otb/lib
-    PYOTB_LOGGER_LEVEL: DEBUG
     OTB_LOGGER_LEVEL: INFO
-    PIPELINE_TEST_INPUT_IMAGE: image.tif
+    PYOTB_LOGGER_LEVEL: DEBUG
+    IMAGE_URL: https://gitlab.orfeo-toolbox.org/orfeotoolbox/otb/-/raw/develop/Data/Input/SP67_FR_subset_1.tif?inline=false
+    TEST_INPUT_IMAGE: tests/image.tif
+  artifacts:
+    reports:
+      junit: test-*.xml
   before_script:
-    - pip install .
-    - cd tests
-    - wget https://gitlab.orfeo-toolbox.org/orfeotoolbox/otb/-/raw/develop/Data/Input/SP67_FR_subset_1.tif?inline=false -O $PIPELINE_TEST_INPUT_IMAGE
-
-basic_tests:
-  extends: .test_base
-  script:
-    - python3 basic_tests.py
-
-compute_ndvi:
-  extends: .test_base
-  allow_failure: true
-  script:
-    - python3 ndvi_test.py
-
-numpy_array:
-  extends: .test_base
-  script:
-    - python3 numpy_array_test.py
-
-pipeline_test_shape:
-  extends: .test_base
-  script:
-    - python3 pipeline_test.py shape
-
-pipeline_test_shape_backward:
-  extends: .test_base
-  script:
-    - python3 pipeline_test.py shape backward
-
-pipeline_test_shape_nointermediate:
-  extends: .test_base
-  script:
-    - python3 pipeline_test.py shape no-intermediate-result
-
-pipeline_test_shape_backward_nointermediate:
-  extends: .test_base
-  script:
-    - python3 pipeline_test.py shape backward no-intermediate-result
+    - wget $IMAGE_URL -O $TEST_INPUT_IMAGE
+    - pip install pytest
 
-pipeline_test_write:
-  extends: .test_base
+test_core:
+  extends: .tests
   script:
-    - python3 pipeline_test.py write
+    - python3 -m pytest --color=yes --junitxml=test-core.xml tests/test_core.py
 
-pipeline_test_write_backward:
-  extends: .test_base
+test_numpy:
+  extends: .tests
   script:
-    - python3 pipeline_test.py write backward
+    - python3 -m pytest --color=yes --junitxml=test-numpy.xml tests/test_numpy.py
 
-pipeline_test_write_nointermediate:
-  extends: .test_base
+test_pipeline:
+  extends: .tests
   script:
-    - python3 pipeline_test.py write no-intermediate-result
+    - python3 -m pytest --color=yes --junitxml=test-pipeline.xml tests/test_pipeline.py
 
-pipeline_test_write_backward_nointermediate:
-  extends: .test_base
+test_serialization:
+  extends: .tests
   script:
-    - python3 pipeline_test.py write backward no-intermediate-result
+    - python3 -m pytest --color=yes --junitxml=test-serialization.xml tests/test_serialization.py
\ No newline at end of file
diff --git a/README.md b/README.md
index 7db6a788ab44ee1b91d3ab81444a130c3892464a..3993fce9644af09c87d5b629bba7f926ac37ea5d 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ Requirements:
 pip install pyotb --upgrade
 ```
 
-For Python>=3.6, latest version available is pyotb 1.5.0 For Python 3.5, latest version available is pyotb 1.2.2
+For Python>=3.6, latest version available is pyotb 1.5.1 For Python 3.5, latest version available is pyotb 1.2.2
 
 ## Quickstart: running an OTB application as a oneliner
 pyotb has been written so that it is more convenient to run an application in Python.
diff --git a/mkdocs.yml b/mkdocs.yml
index 1e1e0c10aeb416c0c690c945f4aa702e54345d07..56542796967f59adaed72babd6de011ec9afd6cd 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -47,7 +47,6 @@ nav:
       - functions: reference/pyotb/functions.md
       - helpers: reference/pyotb/helpers.md
 
-
 # Customization
 extra:
   feature:
@@ -69,7 +68,7 @@ markdown_extensions:
   - pymdownx.inlinehilite
   - pymdownx.snippets
 
-# rest of the navigation..
+# Rest of the navigation..
 site_name: "pyotb documentation: a Python extension of OTB"
 repo_url: https://gitlab.orfeo-toolbox.org/nicolasnn/pyotb
 repo_name: pyotb
diff --git a/pyotb/__init__.py b/pyotb/__init__.py
index f81141ca712c0e84d2a82afd7779353fcd1f18d8..71c0766b3f353d024a09e78d5b312278f1728d1d 100644
--- a/pyotb/__init__.py
+++ b/pyotb/__init__.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 """This module provides convenient python wrapping of otbApplications."""
-__version__ = "1.5.0"
+__version__ = "1.5.1"
 
 from .apps import *
 from .core import App, Output, Input, get_nbchannels, get_pixel_type
diff --git a/pyotb/core.py b/pyotb/core.py
index 3827c2c4903c2b89d53efd20fd91b7b9fa78a5c7..95cca7b9b1c9af3db2086bc2efb18687ed1ce22b 100644
--- a/pyotb/core.py
+++ b/pyotb/core.py
@@ -1,6 +1,5 @@
 # -*- coding: utf-8 -*-
 """This module is the core of pyotb."""
-from abc import ABC
 from pathlib import Path
 
 import numpy as np
@@ -9,34 +8,31 @@ import otbApplication as otb
 from .helpers import logger
 
 
-class otbObject(ABC):
-    """Abstract class that gathers common operations for any OTB in-memory raster.
-
-    All child of this class must have an `app` attribute that is an OTB application.
-    """
-    _parameters = {}
-
-    @property
-    def parameters(self):
-        """Property to merge otb.Application parameters and user's parameters dicts."""
-        parameters = self.app.GetParameters().items()
-        parameters = {k: str(v) if isinstance(v, otb.ApplicationProxy) else v for k, v in parameters}
-        return {**parameters, **self._parameters}
+class otbObject:
+    """Base class that gathers common operations for any OTB in-memory raster."""
+    _name = ""
+    app = None
+    output_param = ""
 
     @property
-    def output_param(self):
-        """Property to get object's unique (or first) output parameter key.
+    def name(self):
+        """Application name that will be printed in logs.
 
         Returns:
-           output parameter key
+            user's defined name or appname
+
+        """
+        return self._name or self.app.GetName()
+
+    @name.setter
+    def name(self, val):
+        """Set custom name.
+
+        Args:
+          val: new name
 
         """
-        if hasattr(self, 'output_parameter_key'):  # this is for Input, Output, Operation, Slicer
-            return self.output_parameter_key
-        # this is for App
-        if not self.output_parameters_keys:
-            return ""  # apps without outputs
-        return self.output_parameters_keys[0]
+        self._name = val
 
     @property
     def dtype(self):
@@ -97,8 +93,8 @@ class otbObject(ABC):
             typ = parse_pixel_type(pixel_type)
             if isinstance(self, App):
                 dtypes = {key: typ for key in self.output_parameters_keys}
-            elif hasattr(self, 'output_parameter_key'):
-                dtypes = {self.output_parameter_key: typ}
+            elif isinstance(self, otbObject):
+                dtypes = {self.output_param: typ}
 
         if filename_extension:
             logger.debug('%s: using extended filename for outputs: %s', self.name, filename_extension)
@@ -119,8 +115,8 @@ class otbObject(ABC):
         # Parse kwargs
         for key, output_filename in kwargs.items():
             # Stop process if a bad parameter is given
-            if not self.__check_output_param(key):
-                raise KeyError(f'{self.app.GetName()}: Wrong parameter key "{key}"')
+            if key not in self.app.GetParametersKeys():
+                raise KeyError(f'{self.app.GetName()}: Unknown parameter key "{key}"')
             # Check if extended filename was not provided twice
             if '?' in output_filename and filename_extension:
                 logger.warning('%s: extended filename was provided twice. Using the one found in path.', self.name)
@@ -137,7 +133,7 @@ class otbObject(ABC):
         try:
             self.app.WriteOutput()
         except RuntimeError:
-            logger.info('%s: failed to simply write output, executing once again then writing', self.name)
+            logger.debug('%s: failed to simply write output, executing once again then writing', self.name)
             self.app.ExecuteAndWriteOutput()
 
     def to_numpy(self, preserve_dtype=True, copy=False):
@@ -182,19 +178,6 @@ class otbObject(ABC):
         }
         return array, profile
 
-    def __check_output_param(self, key):
-        """Check param name to prevent strange behaviour in write() if kwarg key is not implemented.
-
-        Args:
-            key: parameter key
-
-        Returns:
-            bool flag
-        """
-        if hasattr(self, 'output_parameter_key'):
-            return key == self.output_parameter_key
-        return key in self.output_parameters_keys
-
     # Special methods
     def __getitem__(self, key):
         """Override the default __getitem__ behaviour.
@@ -547,11 +530,26 @@ class otbObject(ABC):
 
         return NotImplemented
 
+    def summarize(self):
+        """Return a nested dictionary summarizing the otbObject.
+
+        Returns:
+            Nested dictionary summarizing the otbObject
+
+        """
+        params = self.parameters
+        for k, p in params.items():
+            # In the following, we replace each parameter which is an otbObject, with its summary.
+            if isinstance(p, otbObject):  # single parameter
+                params[k] = p.summarize()
+            elif isinstance(p, list):  # parameter list
+                params[k] = [pi.summarize() if isinstance(pi, otbObject) else pi for pi in p]
+
+        return {"name": self.name, "parameters": params}
+
 
 class App(otbObject):
     """Class of an OTB app."""
-    _name = ""
-
     def __init__(self, appname, *args, frozen=False, quiet=False,
                  preserve_dtype=False, image_dic=None, **kwargs):
         """Enables to init an OTB application as a oneliner. Handles in-memory connection between apps.
@@ -580,43 +578,21 @@ class App(otbObject):
         self.quiet = quiet
         self.preserve_dtype = preserve_dtype
         self.image_dic = image_dic
-
         if self.quiet:
             self.app = otb.Registry.CreateApplicationWithoutLogger(appname)
         else:
             self.app = otb.Registry.CreateApplication(appname)
+        self.description = self.app.GetDocLongDescription()
         self.output_parameters_keys = self.__get_output_parameters_keys()
-        self._parameters = {}
+        if self.output_parameters_keys:
+            self.output_param = self.output_parameters_keys[0]
+
+        self.parameters = {}
         if (args or kwargs):
             self.set_parameters(*args, **kwargs)
         if not self.frozen:
             self.execute()
 
-    @property
-    def name(self):
-        """Application name that will be printed in logs.
-
-        Returns:
-            user's defined name or appname
-
-        """
-        return self._name or self.appname
-
-    @name.setter
-    def name(self, val):
-        """Set custom App name.
-
-        Args:
-          val: new name
-
-        """
-        self._name = val
-
-    @property
-    def description(self):
-        """Return app's long description from OTB documentation."""
-        return self.app.GetDocLongDescription()
-
     def set_parameters(self, *args, **kwargs):
         """Set some parameters of the app.
 
@@ -651,9 +627,12 @@ class App(otbObject):
                 self.__set_param(param, obj)
             except (RuntimeError, TypeError, ValueError, KeyError) as e:
                 raise Exception(f"{self.name}: something went wrong before execution "
-                                f"(while setting parameter {param} to '{obj}')") from e
-
-        self._parameters.update(parameters)
+                                f"(while setting parameter '{param}' to '{obj}')") from e
+        # Update _parameters using values from OtbApplication object
+        otb_params = self.app.GetParameters().items()
+        otb_params = {k: str(v) if isinstance(v, otb.ApplicationProxy) else v for k, v in otb_params}
+        self.parameters.update({**parameters, **otb_params})
+        # Update output images pixel types
         if self.preserve_dtype:
             self.__propagate_pixel_type()
 
@@ -666,7 +645,7 @@ class App(otbObject):
             raise Exception(f'{self.name}: error during during app execution') from e
         self.frozen = False
         logger.debug("%s: execution ended", self.name)
-        if self.__with_output():
+        if self.__has_output_param_key():
             logger.debug('%s: flushing data to disk', self.name)
             self.app.WriteOutput()
         self.__save_objects()
@@ -708,6 +687,14 @@ class App(otbObject):
         return [param for param in self.app.GetParametersKeys()
                 if self.app.GetParameterType(param) == otb.ParameterType_OutputImage]
 
+    def __has_output_param_key(self):
+        """Check if App has any output parameter key."""
+        if not self.output_param:
+            return True  # apps like ReadImageInfo with no filetype output param still needs to WriteOutput
+        types = (otb.ParameterType_OutputFilename, otb.ParameterType_OutputImage, otb.ParameterType_OutputVectorData)
+        outfile_params = [param for param in self.app.GetParametersKeys() if self.app.GetParameterType(param) in types]
+        return any(key in self.parameters for key in outfile_params)
+
     @staticmethod
     def __parse_args(args):
         """Gather all input arguments in kwargs dict.
@@ -760,34 +747,28 @@ class App(otbObject):
     def __propagate_pixel_type(self):
         """Propagate the pixel type from inputs to output.
 
-        If several inputs, the type of an arbitrary input is considered.
+        For several inputs, or with an image list, the type of the first input is considered.
         If several outputs, all outputs will have the same type.
 
         """
         pixel_type = None
         for key, param in self.parameters.items():
-            if key not in self.output_parameters_keys:
+            if self.__is_key_input_image(key):
+                if not param:
+                    continue
+                if isinstance(param, list):
+                    param = param[0]  # first image in "il"
                 try:
                     pixel_type = get_pixel_type(param)
+                    type_name = self.app.ConvertPixelTypeToNumpy(pixel_type)
+                    logger.debug('%s: output(s) will be written with type "%s"', self.name, type_name)
+                    for out_key in self.output_parameters_keys:
+                        self.app.SetParameterOutputImagePixelType(out_key, pixel_type)
+                    return
                 except TypeError:
                     pass
-                if isinstance(pixel_type, int):
-                    break
-        if pixel_type is None:
-            logger.warning("%s: could not propagate pixel type from inputs to output, no valid input found", self.name)
-        else:
-            type_name = self.app.ConvertPixelTypeToNumpy(pixel_type)
-            logger.debug('%s: output(s) will be written with type "%s"', self.name, type_name)
-            for out_key in self.output_parameters_keys:
-                self.app.SetParameterOutputImagePixelType(out_key, pixel_type)
 
-    def __with_output(self):
-        """Check if App has any output parameter key."""
-        if not self.output_param:
-            return True  # apps like ReadImageInfo with no filetype output param still needs to WriteOutput
-        types = (otb.ParameterType_OutputFilename, otb.ParameterType_OutputImage, otb.ParameterType_OutputVectorData)
-        outfile_params = [param for param in self.app.GetParametersKeys() if self.app.GetParameterType(param) in types]
-        return any(key in self.parameters for key in outfile_params)
+        logger.warning("%s: could not propagate pixel type from inputs to output, no valid input found", self.name)
 
     def __save_objects(self):
         """Saving app parameters and outputs as attributes, so that they can be accessed with `obj.key`.
@@ -810,6 +791,10 @@ class App(otbObject):
             if value is not None:
                 setattr(self, key, value)
 
+    def __is_key_input_image(self, key):
+        """Check if a key of the App is an input parameter image list."""
+        return self.app.GetParameterType(key) in (otb.ParameterType_InputImage, otb.ParameterType_InputImageList)
+
     def __is_key_list(self, key):
         """Check if a key of the App is an input parameter list."""
         return self.app.GetParameterType(key) in (otb.ParameterType_InputImageList, otb.ParameterType_StringList,
@@ -826,7 +811,7 @@ class App(otbObject):
         return f'<pyotb.App {self.appname} object id {id(self)}>'
 
 
-class Slicer(otbObject):
+class Slicer(App):
     """Slicer objects i.e. when we call something like raster[:, :, 2] from Python."""
 
     def __init__(self, x, rows, cols, channels):
@@ -845,14 +830,15 @@ class Slicer(otbObject):
         """
         # Initialize the app that will be used for writing the slicer
         self.name = 'Slicer'
+
         self.output_parameter_key = 'out'
-        app = App('ExtractROI', {'in': x, 'mode': 'extent'}, preserve_dtype=True, frozen=True)
-        parameters = {}
+        parameters = {'in': x, 'mode': 'extent'}
+        super().__init__('ExtractROI', parameters, preserve_dtype=True, frozen=True)
         # Channel slicing
         if channels != slice(None, None, None):
             # Trigger source app execution if needed
             nb_channels = get_nbchannels(x)
-            app.app.Execute()  # this is needed by ExtractROI for setting the `cl` parameter
+            self.app.Execute()  # this is needed by ExtractROI for setting the `cl` parameter
             # if needed, converting int to list
             if isinstance(channels, int):
                 channels = [channels]
@@ -890,10 +876,8 @@ class Slicer(otbObject):
                 {'mode.extent.lrx': cols.stop - 1})  # subtract 1 to be compliant with python convention
             spatial_slicing = True
         # Execute app
-        app.set_parameters(**parameters)
-        app.execute()
-        # Keeping the OTB app and the pyotb app
-        self.pyotb_app, self.app = app, app.app
+        self.set_parameters(**parameters)
+        self.execute()
 
         # These are some attributes when the user simply wants to extract *one* band to be used in an Operation
         if not spatial_slicing and isinstance(channels, list) and len(channels) == 1:
@@ -901,7 +885,7 @@ class Slicer(otbObject):
             self.input = x
 
 
-class Input(otbObject):
+class Input(App):
     """Class for transforming a filepath to pyOTB object."""
 
     def __init__(self, filepath):
@@ -911,13 +895,8 @@ class Input(otbObject):
             filepath: raster file path
 
         """
-        self.output_parameter_key = 'out'
         self.filepath = filepath
-        self.name = f'Input from {filepath}'
-        app = App('ExtractROI', filepath, preserve_dtype=True)
-
-        # Keeping the OTB app and the pyotb app
-        self.pyotb_app, self.app = app, app.app
+        super().__init__('ExtractROI', {'in': self.filepath}, preserve_dtype=True)
 
     def __str__(self):
         """Return a nice string representation with input file path."""
@@ -937,15 +916,25 @@ class Output(otbObject):
         """
         # Keeping the OTB app and the pyotb app
         self.pyotb_app, self.app = app, app.app
-        self.output_parameter_key = output_parameter_key
+        self.parameters = self.pyotb_app.parameters
+        self.output_param = output_parameter_key
         self.name = f'Output {output_parameter_key} from {self.app.GetName()}'
 
+    def summarize(self):
+        """Return the summary of the pipeline that generates the Output object.
+
+        Returns:
+            Nested dictionary summarizing the pipeline that generates the Output object.
+
+        """
+        return self.pyotb_app.summarize()
+
     def __str__(self):
         """Return a nice string representation with object id."""
         return f'<pyotb.Output {self.app.GetName()} object, id {id(self)}>'
 
 
-class Operation(otbObject):
+class Operation(App):
     """Class for arithmetic/math operations done in Python.
 
     Example:
@@ -1003,19 +992,14 @@ class Operation(otbObject):
 
         # getting unique image inputs, in the order im1, im2, im3 ...
         self.unique_inputs = [mapping_str_to_input[str_input] for str_input in sorted(self.im_dic, key=self.im_dic.get)]
-        self.output_parameter_key = 'out'
+        self.output_param = 'out'
 
         # Computing the BandMath or BandMathX app
         self.exp_bands, self.exp = self.get_real_exp(self.fake_exp_bands)
         self.name = f'Operation exp="{self.exp}"'
 
-        if len(self.exp_bands) == 1:
-            app = App('BandMath', il=self.unique_inputs, exp=self.exp)
-        else:
-            app = App('BandMathX', il=self.unique_inputs, exp=self.exp)
-
-        # Keeping the OTB app and the pyotb app
-        self.pyotb_app, self.app = app, app.app
+        appname = 'BandMath' if len(self.exp_bands) == 1 else 'BandMathX'
+        super().__init__(appname, il=self.unique_inputs, exp=self.exp)
 
     def create_fake_exp(self, operator, inputs, nb_bands=None):
         """Create a 'fake' expression.
@@ -1191,7 +1175,6 @@ class logicalOperation(Operation):
 
         """
         super().__init__(operator, *inputs, nb_bands=nb_bands)
-
         self.logical_exp_bands, self.logical_exp = self.get_real_exp(self.logical_fake_exp_bands)
 
     def create_fake_exp(self, operator, inputs, nb_bands=None):
diff --git a/pyotb/functions.py b/pyotb/functions.py
index 7fa05facf9409544e4e06b3dda9ca4eac62be1e9..eee401a3ba6638df6e86f9efe96cc8238c9a4655 100644
--- a/pyotb/functions.py
+++ b/pyotb/functions.py
@@ -7,7 +7,7 @@ import textwrap
 import uuid
 from collections import Counter
 
-from .core import (App, Input, Operation, logicalOperation, get_nbchannels)
+from .core import (otbObject, App, Input, Operation, logicalOperation, get_nbchannels)
 from .helpers import logger
 
 
@@ -352,10 +352,10 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m
     for inp in inputs:
         if isinstance(inp, str):  # this is for filepaths
             metadata = Input(inp).GetImageMetaData('out')
-        elif hasattr(inp, 'output_parameter_key'):  # this is for Output, Input, Operation
-            metadata = inp.GetImageMetaData(inp.output_parameter_key)
-        else:  # this is for App
-            metadata = inp.GetImageMetaData(inp.output_parameters_keys[0])
+        elif isinstance(inp, otbObject):
+            metadata = inp.GetImageMetaData(inp.output_param)
+        else:
+            raise TypeError(f"Wrong input : {inp}")
         metadatas[inp] = metadata
 
     # Get a metadata of an arbitrary image. This is just to compare later with other images
diff --git a/pyotb/helpers.py b/pyotb/helpers.py
index 522fcb407931ea99f6bf081cf7726c4165242603..742fee18be4bfbf6951d8e754cb102d470b16b4a 100644
--- a/pyotb/helpers.py
+++ b/pyotb/helpers.py
@@ -60,8 +60,11 @@ def find_otb(prefix=OTB_ROOT, scan=True, scan_userdir=True):
             set_environment(prefix)
             import otbApplication as otb  # pylint: disable=import-outside-toplevel
             return otb
-        except (ImportError, EnvironmentError) as e:
+        except EnvironmentError as e:
             raise SystemExit(f"Failed to import OTB with prefix={prefix}") from e
+        except ImportError as e:
+            __suggest_fix_import(str(e), prefix)
+            raise SystemExit("Failed to import OTB. Exiting.") from e
     # Else try import from actual Python path
     try:
         # Here, we can't properly set env variables before OTB import. We assume user did this before running python
@@ -91,8 +94,6 @@ def find_otb(prefix=OTB_ROOT, scan=True, scan_userdir=True):
         raise SystemExit("Can't run without OTB installed. Exiting.") from e
     # Help user to fix this
     except ImportError as e:
-        logger.critical("An error occurred while importing OTB Python API")
-        logger.critical("OTB error message was '%s'", e)
         __suggest_fix_import(str(e), prefix)
         raise SystemExit("Failed to import OTB. Exiting.") from e
 
@@ -118,13 +119,16 @@ def set_environment(prefix):
         raise EnvironmentError("Can't find OTB external libraries")
     # This does not seems to work
     if sys.platform == "linux" and built_from_source:
-        os.environ["LD_LIBRARY_PATH"] = f"{lib_dir}:{os.environ.get('LD_LIBRARY_PATH') or ''}"
+        new_ld_path = f"{lib_dir}:{os.environ.get('LD_LIBRARY_PATH') or ''}"
+        os.environ["LD_LIBRARY_PATH"] = new_ld_path
     # Add python bindings directory first in PYTHONPATH
     otb_api = __find_python_api(lib_dir)
     if not otb_api:
         raise EnvironmentError("Can't find OTB Python API")
     if otb_api not in sys.path:
         sys.path.insert(0, otb_api)
+    # Add /bin first in PATH, in order to avoid conflicts with another GDAL install when using os.system()
+    os.environ["PATH"] = f"{prefix / 'bin'}{os.pathsep}{os.environ['PATH']}"
     # Applications path  (this can be tricky since OTB import will succeed even without apps)
     apps_path = __find_apps_path(lib_dir)
     if Path(apps_path).exists():
@@ -142,11 +146,14 @@ def set_environment(prefix):
         # If installed using apt or built from source with system deps
         gdal_data = "/usr/share/gdal"
         proj_lib = "/usr/share/proj"
-    if not Path(gdal_data).exists():
-        logger.warning("Can't find GDAL directory with prefix %s or in /usr", prefix)
+    elif sys.platform == "win32":
+        gdal_data = str(prefix / "share/data")
+        proj_lib = str(prefix / "share/proj")
     else:
-        os.environ["GDAL_DATA"] = gdal_data
-        os.environ["PROJ_LIB"] = proj_lib
+        raise EnvironmentError(f"Can't find GDAL location with current OTB prefix '{prefix}' or in /usr")
+
+    os.environ["GDAL_DATA"] = gdal_data
+    os.environ["PROJ_LIB"] = proj_lib
 
 
 def __find_lib(prefix=None, otb_module=None):
@@ -266,8 +273,10 @@ def __find_otb_root(scan_userdir=False):
 
 def __suggest_fix_import(error_message, prefix):
     """Help user to fix the OTB installation with appropriate log messages."""
-    if error_message.startswith('libpython3.'):
-        if sys.platform == "linux":
+    logger.critical("An error occurred while importing OTB Python API")
+    logger.critical("OTB error message was '%s'", error_message)
+    if sys.platform == "linux":
+        if error_message.startswith('libpython3.'):
             logger.critical("It seems like you need to symlink or recompile python bindings")
             if sys.executable.startswith('/usr/bin'):
                 lib = f"/usr/lib/x86_64-linux-gnu/libpython3.{sys.version_info.minor}.so"
@@ -285,6 +294,16 @@ def __suggest_fix_import(error_message, prefix):
                     logger.critical("You may need to install cmake in order to recompile python bindings")
             else:
                 logger.critical("Unable to automatically locate python dynamic library of %s", sys.executable)
-        else:
-            docs_link = "https://www.orfeo-toolbox.org/CookBook/Installation.html"
-            logger.critical("You can verify installation requirements for your OS at %s", docs_link)
+            return
+    elif sys.platform == "win32":
+        if error_message.startswith("DLL load failed"):
+            if sys.version_info.minor != 7:
+                logger.critical("You need Python 3.5 (OTB releases 6.4 to 7.4) or Python 3.7 (since OTB 8)")
+                issue_link = "https://gitlab.orfeo-toolbox.org/orfeotoolbox/otb/-/issues/2010"
+                logger.critical("Another workaround is to recompile Python bindings with cmake, see %s", issue_link)
+            else:
+                logger.critical("It seems that your env variables aren't properly set,"
+                                " first use 'call otbenv.bat' then try to import pyotb once again")
+            return
+    docs_link = "https://www.orfeo-toolbox.org/CookBook/Installation.html"
+    logger.critical("You can verify installation requirements for your OS at %s", docs_link)
diff --git a/setup.py b/setup.py
index b1a1cf3bbce5ff99229c30551e98401b5e4022a4..beded0505c4a002621e35dc8b022e6a3901f26b3 100644
--- a/setup.py
+++ b/setup.py
@@ -6,7 +6,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
 
 setuptools.setup(
     name="pyotb",
-    version="1.5.0",
+    version="1.5.1",
     author="Nicolas Narçon",
     author_email="nicolas.narcon@gmail.com",
     description="Library to enable easy use of the Orfeo Tool Box (OTB) in Python",
diff --git a/tests/basic_tests.py b/tests/basic_tests.py
deleted file mode 100644
index d3d2d1391a4da6bbf36c8aff0a18bdf93b059f5c..0000000000000000000000000000000000000000
--- a/tests/basic_tests.py
+++ /dev/null
@@ -1,26 +0,0 @@
-import pyotb
-
-filepath = 'image.tif'
-inp = pyotb.Input(filepath)
-assert inp.dtype == 'uint8'
-assert inp.shape == (304, 251, 4)
-
-# Test slicer
-extract = inp[:50, :60, :3]
-assert extract.dtype == 'uint8'
-assert extract.shape == (50, 60, 3)
-
-# Test ReadImageInfo
-info = pyotb.ReadImageInfo(inp, quiet=True)
-assert info.sizex == 251
-assert info.sizey == 304
-assert info['numberbands'] == info.numberbands == 4
-
-# Test Statistics
-stats = pyotb.ComputeImagesStatistics([inp], quiet=True)
-assert stats['out.min'] == "[33, 64, 91, 47]"
-
-# Test Statistics on a Slicer
-slicer_stats = pyotb.ComputeImagesStatistics(il=[inp[:10, :10, 0]], quiet=True)
-assert slicer_stats['out.min'] == '[180]'
-
diff --git a/tests/ndvi_test.py b/tests/ndvi_test.py
deleted file mode 100644
index e469d5e1050a84811bfa764687cf54f823f0d03b..0000000000000000000000000000000000000000
--- a/tests/ndvi_test.py
+++ /dev/null
@@ -1,33 +0,0 @@
-import pyotb
-
-filepath = 'image.tif'
-inp = pyotb.Input(filepath)
-
-# Compute NDVI with bandmath
-ndvi_bandmath = (inp[:, :, -1] - inp[:, :, [0]]) / (inp[:, :, -1] + inp[:, :, 0])
-assert ndvi_bandmath.exp == '((im1b4 - im1b1) / (im1b4 + im1b1))'
-ndvi_bandmath.write('/tmp/ndvi_bandmath.tif', pixel_type='float')
-
-# Compute NDVI with RadiometricIndices app
-ndvi_indices = pyotb.RadiometricIndices({'in': inp, 'list': 'Vegetation:NDVI',
-                                         'channels.red': 1, 'channels.nir': 4})
-ndvi_indices.write('/tmp/ndvi_indices.tif', pixel_type='float')
-
-compared = pyotb.CompareImages({'ref.in': ndvi_indices, 'meas.in': '/tmp/ndvi_bandmath.tif'})
-assert compared.count == 0
-assert compared.mse == 0
-
-# Threshold
-thresholded_indices = pyotb.where(ndvi_indices >= 0.3, 1, 0)
-thresholded_bandmath = pyotb.where(ndvi_bandmath >= 0.3, 1, 0)
-assert thresholded_indices.exp == '((im1b1 >= 0.3) ? 1 : 0)'
-assert thresholded_bandmath.exp == '((((im1b4 - im1b1) / (im1b4 + im1b1)) >= 0.3) ? 1 : 0)'
-
-# Sum of bands
-summed = sum(inp[:, :, b] for b in range(inp.shape[-1]))
-assert summed.exp == '((((0 + im1b1) + im1b2) + im1b3) + im1b4)'
-
-# Create binary mask based on several possible values
-values = [1, 2, 3, 4]
-res = pyotb.where(pyotb.any(inp[:, :, 0] == value for value in values), 255, 0)
-assert res.exp == '(((((im1b1 == 1) || (im1b1 == 2)) || (im1b1 == 3)) || (im1b1 == 4)) ? 255 : 0)'
diff --git a/tests/numpy_array_test.py b/tests/numpy_array_test.py
deleted file mode 100644
index 025be54961ad712549ab7c3ff18243152afb8587..0000000000000000000000000000000000000000
--- a/tests/numpy_array_test.py
+++ /dev/null
@@ -1,44 +0,0 @@
-import numpy as np
-import pyotb
-from osgeo import osr
-
-filepath = 'image.tif'
-
-# Test to_numpy array
-inp = pyotb.Input(filepath)
-array = inp.to_numpy()
-assert array.dtype == np.uint8
-assert array.shape == (304, 251, 4)
-
-# Test to_numpy array with slicer
-inp = pyotb.Input(filepath)[:100, :200, :3]
-array = inp.to_numpy()
-assert array.dtype == np.uint8
-assert array.shape == (100, 200, 3)
-
-# Test conversion to numpy array
-array = np.array(inp)
-assert isinstance(array, np.ndarray)
-assert inp.shape == array.shape
-
-# Test image + noise README example
-white_noise = np.random.normal(0, 50, size=inp.shape)
-noisy_image = inp + white_noise
-assert isinstance(noisy_image, pyotb.App)
-assert noisy_image.shape == inp.shape
-
-# Test to_rasterio
-array, profile = inp.to_rasterio()
-# Data type and shape
-assert array.dtype == profile['dtype'] == np.uint8
-assert array.shape == (3, 100, 200)
-# Array statistics
-assert array.min() == 35
-assert array.max() == 255
-# Spatial reference
-assert profile['transform'] == (6.0, 0.0, 760056.0, 0.0, -6.0, 6946092.0)
-crs = osr.SpatialReference()
-crs.ImportFromEPSG(2154)
-dest_crs = osr.SpatialReference()
-dest_crs.ImportFromWkt(profile['crs'])
-assert dest_crs.IsSame(crs)
diff --git a/tests/pipeline_test.py b/tests/pipeline_test.py
deleted file mode 100644
index a3e8c660278ed1fed8377ac25a064ab2fc9378a2..0000000000000000000000000000000000000000
--- a/tests/pipeline_test.py
+++ /dev/null
@@ -1,189 +0,0 @@
-import sys
-import itertools
-import os
-import pyotb
-
-# List of buildings blocks
-# We can add other pyotb objects here
-OTBAPPS_BLOCKS = [
-    # lambda inp: pyotb.ExtractROI({"in": inp, "startx": 10, "starty": 10, "sizex": 50, "sizey": 50}),
-    lambda inp: pyotb.ManageNoData({"in": inp, "mode": "changevalue"}),
-    lambda inp: pyotb.DynamicConvert({"in": inp}),
-    lambda inp: pyotb.Mosaic({"il": [inp]}),
-    lambda inp: pyotb.BandMath({"il": [inp], "exp": "im1b1 + 1"}),
-    lambda inp: pyotb.BandMathX({"il": [inp], "exp": "im1"})
-]
-
-PYOTB_BLOCKS = [
-    lambda inp: 1 + abs(inp) * 2,
-    lambda inp: inp[:80, 10:60, :],
-]
-
-ALL_BLOCKS = PYOTB_BLOCKS + OTBAPPS_BLOCKS
-
-
-def backward():
-    """
-    Return True if backward mode.
-    In backward mode applications are tested from the end to the beginning of the pipeline.
-    """
-
-
-def check_app_write(app, out):
-    """
-    Check that the app write correctly its output
-    """
-    print(f"Checking app {app.name} writing")
-
-    # Remove output file it already there
-    if os.path.isfile(out):
-        os.remove(out)
-    # Write
-    try:
-        app.write(out)
-    except Exception as e:
-        print("\n\033[91mWRITE ERROR\033[0m")
-        print(e)
-        return False
-    # Report
-    if not os.path.isfile(out):
-        return False
-    return True
-
-
-filepath = os.environ["PIPELINE_TEST_INPUT_IMAGE"]
-pyotb_input = pyotb.Input(filepath)
-args = [arg.lower() for arg in sys.argv[1:]] if len(sys.argv) > 1 else []
-
-
-def generate_pipeline(inp, building_blocks):
-    """
-    Create pipeline formed with the given building blocks
-
-    Args:
-      inp: input
-      building_blocks: building blocks
-
-    Returns:
-        pipeline
-
-    """
-    # Create the N apps pipeline
-    pipeline = []
-    for app in building_blocks:
-        new_app_inp = pipeline[-1] if pipeline else inp
-        new_app = app(new_app_inp)
-        pipeline.append(new_app)
-    return pipeline
-
-
-def combi(building_blocks, length):
-    """Returns all possible combinations of N unique buildings blocks
-
-    Args:
-        building_blocks: building blocks
-        length: length
-
-    Returns:
-        list of combinations
-
-    """
-    av = list(itertools.combinations(building_blocks, length))
-    al = []
-    for a in av:
-        al += itertools.permutations(a)
-
-    return list(set(al))
-
-
-def test_pipeline(pipeline):
-    """Test the pipeline
-
-    Args:
-      pipeline: pipeline (list of pyotb objects)
-
-    """
-    report = {"shapes_errs": [], "write_errs": []}
-
-    # Test outputs shapes
-    pipeline_items = [pipeline[-1]] if "no-intermediate-result" in args else pipeline
-    generator = lambda: enumerate(pipeline_items)
-    if "backward" in args:
-        print("Perform tests in backward mode")
-        generator = lambda: enumerate(reversed(pipeline_items))
-    if "shape" in args:
-        for i, app in generator():
-            try:
-                print(f"Trying to access shape of app {app.name} output...")
-                shape = app.shape
-                print(f"App {app.name} output shape is {shape}")
-            except Exception as e:
-                print("\n\033[91mGET SHAPE ERROR\033[0m")
-                print(e)
-                report["shapes_errs"].append(i)
-
-    # Test all pipeline outputs
-    if "write" in args:
-        for i, app in generator():
-            if not check_app_write(app, f"/tmp/out_{i}.tif"):
-                report["write_errs"].append(i)
-
-    return report
-
-
-def pipeline2str(pipeline):
-    """Prints the pipeline blocks
-
-    Args:
-      pipeline: pipeline
-
-    Returns:
-        a string
-
-    """
-    return " \u2192 ".join([inp.__class__.__name__] + [f"{i}.{app.name.split(' ')[0]}"
-                                                       for i, app in enumerate(pipeline)])
-
-
-# Generate pipelines of different length
-blocks = {filepath: OTBAPPS_BLOCKS,  # for filepath, we can't use Slicer or Operation
-          pyotb_input: ALL_BLOCKS}
-pipelines = []
-for inp, blocks in blocks.items():
-    for length in [1, 2, 3]:
-        print(f"Testing pipelines of length {length}")
-        blocks_combis = combi(building_blocks=blocks, length=length)
-        for block_combi in blocks_combis:
-            pipelines.append(generate_pipeline(inp, block_combi))
-
-# Test pipelines
-pipelines.sort(key=lambda x: f"{len(x)}" "".join([app.name for app in x]))  # Sort by length then alphabetical
-results = {}
-for pipeline in pipelines:
-    print("\033[94m" f"\nTesting the following pipeline: {pipeline2str(pipeline)}\n" "\033[0m")
-    results.update({tuple(pipeline): test_pipeline(pipeline)})
-
-# Summary
-cols = max([len(pipeline2str(pipeline)) for pipeline in pipelines]) + 1
-print(f'Tests summary (\033[93mTest options: {"; ".join(args)}\033[0m)')
-print("Pipeline".ljust(cols) + " | Status (reason)")
-print("-" * cols + "-|-" + "-" * 20)
-nb_fails = 0
-allowed_to_fail = 0
-for pipeline, errs in results.items():
-    has_err = sum(len(value) for key, value in errs.items()) > 0
-    graph = pipeline2str(pipeline)
-    msg = graph + " ".ljust(cols - len(graph))
-    if has_err:
-        msg = f"\033[91m{msg}\033[0m"
-    msg += " | "
-    if has_err:
-        causes = [f"{section}: " + ", ".join([f"app{i}" for i in out_ids])
-                  for section, out_ids in errs.items() if out_ids]
-        msg += "\033[91mFAIL\033[0m (" + "; ".join(causes) + ")"
-        nb_fails += 1
-    else:
-        msg += "\033[92mPASS\033[0m"
-    print(msg)
-print(f"End of summary ({nb_fails} error(s))", flush=True)
-assert nb_fails == 0, "One of the pipelines have failed. Please read the report."
diff --git a/tests/test_core.py b/tests/test_core.py
new file mode 100644
index 0000000000000000000000000000000000000000..abefc80c7c5a03c06360e900d2e7dabee4e116b3
--- /dev/null
+++ b/tests/test_core.py
@@ -0,0 +1,88 @@
+import os
+import pyotb
+from ast import literal_eval
+from pathlib import Path
+
+
+FILEPATH = os.environ["TEST_INPUT_IMAGE"]
+INPUT = pyotb.Input(FILEPATH)
+
+
+# Basic tests
+def test_dtype():
+    assert INPUT.dtype == "uint8"
+
+
+def test_shape():
+    assert INPUT.shape == (304, 251, 4)
+
+
+def test_slicer_shape():
+    extract = INPUT[:50, :60, :3]
+    assert extract.shape == (50, 60, 3)
+
+
+def test_slicer_preserve_dtype():
+    extract = INPUT[:50, :60, :3]
+    assert extract.dtype == "uint8"
+
+
+# More complex tests
+def test_operation():
+    op = INPUT / 255 * 128
+    assert op.exp == "((im1b1 / 255) * 128);((im1b2 / 255) * 128);((im1b3 / 255) * 128);((im1b4 / 255) * 128)"
+
+
+def test_sum_bands():
+    # Sum of bands
+    summed = sum(INPUT[:, :, b] for b in range(INPUT.shape[-1]))
+    assert summed.exp == "((((0 + im1b1) + im1b2) + im1b3) + im1b4)"
+
+
+def test_binary_mask_where():
+    # Create binary mask based on several possible values
+    values = [1, 2, 3, 4]
+    res = pyotb.where(pyotb.any(INPUT[:, :, 0] == value for value in values), 255, 0)
+    assert res.exp == "(((((im1b1 == 1) || (im1b1 == 2)) || (im1b1 == 3)) || (im1b1 == 4)) ? 255 : 0)"
+
+
+# Apps
+def test_app_readimageinfo():
+    info = pyotb.ReadImageInfo(INPUT, quiet=True)
+    assert info.sizex == 251
+    assert info.sizey == 304
+    assert info["numberbands"] == info.numberbands == 4
+
+
+def test_app_computeimagestats():
+    stats = pyotb.ComputeImagesStatistics([INPUT], quiet=True)
+    assert stats["out.min"] == "[33, 64, 91, 47]"
+
+
+def test_app_computeimagestats_sliced():
+    slicer_stats = pyotb.ComputeImagesStatistics(il=[INPUT[:10, :10, 0]], quiet=True)
+    assert slicer_stats["out.min"] == "[180]"
+
+
+# NDVI
+def test_ndvi_comparison():
+    ndvi_bandmath = (INPUT[:, :, -1] - INPUT[:, :, [0]]) / (INPUT[:, :, -1] + INPUT[:, :, 0])
+    ndvi_indices = pyotb.RadiometricIndices(
+        {"in": INPUT, "list": "Vegetation:NDVI", "channels.red": 1, "channels.nir": 4}
+    )
+    assert ndvi_bandmath.exp == "((im1b4 - im1b1) / (im1b4 + im1b1))"
+
+    ndvi_bandmath.write("/tmp/ndvi_bandmath.tif", pixel_type="float")
+    assert Path("/tmp/ndvi_bandmath.tif").exists()
+    ndvi_indices.write("/tmp/ndvi_indices.tif", pixel_type="float")
+    assert Path("/tmp/ndvi_indices.tif").exists()
+
+    compared = pyotb.CompareImages({"ref.in": ndvi_indices, "meas.in": "/tmp/ndvi_bandmath.tif"})
+    assert compared.count == 0
+    assert compared.mse == 0
+
+    thresholded_indices = pyotb.where(ndvi_indices >= 0.3, 1, 0)
+    assert thresholded_indices.exp == "((im1b1 >= 0.3) ? 1 : 0)"
+
+    thresholded_bandmath = pyotb.where(ndvi_bandmath >= 0.3, 1, 0)
+    assert thresholded_bandmath.exp == "((((im1b4 - im1b1) / (im1b4 + im1b1)) >= 0.3) ? 1 : 0)"
diff --git a/tests/test_numpy.py b/tests/test_numpy.py
new file mode 100644
index 0000000000000000000000000000000000000000..5b1dd048cb73d2a9c904c3b937c6d60d8b8520c3
--- /dev/null
+++ b/tests/test_numpy.py
@@ -0,0 +1,53 @@
+import os
+import numpy as np
+import pyotb
+
+
+FILEPATH = os.environ["TEST_INPUT_IMAGE"]
+INPUT = pyotb.Input(FILEPATH)
+
+
+def test_to_numpy():
+    array = INPUT.to_numpy()
+    assert array.dtype == np.uint8
+    assert array.shape == INPUT.shape
+    assert array.min() == 33
+    assert array.max() == 255
+
+
+def test_to_numpy_sliced():
+    sliced = INPUT[:100, :200, :3]
+    array = sliced.to_numpy()
+    assert array.dtype == np.uint8
+    assert array.shape == (100, 200, 3)
+
+
+def test_convert_to_array():
+    array = np.array(INPUT)
+    assert isinstance(array, np.ndarray)
+    assert INPUT.shape == array.shape
+
+
+def test_add_noise_array():
+    white_noise = np.random.normal(0, 50, size=INPUT.shape)
+    noisy_image = INPUT + white_noise
+    assert isinstance(noisy_image, pyotb.otbObject)
+    assert noisy_image.shape == INPUT.shape
+
+
+def test_to_rasterio():
+    array, profile = INPUT.to_rasterio()
+    assert array.dtype == profile["dtype"] == np.uint8
+    assert array.shape == (4, 304, 251)
+    assert profile["transform"] == (6.0, 0.0, 760056.0, 0.0, -6.0, 6946092.0)
+
+    # CRS test requires GDAL python bindings
+    try:        
+        from osgeo import osr
+        crs = osr.SpatialReference()
+        crs.ImportFromEPSG(2154)
+        dest_crs = osr.SpatialReference()
+        dest_crs.ImportFromWkt(profile["crs"])
+        assert dest_crs.IsSame(crs)
+    except ImportError:
+        pass
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
new file mode 100644
index 0000000000000000000000000000000000000000..c2d8f0e910851f5412f044c5d4ef887613af57a0
--- /dev/null
+++ b/tests/test_pipeline.py
@@ -0,0 +1,153 @@
+import sys
+import os
+import itertools
+import pytest
+import pyotb
+from pyotb.helpers import logger
+
+
+# List of buildings blocks, we can add other pyotb objects here
+OTBAPPS_BLOCKS = [
+    # lambda inp: pyotb.ExtractROI({"in": inp, "startx": 10, "starty": 10, "sizex": 50, "sizey": 50}),
+    lambda inp: pyotb.ManageNoData({"in": inp, "mode": "changevalue"}),
+    lambda inp: pyotb.DynamicConvert({"in": inp}),
+    lambda inp: pyotb.Mosaic({"il": [inp]}),
+    lambda inp: pyotb.BandMath({"il": [inp], "exp": "im1b1 + 1"}),
+    lambda inp: pyotb.BandMathX({"il": [inp], "exp": "im1"})
+]
+
+PYOTB_BLOCKS = [
+    lambda inp: 1 + abs(inp) * 2,
+    lambda inp: inp[:80, 10:60, :],
+]
+PIPELINES_LENGTH = [1, 2, 3]
+
+ALL_BLOCKS = PYOTB_BLOCKS + OTBAPPS_BLOCKS
+FILEPATH = os.environ["TEST_INPUT_IMAGE"]
+INPUT = pyotb.Input(FILEPATH)
+
+
+def generate_pipeline(inp, building_blocks):
+    """
+    Create pipeline formed with the given building blocks
+
+    Args:
+      inp: input
+      building_blocks: building blocks
+
+    Returns:
+        pipeline
+
+    """
+    # Create the N apps pipeline
+    pipeline = []
+    for app in building_blocks:
+        new_app_inp = pipeline[-1] if pipeline else inp
+        new_app = app(new_app_inp)
+        pipeline.append(new_app)
+    return pipeline
+
+
+def combi(building_blocks, length):
+    """Returns all possible combinations of N unique buildings blocks
+
+    Args:
+        building_blocks: building blocks
+        length: length
+
+    Returns:
+        list of combinations
+
+    """
+    av = list(itertools.combinations(building_blocks, length))
+    al = []
+    for a in av:
+        al += itertools.permutations(a)
+
+    return list(set(al))
+
+
+def pipeline2str(pipeline):
+    """Prints the pipeline blocks
+
+    Args:
+      pipeline: pipeline
+
+    Returns:
+        a string
+
+    """
+    return " > ".join([INPUT.__class__.__name__] + [f"{i}.{app.name.split()[0]}"
+                                                       for i, app in enumerate(pipeline)])
+
+
+def make_pipelines_list():
+    """Create a list of pipelines using different lengths and blocks"""
+    blocks = {FILEPATH: OTBAPPS_BLOCKS,  # for filepath, we can't use Slicer or Operation
+              INPUT: ALL_BLOCKS}
+    pipelines = []
+    names = []
+    for inp, blocks in blocks.items():
+        # Generate pipelines of different length
+        for length in PIPELINES_LENGTH:
+            blocks_combis = combi(building_blocks=blocks, length=length)
+            for block in blocks_combis:
+                pipe = generate_pipeline(inp, block)
+                name = pipeline2str(pipe)
+                if name not in names:
+                    pipelines.append(pipe)
+                    names.append(name)
+
+    return pipelines, names
+
+
+PIPELINES, NAMES = make_pipelines_list()
+
+
+@pytest.mark.parametrize("pipe", PIPELINES, ids=NAMES)
+def test_pipeline_shape(pipe):
+    for i, app in enumerate(pipe):
+        print(app.shape)
+        assert bool(app.shape)
+
+
+@pytest.mark.parametrize("pipe", PIPELINES, ids=NAMES)
+def test_pipeline_shape_nointermediate(pipe):
+    app = [pipe[-1]][0]
+    assert bool(app.shape)
+
+
+@pytest.mark.parametrize("pipe", PIPELINES, ids=NAMES)
+def test_pipeline_shape_backward(pipe):
+    for i, app in enumerate(reversed(pipe)):
+        assert bool(app.shape)
+
+
+@pytest.mark.parametrize("pipe", PIPELINES, ids=NAMES)
+def test_pipeline_write(pipe):
+    for i, app in enumerate(pipe):
+        out = f"/tmp/out_{i}.tif"
+        if os.path.isfile(out):
+            os.remove(out)
+        app.write(out)
+        assert os.path.isfile(out)
+
+
+@pytest.mark.parametrize("pipe", PIPELINES, ids=NAMES)
+def test_pipeline_write_nointermediate(pipe):
+    app = [pipe[-1]][0]
+    out = f"/tmp/out_0.tif"
+    if os.path.isfile(out):
+        os.remove(out)
+    app.write(out)
+    assert os.path.isfile(out)
+
+
+@pytest.mark.parametrize("pipe", PIPELINES, ids=NAMES)
+def test_pipeline_write_backward(pipe):
+    for i, app in enumerate(reversed(pipe)):
+        out = f"/tmp/out_{i}.tif"
+        if os.path.isfile(out):
+            os.remove(out)
+        app.write(out)
+        assert os.path.isfile(out)
diff --git a/tests/test_serialization.py b/tests/test_serialization.py
new file mode 100644
index 0000000000000000000000000000000000000000..760747f12a683cd9edd7b5976d7e2a0a9361e297
--- /dev/null
+++ b/tests/test_serialization.py
@@ -0,0 +1,43 @@
+import os
+import pyotb
+
+filepath = os.environ["TEST_INPUT_IMAGE"]
+
+
+def test_pipeline_simple():
+    # BandMath -> OrthoRectification -> ManageNoData
+    app1 = pyotb.BandMath({'il': [filepath], 'exp': 'im1b1'})
+    app2 = pyotb.OrthoRectification({'io.in': app1})
+    app3 = pyotb.ManageNoData({'in': app2})
+    summary = app3.summarize()
+    reference = {'name': 'ManageNoData',
+                'parameters': {'in': {'name': 'OrthoRectification',
+                'parameters': {'io.in': {'name': 'BandMath',
+                    'parameters': {'il': ('tests/image.tif',), 'exp': 'im1b1'}},
+                    'map': 'utm',
+                    'outputs.isotropic': True}},
+                'mode': 'buildmask'}}
+    assert summary == reference
+
+def test_pipeline_diamond():
+    # Diamond graph
+    app1 = pyotb.BandMath({'il': [filepath], 'exp': 'im1b1'})
+    app2 = pyotb.OrthoRectification({'io.in': app1})
+    app3 = pyotb.ManageNoData({'in': app2})
+    app4 = pyotb.BandMathX({'il': [app2, app3], 'exp': 'im1+im2'})
+    summary = app4.summarize()
+    reference = {'name': 'BandMathX',
+                'parameters': {'il': [{'name': 'OrthoRectification',
+                    'parameters': {'io.in': {'name': 'BandMath',
+                    'parameters': {'il': ('tests/image.tif',), 'exp': 'im1b1'}},
+                    'map': 'utm',
+                    'outputs.isotropic': True}},
+                {'name': 'ManageNoData',
+                    'parameters': {'in': {'name': 'OrthoRectification',
+                    'parameters': {'io.in': {'name': 'BandMath',
+                        'parameters': {'il': ('tests/image.tif',), 'exp': 'im1b1'}},
+                    'map': 'utm',
+                    'outputs.isotropic': True}},
+                    'mode': 'buildmask'}}],
+                'exp': 'im1+im2'}}
+    assert summary == reference