Repository: HK3-Lab-Team/pytrousse

Issue:
Dataset property: operations_history 
[3a]

Discussion:
lgtm-com[bot]: This pull request **introduces 1 alert** when merging 616ebee830154b654e89da8da0583d8c3252b821 into c01b3131ef11f291a4286ec78640c1f1373ac47c - [view on LGTM.com](https://lgtm.com/projects/g/HK3-Lab-Team/pytrousse/rev/pr-81fc86223ac03524e5f18110fe28ee716f438815)

**new alerts:**

* 1 for Module is imported with &#39;import&#39; and &#39;import from&#39;
lgtm-com[bot]: This pull request **introduces 1 alert** when merging a1af7913760a61643425d1435a6bec6d27024d59 into c01b3131ef11f291a4286ec78640c1f1373ac47c - [view on LGTM.com](https://lgtm.com/projects/g/HK3-Lab-Team/pytrousse/rev/pr-9ac239da937f94d328fd7f420f52f6cb623a3b31)

**new alerts:**

* 1 for Module is imported with &#39;import&#39; and &#39;import from&#39;
lgtm-com[bot]: This pull request **introduces 2 alerts** when merging 043ccfa2e4755e8db7ffd81c4313bde3eade980b into c01b3131ef11f291a4286ec78640c1f1373ac47c - [view on LGTM.com](https://lgtm.com/projects/g/HK3-Lab-Team/pytrousse/rev/pr-02bf63b54cf1bd2747606dd7337d3518642e06be)

**new alerts:**

* 2 for Module is imported with &#39;import&#39; and &#39;import from&#39;

Patch:
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/README.md b/README.md
index 64132a8..01f5eb7 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,13 @@
 # PyTrousse
 
-[![Coverage Status](https://coveralls.io/repos/github/HK3-Lab-Team/pytrousse/badge.svg?branch=coveralls)](https://coveralls.io/github/HK3-Lab-Team/pytrousse?branch=master)
+[![Coverage Status](https://coveralls.io/repos/github/HK3-Lab-Team/pytrousse/badge.svg?branch=master&kill_cache=1)](https://coveralls.io/github/HK3-Lab-Team/pytrousse?branch=master)
 [![Build Status](https://travis-ci.com/HK3-Lab-Team/pytrousse.svg?branch=master)](https://travis-ci.com/HK3-Lab-Team/pytrousse)
 [![Total alerts](https://img.shields.io/lgtm/alerts/g/HK3-Lab-Team/pytrousse.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/HK3-Lab-Team/pytrousse/alerts/)
 [![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/HK3-Lab-Team/pytrousse.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/HK3-Lab-Team/pytrousse/context:python)
 
 ## WIP ⚠️
 
-PyTrousse helps to handle tabular data with many features/columns and to automatically track all the preprocessing steps performed on the dataset.
-This will help the data scientist to easily reproduce the entire preprocessing pipeline.
+PyTrousse collects into one toolbox a set of data wrangling procedures tailored for composing reproducible analytics pipelines. Data transformations include encoding, binning, scaling, strings replacement, NaN filling, column type conversion, data anonymization.
 
 ## Getting started
 The user can install PyTrousse in his/her Python virtual environment by cloning this repository:
@@ -24,12 +23,39 @@ $ cd pytrousse
 $ pip install .
 ```
 
-## Features
+## Main Features
+
+### Tracing the path from raw data
+
+PyTrousse transformations are progressively wrapped internally with the data, thus linking all stages of data preprocessing for future reproducibility. 
+
+Along with processed data, every `Dataset` object document how the user performed the analysis, in order to reproduce it in the future and to address questions about how the analysis was carried out months, years after the fact.
+
+The traced data path can be inspected through `operation_history` attribute.
+
+```python
+>>> dataset.operations_history
+```
+```bash
+[
+    FillNA(
+        columns=["column_with_nan"],
+        derived_columns=["column_filled"],
+        value=0,
+    ),
+    ReplaceSubstrings(
+        columns=["column_invalid_values"],
+        derived_columns=["column_valid_values"],
+        replacement_map={",": ".", "°": ""},
+    ),
+]
+```
+
+### Automatic column data type detection
 
-### Easy column type inference
 Wouldn't it be cool to have full column data type detection for your data?
 
-For example, PyTrousse can automatically identify categorical columns in your dataset using heuristic algorithms.
+PyTrousse expands Pandas tools for data type inference. Automatic identification is provided on an enlarged set of types (categorical, numerical, boolean, mixed, strings, etc.) using heuristic algorithms.
 
 ```python
 >>> import trousse
@@ -55,6 +81,7 @@ You can also get the name of boolean columns, numerical columns (i.e. containing
 ```
 
 ### Composable data transformations
+
 What about having an easy API for all those boring data preprocessing steps?
 
 Along with the common preprocessing utilities (for encoding, binning, scaling, etc.), PyTrousse provides tools for noisy data handling and for data anonymization.
@@ -63,44 +90,23 @@ Along with the common preprocessing utilities (for encoding, binning, scaling, e
 >>> from trousse.feature_operations import Compose, FillNA, ReplaceSubstrings
 
 >>> fillna_replacestrings = Compose(
-    [
-        FillNA(
-            columns=["column_with_nan"],
-            derived_columns=["column_filled"],
-            value=0,
-        ),
-        ReplaceSubstrings(
-            columns=["column_invalid_values"],
-            derived_columns=["column_valid_values"],
-            replacement_map={",": ".", "°": ""},
-        ),
-    ]
-)
+...     [
+...         FillNA(
+...             columns=["column_with_nan"],
+...             derived_columns=["column_filled"],
+...             value=0,
+...         ),
+...         ReplaceSubstrings(
+...             columns=["column_invalid_values"],
+...             derived_columns=["column_valid_values"],
+...             replacement_map={",": ".", "°": ""},
+...         ),
+...     ]
+... )
 
 >>> dataset = fillna_replacestrings(dataset)
 ```
 
-### Preprocessing pipeline tracking and export
-
-Every stage in the transformation process from raw data to clean data should be linked to prior stages. 
-This helps you to document how you performed the analysis, in order to reproduce it in the future. Also, it allows you to address questions about how the analysis was carried out months, years after the fact.
+### Integrated tools for synthetic data generation
 
-PyTrousse automagically tracks every dataset transformation which can be inspected through `operation_history` attribute.
-
-```python
->>> dataset.operations_history
-```
-```bash
-[
-    FillNA(
-        columns=["column_with_nan"],
-        derived_columns=["column_filled"],
-        value=0,
-    ),
-    ReplaceSubstrings(
-        columns=["column_invalid_values"],
-        derived_columns=["column_valid_values"],
-        replacement_map={",": ".", "°": ""},
-    ),
-]
-```
+PyTrousse aids automated testing by inverting the data transformation operators. Generation of testing fixtures and injection of errors is automatically available (more information [here](https://github.com/HK3-Lab-Team/pytrousse/blob/master/tests/README.md)).
diff --git a/src/trousse/anonymize_database.py b/src/trousse/anonymize_database.py
index 268cca6..17cb9bc 100644
--- a/src/trousse/anonymize_database.py
+++ b/src/trousse/anonymize_database.py
@@ -192,18 +192,13 @@ def anonymize_data(
     anonym_df = anonym_df.drop(private_cols_to_remove, axis=1)
 
     # Write the two DataFrames to CSV files
-    dest_path = str(dest_path)
-    file_name = str(file_name)
-    try:
-        private_df.to_csv(
-            os.path.join(dest_path, f"{file_name}_private_info.csv"),
-            mode="w+",
-            index=False,
-        )
-        anonym_df.to_csv(
-            os.path.join(dest_path, f"{file_name}_anonym.csv"), mode="w+", index=False
-        )
-    except FileNotFoundError:
-        print("FileNotFoundError: The destination path was not found")
+    private_df.to_csv(
+        os.path.join(dest_path, f"{file_name}_private_info.csv"),
+        mode="w+",
+        index=False,
+    )
+    anonym_df.to_csv(
+        os.path.join(dest_path, f"{file_name}_anonym.csv"), mode="w+", index=False
+    )
 
     return anonym_df, private_df
diff --git a/src/trousse/dataset.py b/src/trousse/dataset.py
index c3eadb2..fe32f7c 100644
--- a/src/trousse/dataset.py
+++ b/src/trousse/dataset.py
@@ -4,24 +4,22 @@ import dbm
 import logging
 import os
 import shelve
+import typing
 from dataclasses import dataclass, field
 from pathlib import Path
 from typing import DefaultDict, Dict, List, Set, Tuple, Union
 
 import pandas as pd
-import sklearn
 from joblib import Parallel, delayed
 
-from .exceptions import (
-    MultipleObjectsInFileError,
-    MultipleOperationsFoundError,
-    NotShelveFileError,
-)
-from .feature_enum import OperationTypeEnum
-from .feature_operation import FeatureOperation
+from .exceptions import MultipleObjectsInFileError, NotShelveFileError
+from .operations_list import OperationsList
 from .settings import CATEG_COL_THRESHOLD
 from .util import lazy_property
 
+if typing.TYPE_CHECKING:  # pragma: no cover
+    from .feature_operations import FeatureOperation
+
 logger = logging.getLogger(__name__)
 
 
@@ -142,7 +140,7 @@ class Dataset:
         data_file: str = None,
         df_object: pd.DataFrame = None,
         new_columns_encoding_maps: Union[
-            DefaultDict[str, List[FeatureOperation]], None
+            DefaultDict[str, List["FeatureOperation"]], None
         ] = None,
     ):
         """
@@ -197,6 +195,7 @@ class Dataset:
             # Dict already initialized to lists for every "column_name"
             new_columns_encoding_maps = collections.defaultdict(list)
         self.feature_elaborations = new_columns_encoding_maps
+        self._operations_history = OperationsList()
 
         # Columns generated by Feature Refactoring (e.g.: encoding, bin_splitting)
         self.derived_columns = set()
@@ -477,6 +476,17 @@ class Dataset:
         """
         return self._data
 
+    @property
+    def operations_history(self) -> OperationsList:
+        """Return the history of the operations performed on the dataset.
+
+        Returns
+        -------
+        OperationsList
+            History of the operations
+        """
+        return self._operations_history
+
     def _get_categorical_cols(self, col_list: Tuple[str]) -> Set[str]:
         """
         Identify every categorical column in dataset.
@@ -547,12 +557,9 @@ class Dataset:
 
         """
         to_be_encoded_categorical_cols = set()
-        cols_by_type = self._columns_type
         # TODO: Check this because maybe categorical columns that are numerical, do
         #  not need encoding probably!
-        categorical_cols = (
-            cols_by_type.str_categorical_cols | cols_by_type.num_categorical_cols
-        )
+        categorical_cols = self.str_categorical_cols | self.num_categorical_cols
         for categ_col in categorical_cols:
             if self.get_enc_column_from_original(categ_col) is None:
                 to_be_encoded_categorical_cols.add(categ_col)
@@ -563,35 +570,6 @@ class Dataset:
     # =    METHODS        =
     # =====================
 
-    def get_encoded_string_values_map(
-        self, column_name: str
-    ) -> Union[Dict[int, str], None]:
-        """
-        Return the encoded values map of the column named ``column_name``.
-
-        Selecting the first operation of column_name because it will be the operation
-        that created it (whether it is the encoded of one or multiple columns)
-
-        Parameters
-        ----------
-        column_name: str
-            Name of the derived column which we are looking the encoded_values_map of
-
-        Returns
-        -------
-        Dict[int, str]
-            Dict where the keys are the integer values of the ``column_name``, and the
-                                   values are the values of the encoded column
-        """
-        try:
-            encoded_map = self.feature_elaborations[column_name][
-                0
-            ].encoded_string_values_map
-            return encoded_map
-        except (KeyError, IndexError):
-            logging.info(f"The column {column_name} was not among the operations.")
-            return None
-
     def convert_column_id_to_name(self, col_id_list: Tuple[int]) -> Set:
         """
         Convert the column IDs to column names
@@ -657,228 +635,31 @@ class Dataset:
                 f"{i}: {col_type_dict[_COL_NAME_COLUMN]} -> {col_type_dict[_COL_TYPE_COLUMN]}"
             )
 
-    def add_operation(self, feature_operation: FeatureOperation) -> None:
+    def add_operation(self, feature_operation: "FeatureOperation") -> None:
         """
-        Add a new operation to the instance attribute ``feature_operations``
+        Add a new operation to the history.
 
-        For each column contained in ``original_columns`` or ``derived_columns``,
-        attributes of ``feature_operation``, this method adds ``feature_operation``
-        to the corresponding key of this instance attribute ``feature_operations``
-        dictionary.
-        This method also checks if every column in ``original_columns`` is
-        in the list of ``metadata_cols`` attribute. In that case the derived column(s)
-        contains metadata information and it (they) will be added to
-        ``metadata_cols`` attribute.
+        If every original column is in the ``metadata_cols`` attribute, then all the
+        derived columns will be added to ``metadata_cols``.
 
         Parameters
         ----------
         feature_operation: FeatureOperation
-            FeatureOperation instance that will be added to the keys of
-            ``self.feature_operation`` attribute corresponding to ``original_columns``
-            or ``derived_columns`` feature_operation attributes
-        """
-        # TODO: Think about the case where the same column is among "original_columns"
-        #  and "derived_columns". Does this make sense? Should we raise an error?
-        #  Would we add the same FeatureOperation twice to the column? That could
-        #  create problems when looking for that operation because two are found!
-        # This is used to identify the type of columns produced (it will be tested
-        # and changed in the loop)
-        is_metadata_cols = True
-        # Loop for every original column name, so we append this operation to every
-        # column_name
-        for o in feature_operation.original_columns:
-            self.feature_elaborations[o].append(feature_operation)
-            # Check if at least one of original_columns is not in the list of
-            # metadata_cols (in that case it does not contain only metadata information)
-            if o not in self.metadata_cols:
-                is_metadata_cols = False
-
-        # TODO: Next line should now raise an Error because None means
-        #  "Not Specified", and if user wants to leave it blank, he should use "()",
-        #  (which is the new default value)
+            FeatureOperation to be added to the history
+        """
+        self._operations_history += feature_operation
+
         if feature_operation.derived_columns is not None:
-            # Add the same operation for each derived column
-            for d in feature_operation.derived_columns:
-                self.feature_elaborations[d].append(feature_operation)
-            # If every original_column is in the list of metadata_cols, the
+            # If every original column is in the list of metadata_cols, the
             # derived_columns is also derived by metadata_cols only and therefore
             # must be inserted in metadata_cols set, too
-            if is_metadata_cols:
+
+            if all(
+                [column in self.metadata_cols for column in feature_operation.columns]
+            ):
                 self._metadata_cols = self.metadata_cols.union(
                     set(feature_operation.derived_columns)
                 )
-            # Add the derived columns to the list of the instance
-            self.derived_columns = self.derived_columns.union(
-                set(feature_operation.derived_columns)
-            )
-
-    def find_operation_in_column(
-        self, feat_operation: FeatureOperation
-    ) -> Union[FeatureOperation, None]:
-        """
-        Search an operation previously executed on this object.
-
-        This method checks in ``feature_operations`` attribute if an operation similar
-        to ``feat_operation`` has already been performed on this instance.
-        Therefore, the unknown attributes of the operation can be set to None
-        (corresponding to "Not Specified") and, if the operation is found,
-        it will contain full information and it will be returned.
-
-        Parameters
-        ----------
-        feat_operation: FeatureOperation
-            FeatureOperation instance containing some information about the operation
-            the user is looking for. It must contain either ``original_columns`` or
-            ``derived_columns`` attribute, otherwise no operation is returned.
-            If some attributes are unknown and the user wants to leave them unspecified,
-            they can be set to None.
-
-        Returns
-        -------
-        FeatureOperation
-            FeatureOperation instance that has been performed on the Dataset
-            instance, and that has the same specified attributes of ``feat_operation``.
-            If no operation similar to ``feat_operation`` is found, None is returned.
-            If more than one operation similar to ``feat_operation`` is found,
-            MultipleOperationsFoundError is raised.
-
-        Raises
-        ------
-        MultipleOperationsFoundError
-            Exception raised when more than one operation similar to ``feat_operation``
-            is found.
-
-        """
-        # Select only the first element of the original_columns (since each of the
-        # columns is linked to an operation) and check if the 'feat_operation'
-        # argument is among the operations linked to that column.
-        if feat_operation.original_columns is not None:
-            selected_column_operations = self.feature_elaborations[
-                feat_operation.original_columns[0]
-            ]
-        elif feat_operation.derived_columns is not None:
-            selected_column_operations = self.feature_elaborations[
-                feat_operation.derived_columns[0]
-            ]
-        else:
-            logging.warning(
-                "It is not possible to look for an operation if neither "
-                "original columns nor derived columns attributes are provided"
-            )
-            return None
-
-        similar_operations = []
-        for f in selected_column_operations:
-            if f == feat_operation:
-                similar_operations.append(f)
-
-        if len(similar_operations) == 0:
-            return None
-        elif len(similar_operations) == 1:
-            return similar_operations[0]
-        else:
-            nl = "\n"
-            raise MultipleOperationsFoundError(
-                "Multiple operations were found. Please provide additional information."
-                "\nOperations found: "
-                + str(
-                    [
-                        f"{nl * 2}{i}. {sim_op}"
-                        for (i, sim_op) in enumerate(similar_operations)
-                    ]
-                )
-            )
-
-    def get_enc_column_from_original(
-        self,
-        column_name: str,
-        encoder: sklearn.preprocessing._encoders._BaseEncoder = None,
-    ) -> Union[Tuple[str, ...], None]:
-        """
-        Return the name of the column with encoded values, derived from ``column_name``.
-
-        This method checks if an operation of type ``OperationTypeEnum.CATEGORICAL_ENCODING``
-        has been performed on the ``column_name`` column. In case it is, it will return
-        the name of the column with encoded values, otherwise None.
-
-        Parameters
-        ----------
-        column_name: str
-            Name of the original column that has been encoded
-        encoder: sklearn.preprocessing._encoders._BaseEncoder, optional
-            Type of encoder used to encode ``column_name`` column
-
-        Returns
-        -------
-        Tuple[str]
-            Tuple of names of one (or more) columns with encoded values. None
-            if the column has not been encoded
-
-        See Also
-        --------
-        If the user wants to check the columns generated by operations of different
-        type, 'find_operation_in_column' method should be employed.
-        """
-        feat_operation = FeatureOperation(
-            operation_type=OperationTypeEnum.CATEGORICAL_ENCODING,
-            original_columns=column_name,
-            encoder=encoder,
-            derived_columns=None,
-        )
-        found_operat = self.find_operation_in_column(feat_operation)
-        # If no operation is found, or the column is the derived column
-        # (i.e. the input of encoding function), we return None
-        # TODO: Is the check "column_name in found_operat.derived_columns" necessary?
-        if found_operat is None or column_name in found_operat.derived_columns:
-            return None
-        else:
-            return found_operat.derived_columns
-
-    def get_original_from_enc_column(
-        self,
-        column_name: str,
-        encoder: sklearn.preprocessing._encoders._BaseEncoder = None,
-    ) -> Union[Tuple[str, ...], None]:
-        """
-        Return the name of the column with original values, used to generate ``column_name``.
-
-        This method checks if an operation of type OperationTypeEnum.CATEGORICAL_ENCODING
-        has been performed and generated ``column_name`` column. In case it exists, it
-        will return the name of the column with original values, otherwise None.
-
-        Parameters
-        ----------
-        column_name: str
-            Name of the column that has been encoded.
-        encoder: sklearn.preprocessing._encoders._BaseEncoder, optional
-            Type of encoder used to generate ``column_name`` column.
-
-        Returns
-        -------
-        Tuple[str]
-            Tuple of names of one (or more) column name(s) with original values. None
-            if the column is not generated by a OperationTypeEnum.CATEGORICAL_ENCODING
-            operation.
-
-        See Also
-        --------
-        If the user wants to check the columns generated by operations of different
-        type, 'find_operation_in_column' method should be employed.
-        """
-        feat_operation = FeatureOperation(
-            operation_type=OperationTypeEnum.CATEGORICAL_ENCODING,
-            original_columns=None,
-            encoder=encoder,
-            derived_columns=column_name,
-        )
-        found_operat = self.find_operation_in_column(feat_operation)
-        # If no operation is found, or the column is the derived column
-        # (i.e. the input of encoding function), we return None
-        # TODO: Is the check "column_name in found_operat.original_columns" necessary?
-        if found_operat is None or column_name in found_operat.original_columns:
-            return None
-        else:
-            return found_operat.original_columns
 
     def to_file(self, filename: Union[Path, str], overwrite: bool = False) -> None:
         """
diff --git a/src/trousse/feature_enum.py b/src/trousse/feature_enum.py
deleted file mode 100644
index 5cb2df2..0000000
--- a/src/trousse/feature_enum.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from enum import Enum
-
-from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder
-
-
-class OperationTypeEnum(Enum):
-    CATEGORICAL_ENCODING = "categ_encoding"
-    BIN_SPLITTING = "bin_splitting"
-    FEAT_COMBOS_ENCODING = "feature_combination_encoding"
-
-
-ENCODED_COLUMN_SUFFIX = "_enc"
-
-
-class EncodingFunctions(Enum):
-    # Enum for possible encoding functions
-
-    # Classic Encoders
-    # convert string labels to integer values 1 through k. Ordinal.
-    ORDINAL = OrdinalEncoder
-    # one column for each value to compare vs. all other values. Nominal, ordinal.
-    ONEHOT = OneHotEncoder
diff --git a/src/trousse/feature_operation.py b/src/trousse/feature_operation.py
deleted file mode 100644
index 0b5d76b..0000000
--- a/src/trousse/feature_operation.py
+++ /dev/null
@@ -1,189 +0,0 @@
-from typing import Any, Dict, Iterable, Tuple, Union
-
-import sklearn
-
-from .feature_enum import EncodingFunctions, OperationTypeEnum
-
-
-def _to_tuple(x: Union[str, Iterable]) -> Tuple:
-    """
-    Transform input into a tuple.
-
-    This function transforms the input ``x`` into a tuple avoiding to interpret a
-    string as a tuple of characters.
-    In case ``x`` is None, the function returns None.
-
-    Parameters
-    ----------
-    x: Union[str, Iterable]
-        The variable that needs to be transformed to tuple
-
-    Returns
-    -------
-    Tuple
-        If the input ``x`` is list/set, ``x`` is cast to tuple and returned. Otherwise
-        a tuple containing ``x`` element only is returned.
-    """
-    if isinstance(x, tuple):
-        return x
-    elif isinstance(x, list) or isinstance(x, set):
-        return tuple(x)
-    elif x is None:
-        return None
-    else:
-        return tuple([x])
-
-
-class FeatureOperation:
-    """
-    This is a Class to store the operations executed on df.
-    """
-
-    def __init__(
-        self,
-        operation_type: OperationTypeEnum,
-        original_columns: Union[Tuple[str], str, None] = (),
-        derived_columns: Union[Tuple[str], str, None] = (),
-        encoded_values_map: Union[Dict[int, Any], None] = None,
-        encoder: sklearn.preprocessing._encoders._BaseEncoder = None,
-        details: Union[Dict, None] = None,
-    ):
-        """
-        Model to store the operations executed on a Dataset instance
-
-        Parameters
-        ----------
-        operation_type: OperationTypeEnum
-            OperationTypeEnum value that describes the type of the operation performed.
-        original_columns: Union[Tuple[str], str, None], optional
-            Tuple of the columns that were used as input for the operation performed.
-            Default set to ().
-        derived_columns: Union[Tuple[str], str, None], optional
-            Tuple of the columns that were generated as output after performing the
-            operation. If this tuple is equal to original_columns, it will be
-            reassigned to (). Default set to ().
-        encoded_values_map: Union[Dict[int, Any], None], optional
-            Map that connects the ``derived_columns`` values, generated by an
-            encoding operation, to the represented values (of the
-            ``original_columns``). Default set to None
-        encoder: sklearn.preprocessing._encoders._BaseEncoder, optional
-            Sklearn.preprocessing encoder instance that has been used for encoding.
-            This should be an instance of one the classes listed in
-            ``EncodingFunctions`` Enum. This may be useful to reverse the encoding.
-        details: Dict, optional
-            Dict containing details about the operation, like the map between encoded
-            value and original value. It may be set to None
-        """
-        # This is to avoid that a single column (i.e. a string) is interpreted as a
-        # tuple of single chars
-        original_columns = _to_tuple(original_columns)
-        derived_columns = _to_tuple(derived_columns)
-
-        self.original_columns = original_columns
-        self.operation_type = operation_type
-
-        # TODO: Maybe remove this (because the value in docstring is the same
-        #  as encoded_values_map
-        self.details = details
-
-        encoding_functions_values = [e.value for e in EncodingFunctions]
-        if encoder is None or type(encoder) in encoding_functions_values:
-            # TODO: Remove one of these following two
-            #  (or maybe just distinguish between the instance and the
-            #  class type...maybe not!):
-            self.encoder = encoder
-            self.encoding_function = encoder
-        else:
-            raise NotImplementedError(
-                f"The encoder {encoder} is not yet supported "
-                f"by the library. Possible values are: {encoding_functions_values}"
-            )
-
-        if encoded_values_map is None:
-            self.encoded_values_map = encoded_values_map
-        else:
-            self.encoded_values_map = {}
-
-        if derived_columns == original_columns and original_columns is not None:
-            self.derived_columns = ()
-        else:
-            self.derived_columns = derived_columns
-
-    @property
-    def encoded_string_values_map(self) -> Union[Dict[int, str], None]:
-        """
-        This is a modified version of the attribute 'self.encoded_values_map'
-        where we want string values instead of tuples
-        """
-        if self.encoded_values_map is None:
-            return None
-        else:
-            encoded_string_values_map = {}
-            for key, value in self.encoded_values_map.items():
-                if not isinstance(value, str):
-                    encoded_string_values_map[key] = "-".join(str(x) for x in value)
-                else:
-                    encoded_string_values_map[key] = value
-            return encoded_string_values_map
-
-    def __eq__(self, other) -> bool:
-        """
-        Compare two FeatureOperation instances to check if they are equal.
-
-        This is useful when we want to compare two FeatureOperation instances (used in
-        method 'find_operation_in_column').
-        This allows some attributes not to be explicit, if they are set to None.
-        In that case the attribute is considered "not specified" and it is not
-        considered in the equality check.
-        Therefore the conditions for the two instances to be considered equal are:
-        1. Same ``operation_type`` attribute
-        2. Each of the attributes ``original_columns``, ``derived_columns``
-        and ``encoder`` is compared between the two instances and it is considered
-        equal when:
-            a. In at least one of the two instance is None,
-            b. Or has the same value
-
-        Parameters
-        ----------
-        other: FeatureOperation
-            The instance that is compared with self
-
-        Returns
-        -------
-        bool
-            True if all the conditions above are fulfilled and False otherwise
-        """
-        if isinstance(other, self.__class__):
-            if (
-                self.operation_type == other.operation_type
-                and (
-                    self.original_columns is None
-                    or other.original_columns is None
-                    or set(self.original_columns) == set(other.original_columns)
-                )
-                and (
-                    self.derived_columns is None
-                    or other.derived_columns is None
-                    or set(self.derived_columns) == set(other.derived_columns)
-                )
-                and (
-                    self.encoder is None
-                    or other.encoder is None
-                    or isinstance(self.encoder, type(other.encoder))
-                )
-            ):
-                return True
-        return False
-
-    def __str__(self):
-        return (
-            f"Columns used to produce the result: {self.original_columns}"
-            + f"\nType of the operation that has been applied: {self.operation_type}"
-            + f"\nColumns created after the operation: {self.derived_columns}"
-            + f"\nMap between original values and encoded ones: \n{self.encoded_values_map}"
-            + (
-                f"\nEncoding function used: {self.encoding_function}"
-                if self.encoding_function
-                else ""
-            )
-        )
diff --git a/src/trousse/feature_operations.py b/src/trousse/feature_operations.py
index 4d77463..620ee69 100644
--- a/src/trousse/feature_operations.py
+++ b/src/trousse/feature_operations.py
@@ -3,10 +3,9 @@ try:
 except ImportError:
     from typing_extensions import Protocol, runtime_checkable
 
-import collections
-import copy
 from abc import abstractmethod
-from typing import Any, List, Union
+import copy
+from typing import Any, List
 
 from .dataset import Dataset
 from .util import is_sequence_and_not_str
@@ -24,137 +23,12 @@ class FeatureOperation(Protocol):
         raise NotImplementedError
 
     @abstractmethod
-    def is_similar(self, other: "FeatureOperation"):
+    def __eq__(self, other: Any) -> bool:
         raise NotImplementedError
 
-
-class _OperationsList:
-    def __init__(self):
-        self._operations_list = []
-        self._operations_by_column = collections.defaultdict(list)
-
-    def derived_columns_from_col(self, column: str) -> List[str]:
-        """Return name of the columns created by FeatureOperations applied on ``column``
-
-        Parameters
-        ----------
-        column : str
-            The column on which the FeatureOperation has been applied on.
-
-        Returns
-        -------
-        List[str]
-            Name of the columns created by FeatureOperations applied on ``column``.
-        """
-        derived_columns = []
-        operations = self[column]
-
-        for operation in operations:
-            if column in operation.columns and operation.derived_columns is not None:
-                derived_columns.extend(operation.derived_columns)
-
-        return derived_columns
-
-    def original_columns_from_derived_column(self, derived_column: str) -> List[str]:
-        """Return the name of the columns from which ``derived_column`` is generated from.
-
-        Parameters
-        ----------
-        derived_column : str
-            The column that has been generated
-
-        Returns
-        -------
-        List[str]
-            Name of the columns from which ``derived_column`` has been generated from.
-        """
-        operations = self._operations_from_derived_column(derived_column)
-
-        if len(operations) > 1:
-            raise RuntimeError(
-                "Multiple FeatureOperation found that generated column "
-                f"{derived_column}... the pipeline is compromised"
-            )
-        if len(operations) == 0:
-            raise RuntimeError(
-                "No FeatureOperation found that generated column "
-                f"{derived_column}... the pipeline is compromised"
-            )
-
-        return operations[0].columns
-
-    def _operations_from_derived_column(
-        self, derived_column: str
-    ) -> List[FeatureOperation]:
-        """Return the FeatureOperations that generated ``derived_column``
-
-        Parameters
-        ----------
-        derived_column : str
-            The column that has been generated
-
-        Returns
-        -------
-        List[FeatureOperation]
-            FeatureOperations that generated ``derived_column``
-        """
-        return list(
-            filter(
-                lambda op: op.derived_columns is not None
-                and derived_column in op.derived_columns,
-                self[derived_column],
-            )
-        )
-
-    def __iadd__(self, feat_op: FeatureOperation):
-        self._operations_list.append(feat_op)
-
-        derived_columns = (
-            feat_op.derived_columns if feat_op.derived_columns is not None else []
-        )
-
-        for column in feat_op.columns + derived_columns:
-            self._operations_by_column[column].append(feat_op)
-
-        return self
-
-    def __iter__(self):
-        for operation in self._operations_list:
-            yield operation
-
-    def __getitem__(
-        self, label: Union[int, str]
-    ) -> Union[FeatureOperation, List[FeatureOperation]]:
-        """Retrieve FeatureOperation element.
-
-        Parameters
-        ----------
-        label : Union[int, str]
-            Label used to retrieve the element. If int: get the label-th FeatureOperation.
-            If str: ``label`` will be treated as the name of a column and it will get all
-            the FeatureOperation associated with that column.
-
-        Returns
-        -------
-        Union[FeatureOperation, List[FeatureOperation]]
-            Requested FeatureOperation(s)
-
-        Raises
-        ------
-        TypeError
-            If ``label`` is not either an integer or a string.
-        """
-        if isinstance(label, int):
-            return self._operations_list[label]
-        elif isinstance(label, str):
-            return self._operations_by_column[label]
-        else:
-            raise TypeError(
-                f"Cannot get FeatureOperation with a label of type {type(label).__name__}"
-            )
-
-    def __len__(self):
-        return len(self._operations_list)
+    @abstractmethod
+    def is_similar(self, other: "FeatureOperation"):
+        raise NotImplementedError
 
 
 class FillNA(FeatureOperation):
@@ -227,5 +101,30 @@ class FillNA(FeatureOperation):
 
         return dataset
 
+    def __eq__(self, other: Any) -> bool:
+        """Return True if ``other`` is a FillNA instance and it has the same fields value.
+
+        Parameters
+        ----------
+        other : Any
+            The instance to compare
+
+        Returns
+        -------
+        bool
+            True if ``other`` is a FillNA instance and it has the same fields value,
+            False otherwise
+        """
+        if not isinstance(other, FillNA):
+            return False
+        if (
+            self.columns == other.columns
+            and self.derived_columns == other.derived_columns
+            and self.value == other.value
+        ):
+            return True
+
+        return False
+
     def is_similar(self, other: FeatureOperation):
         raise NotImplementedError
diff --git a/src/trousse/operations_list.py b/src/trousse/operations_list.py
new file mode 100644
index 0000000..1df19cb
--- /dev/null
+++ b/src/trousse/operations_list.py
@@ -0,0 +1,184 @@
+import collections
+import typing
+from typing import List, Union, Any
+
+if typing.TYPE_CHECKING:  # pragma: no cover
+    from .feature_operations import FeatureOperation
+
+
+class OperationsList:
+    def __init__(self):
+        self._operations_list = []
+        self._operations_by_column = collections.defaultdict(list)
+
+    def derived_columns_from_col(self, column: str) -> List[str]:
+        """Return name of the columns created by FeatureOperations applied on ``column``
+
+        Parameters
+        ----------
+        column : str
+            The column on which the FeatureOperation has been applied on.
+
+        Returns
+        -------
+        List[str]
+            Name of the columns created by FeatureOperations applied on ``column``.
+        """
+        derived_columns = []
+        operations = self.operations_from_original_column(column)
+
+        for operation in operations:
+            if operation.derived_columns is not None:
+                derived_columns.extend(operation.derived_columns)
+
+        return derived_columns
+
+    def operations_from_derived_column(
+        self, derived_column: str
+    ) -> List["FeatureOperation"]:
+        """Return the FeatureOperations that generated ``derived_column``
+
+        Parameters
+        ----------
+        derived_column : str
+            The column that has been generated
+
+        Returns
+        -------
+        List[FeatureOperation]
+            FeatureOperations that generated ``derived_column``
+        """
+        return list(
+            filter(
+                lambda op: op.derived_columns is not None
+                and derived_column in op.derived_columns,
+                self[derived_column],
+            )
+        )
+
+    def operations_from_original_column(
+        self, original_column: str
+    ) -> List["FeatureOperation"]:
+        """Return the FeatureOperations applied on ``original_column``
+
+        Parameters
+        ----------
+        original_column : str
+            The column on which the FeatureOperation has been applied on.
+
+        Returns
+        -------
+        List[FeatureOperation]
+            FeatureOperations applied on ``original_column``
+        """
+        return list(
+            filter(
+                lambda op: original_column in op.columns,
+                self[original_column],
+            )
+        )
+
+    def original_columns_from_derived_column(self, derived_column: str) -> List[str]:
+        """Return the name of the columns from which ``derived_column`` is generated from.
+
+        Parameters
+        ----------
+        derived_column : str
+            The column that has been generated
+
+        Returns
+        -------
+        List[str]
+            Name of the columns from which ``derived_column`` has been generated from.
+
+        Raises
+        ------
+        RuntimeError
+            If multiple FeatureOperation are found that generated ``derived_column``
+        RuntimeError
+            If no FeatureOperation are found that generated ``derived_column``
+        """
+        operations = self.operations_from_derived_column(derived_column)
+
+        if len(operations) > 1:
+            raise RuntimeError(
+                "Multiple FeatureOperation found that generated column "
+                f"{derived_column}... the pipeline is compromised"
+            )
+        if len(operations) == 0:
+            raise RuntimeError(
+                "No FeatureOperation found that generated column "
+                f"{derived_column}... the pipeline is compromised"
+            )
+
+        return operations[0].columns
+
+    def __eq__(self, other: Any) -> bool:
+        """Return True if ``other`` is a OperationsList and it contains the same operations.
+
+        Parameters
+        ----------
+        other : Any
+            The instance to compare
+
+        Returns
+        -------
+        bool
+            True if ``other`` is a OperationsList and it contains the same operations,
+            False otherwise.
+        """
+        if not isinstance(other, OperationsList):
+            return False
+        if not self._operations_list == other._operations_list:
+            return False
+        return True
+
+    def __getitem__(
+        self, label: Union[int, str]
+    ) -> Union["FeatureOperation", List["FeatureOperation"]]:
+        """Retrieve FeatureOperation element.
+
+        Parameters
+        ----------
+        label : Union[int, str]
+            Label used to retrieve the element. If int: get the label-th FeatureOperation.
+            If str: ``label`` will be treated as the name of a column and it will get all
+            the FeatureOperation associated with that column.
+
+        Returns
+        -------
+        Union[FeatureOperation, List[FeatureOperation]]
+            Requested FeatureOperation(s)
+
+        Raises
+        ------
+        TypeError
+            If ``label`` is not either an integer or a string.
+        """
+        if isinstance(label, int):
+            return self._operations_list[label]
+        elif isinstance(label, str):
+            return self._operations_by_column[label]
+        else:
+            raise TypeError(
+                f"Cannot get FeatureOperation with a label of type {type(label).__name__}"
+            )
+
+    def __iadd__(self, feat_op: "FeatureOperation"):
+        self._operations_list.append(feat_op)
+
+        derived_columns = (
+            feat_op.derived_columns if feat_op.derived_columns is not None else []
+        )
+
+        for column in feat_op.columns + derived_columns:
+            self._operations_by_column[column].append(feat_op)
+
+        return self
+
+    def __iter__(self):
+        for operation in self._operations_list:
+            yield operation
+
+    def __len__(self):
+        return len(self._operations_list)


Test patch:
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000..ef87103
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,75 @@
+### Integrated tools for synthetic data generation
+
+PyTrousse aids developers in automated testing by creating simulated raw data in a controlled way along with the data expected after appropriate data wrangling operations.
+Particularly, this tool can support the data transformation operator testing by inverting their behavior through error injection.
+
+Raw and expected data are wrapped in a `TestDataset` object that, following PyTrousse behavior, can be modified by applying the inverted data transformation operators.
+```python
+>>> import pandas as pd
+>>> from .datasim import from_pandas
+>>> from .datasim_util import (
+...         Compose,
+...         InsertOutOfScaleValues,
+...         ReplaceSubstringsByValue,
+...         SubstringReplaceMapByValue,
+... )
+
+>>> df = pd.DataFrame(
+...     {"float_column": list(range(10)), "int_column": [0.2 * i for i in range(10)]}
+... )
+>>> test_dataset = from_pandas(df)
+>>> insert_errors = Compose(
+...     [
+...         ReplaceSubstringsByValue(
+...             column_names=["float_column"],
+...             error_count=2,
+...             replacement_map_list=[
+...                 SubstringReplaceMapByValue(
+...                     substr_to_replace=".",
+...                     new_substring=",",
+...                     value_after_fix=".",
+...                     fix_with_substring=True,
+...                 )
+...             ],
+...         ),
+...         InsertOutOfScaleValues(
+...             column_names=["int_column"],
+...             error_count=2,
+...             upperbound_increase=0.02,
+...             lowerbound_decrease=0.02,
+...         ),
+...     ]
+... )
+>>> test_dataset = insert_errors(test_dataset)
+
+>>> test_dataset.dataframe_to_fix
+```
+```bash
+   float_column int_column
+0             0          0
+1             1        0.2
+2             2       >1.8
+3             3        0.6
+4             4        0.8
+5             5          1
+6             6        1.2
+7             7       <0.0
+8             8        1.6
+9             9        1.8
+```
+```python
+>>> test_dataset.dataframe_after_fix
+```
+```bash
+   float_column  int_column
+0             0       0.000
+1             1       0.200
+2             2       1.836
+3             3       0.600
+4             4       0.800
+5             5       1.000
+6             6       1.200
+7             7       0.000
+8             8       1.600
+9             9       1.800
+```
diff --git a/tests/conftest.py b/tests/conftest.py
index 94d86fa..5628250 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,7 +1,7 @@
 import os
 import shutil
 from pathlib import Path
-
+from trousse import feature_operations as fop
 import pytest
 
 
@@ -36,3 +36,23 @@ def temporary_data_dir(request) -> Path:
 
     request.addfinalizer(remove_temp_dir_created)
     return temp_data_dir
+
+
+@pytest.fixture
+def fillna_col0_col1(request):
+    return fop.FillNA(columns=["col0"], derived_columns=["col1"], value=0)
+
+
+@pytest.fixture
+def fillna_col1_col4(request):
+    return fop.FillNA(columns=["col1"], derived_columns=["col4"], value=0)
+
+
+@pytest.fixture
+def fillna_col4_none(request):
+    return fop.FillNA(columns=["col4"], derived_columns=None, value=0)
+
+
+@pytest.fixture
+def fillna_col1_col2(request):
+    return fop.FillNA(columns=["col1"], derived_columns=["col2"], value=0)
diff --git a/tests/integration/test_anonymize_database.py b/tests/integration/test_anonymize_database.py
index 7eb70ec..63f6943 100644
--- a/tests/integration/test_anonymize_database.py
+++ b/tests/integration/test_anonymize_database.py
@@ -84,3 +84,22 @@ def test_anonymize_data(
 
     pd.testing.assert_frame_equal(anonym_df, expected_anonym_df)
     pd.testing.assert_frame_equal(private_df, expected_private_df)
+
+
+def but_it_raises_filenotfounderror_with_wrong_dest_path():
+    original_df = DataFrameMock.df_with_private_info(private_cols=["private_col_a"])
+
+    with pytest.raises(FileNotFoundError) as err:
+        anonym_df, private_df = anonymize_data(
+            df=original_df,
+            file_name="test_original_db_anonymize",
+            private_cols_to_remove=["private_col_a"],
+            private_cols_to_map=["private_col_a"],
+            dest_path="path/fake",
+            random_seed=42,
+        )
+    assert isinstance(err.value, FileNotFoundError)
+    assert (
+        "[Errno 2] No such file or directory: "
+        "'path/fake/test_original_db_anonymize_private_info.csv'"
+    ) == str(err.value)
diff --git a/tests/integration/test_dataset.py b/tests/integration/test_dataset.py
index f6ec4d7..0539be9 100644
--- a/tests/integration/test_dataset.py
+++ b/tests/integration/test_dataset.py
@@ -6,20 +6,18 @@ from typing import Tuple
 import pandas as pd
 import pytest
 
+from trousse import feature_operations as fop
 from trousse.dataset import (
     Dataset,
-    FeatureOperation,
     _ColumnListByType,
     _find_single_column_type,
     copy_dataset_with_new_df,
     get_df_from_csv,
     read_file,
 )
-from trousse.exceptions import MultipleOperationsFoundError, NotShelveFileError
-from trousse.feature_enum import EncodingFunctions, OperationTypeEnum
+from trousse.exceptions import NotShelveFileError
 
 from ..dataset_util import DataFrameMock, SeriesMock
-from ..featureoperation_util import eq_featureoperation_combs
 from ..fixtures import CSV
 
 
@@ -393,385 +391,86 @@ class Describe_Dataset:
         pass
 
     @pytest.mark.parametrize(
-        "original_columns, derived_columns, operation_type, encoded_values_map, "
-        "encoder, details",
+        "metadata_columns, original_columns, derived_columns, expected_metadata_cols",
         [
-            (  # Case 1: Only metadata columns as original_columns
-                ["metadata_num_col", "metadata_str_col"],
-                ["exam_num_col_0", "exam_str_col_0"],
-                OperationTypeEnum.BIN_SPLITTING,
-                {0: "value_0", 1: "value_1"},
-                EncodingFunctions.ONEHOT.value(),
-                {"key_2": "value_2", "key_3": "value_3"},
-            ),
-            (  # Case 2: Only one metadata column as original_columns
-                ["metadata_num_col", "exam_num_col_0"],
-                ["exam_num_col_1", "exam_str_col_0"],
-                OperationTypeEnum.CATEGORICAL_ENCODING,
-                None,
-                None,
-                None,
-            ),
-            (  # Case 3: One of the derived columns is not present in df
-                ["metadata_num_col", "exam_num_col_0"],
-                ["exam_str_col_0", "exam_str_col_1"],
-                OperationTypeEnum.FEAT_COMBOS_ENCODING,
-                {0: "value_0", 1: "value_1"},
-                EncodingFunctions.ORDINAL.value(),
-                {"key_2": "value_2", "key_3": "value_3"},
-            ),
-            (  # Case 4: Only one derived column, no metadata columns involved
-                ["exam_num_col_0", "exam_num_col_1"],
-                ["exam_str_col_0"],
-                OperationTypeEnum.CATEGORICAL_ENCODING,
-                {0: "value_0", 1: "value_1"},
-                EncodingFunctions.ONEHOT.value(),
-                {"key_2": "value_2", "key_3": "value_3"},
+            (
+                tuple(["metadata_num_col"]),
+                ["metadata_num_col"],
+                ["derived_metadata_num_col"],
+                {"metadata_num_col", "derived_metadata_num_col"},
             ),
+            # TODO: add case with a FeatureOperation that can accept more than
+            # one derived_column
         ],
     )
-    def test_add_operation(
+    def test_add_operation_with_derived_columns(
         self,
         request,
+        metadata_columns,
         original_columns,
         derived_columns,
-        operation_type,
-        encoded_values_map,
-        encoder,
-        details,
+        expected_metadata_cols,
     ):
         df = DataFrameMock.df_generic(10)
         dataset = Dataset(
             df_object=df,
-            metadata_cols=("metadata_num_col", "metadata_str_col"),
+            metadata_cols=metadata_columns,
         )
-        feat_op = FeatureOperation(
-            original_columns=original_columns,
-            derived_columns=derived_columns,
-            operation_type=operation_type,
-            encoded_values_map=encoded_values_map,
-            encoder=encoder,
-            details=details,
+        feat_op = fop.FillNA(
+            columns=original_columns, derived_columns=derived_columns, value=0
         )
 
         dataset.add_operation(feat_op)
 
-        for orig_column in original_columns:
-            # Check if the operation is added to each column
-            assert feat_op in dataset.feature_elaborations[orig_column]
-        for deriv_column in derived_columns:
-            # Check that the derived_columns are inserted in the derived_columns
-            # attribute of Dataset instance
-            assert deriv_column in dataset.derived_columns
+        for column in original_columns + derived_columns:
             # Check if the operation is added to each column
-            assert feat_op in dataset.feature_elaborations[deriv_column]
-            # If original cols are all metadata_cols, check if they are
-            # added to metadata_cols
-            if original_columns == ["dataset", "metadata_str_col"]:
-                assert deriv_column in dataset.metadata_cols
-
-    def test_add_operation_on_previous_one(self, request, dataset_with_operations):
-        # Test `add_operation` method when some columns already have other previous
-        # FeatureOperation instances associated
-        origin_column = "fop_original_col_0"
-        deriv_column = "fop_derived_col_1"
-        feat_op = FeatureOperation(
-            original_columns=[origin_column],
-            derived_columns=[deriv_column],
-            operation_type=OperationTypeEnum.BIN_SPLITTING,
-            encoded_values_map={0: "value_0", 1: "value_1"},
-            encoder=EncodingFunctions.ONEHOT.value(),
-            details={"key_2": "value_2", "key_3": "value_3"},
-        )
-
-        dataset_with_operations.add_operation(feat_op)
-
-        # Check if the previous operations are still present
-        assert len(dataset_with_operations.feature_elaborations[origin_column]) == 6
-        assert (
-            FeatureOperation(
-                OperationTypeEnum.BIN_SPLITTING,
-                original_columns=["fop_original_col_0", "fop_original_col_1"],
-                derived_columns=["fop_derived_col_0", "fop_derived_col_1"],
-            )
-            in dataset_with_operations.feature_elaborations[origin_column]
-        )
-        # Check if the operation is added to each column
-        assert feat_op in dataset_with_operations.feature_elaborations[origin_column]
-        # Check that they are inserted in derived cols attribute
-        assert deriv_column in dataset_with_operations.derived_columns
-        # Check if the operation is added to each column
-        assert feat_op in dataset_with_operations.feature_elaborations[deriv_column]
+            assert feat_op in dataset.operations_history[column]
+        assert dataset.metadata_cols == expected_metadata_cols
 
     @pytest.mark.parametrize(
-        "searched_feat_op, expected_found_feat_op",
+        "metadata_columns, original_columns, expected_metadata_cols",
         [
             (
-                {  # Case 1: Encoder attribute not specified
-                    "original_columns": ("fop_original_col_0", "fop_original_col_1"),
-                    "derived_columns": ["fop_derived_col_0"],
-                    "operation_type": OperationTypeEnum.BIN_SPLITTING,
-                    "encoded_values_map": None,
-                    "encoder": None,
-                    "details": None,
-                },
-                {
-                    "original_columns": ("fop_original_col_0", "fop_original_col_1"),
-                    "derived_columns": ["fop_derived_col_0"],
-                    "operation_type": OperationTypeEnum.BIN_SPLITTING,
-                    "encoded_values_map": {0: "value_0", 1: "value_1"},
-                    "encoder": None,
-                    "details": None,
-                },
-            ),
-            (
-                {  # Case 2: Derived_columns not specified
-                    "original_columns": ("fop_original_col_0", "fop_original_col_1"),
-                    "derived_columns": None,
-                    "operation_type": OperationTypeEnum.CATEGORICAL_ENCODING,
-                    "encoded_values_map": None,
-                    "encoder": None,
-                    "details": None,
-                },
-                {
-                    "original_columns": ("fop_original_col_0", "fop_original_col_1"),
-                    "derived_columns": ("fop_derived_col_1",),
-                    "operation_type": OperationTypeEnum.CATEGORICAL_ENCODING,
-                    "encoded_values_map": {0: "value_3", 1: "value_4"},
-                    "encoder": EncodingFunctions.ORDINAL.value(),
-                    "details": {"key_A": "value_A", "key_B": "value_B"},
-                },
-            ),
-            (
-                {  # Case 3: Original columns not specified
-                    "original_columns": None,
-                    "derived_columns": ["fop_derived_col_1"],
-                    "operation_type": OperationTypeEnum.CATEGORICAL_ENCODING,
-                    "encoder": EncodingFunctions.ORDINAL.value(),
-                    "encoded_values_map": None,
-                    "details": None,
-                },
-                {
-                    "original_columns": ("fop_original_col_0", "fop_original_col_1"),
-                    "derived_columns": ("fop_derived_col_1",),
-                    "operation_type": OperationTypeEnum.CATEGORICAL_ENCODING,
-                    "encoded_values_map": {0: "value_3", 1: "value_4"},
-                    "encoder": EncodingFunctions.ORDINAL.value(),
-                    "details": {"key_A": "value_A", "key_B": "value_B"},
-                },
-            ),
-            (
-                {  # Case 4: Only derived_columns specified
-                    "operation_type": OperationTypeEnum.BIN_SPLITTING,
-                    "original_columns": None,
-                    "derived_columns": ["fop_derived_col_0"],
-                    "encoded_values_map": None,
-                    "encoder": None,
-                    "details": None,
-                },
-                {
-                    "operation_type": OperationTypeEnum.BIN_SPLITTING,
-                    "original_columns": ["fop_original_col_0", "fop_original_col_1"],
-                    "derived_columns": ["fop_derived_col_0"],
-                    "encoded_values_map": {0: "value_0", 1: "value_1"},
-                    "encoder": None,
-                    "details": None,
-                },
-            ),
-            (
-                {  # Case 5: Original, Derived_columns and encoder specified
-                    "operation_type": OperationTypeEnum.CATEGORICAL_ENCODING,
-                    "original_columns": ("fop_original_col_0",),
-                    "derived_columns": ("fop_derived_col_0",),
-                    "encoded_values_map": None,
-                    "encoder": EncodingFunctions.ONEHOT.value(),
-                    "details": None,
-                },
-                {
-                    "original_columns": ("fop_original_col_0",),
-                    "derived_columns": ("fop_derived_col_0",),
-                    "operation_type": OperationTypeEnum.CATEGORICAL_ENCODING,
-                    "encoded_values_map": {0: "value_0", 1: "value_1"},
-                    "encoder": EncodingFunctions.ONEHOT.value(),
-                    "details": {"key_2": "value_2", "key_3": "value_3"},
-                },
-            ),
-        ],
-    )
-    def test_find_operation_in_column(
-        self, request, searched_feat_op, expected_found_feat_op, dataset_with_operations
-    ):
-        feat_op = FeatureOperation(
-            original_columns=searched_feat_op["original_columns"],
-            derived_columns=searched_feat_op["derived_columns"],
-            operation_type=searched_feat_op["operation_type"],
-            encoded_values_map=searched_feat_op["encoded_values_map"],
-            encoder=searched_feat_op["encoder"],
-            details=searched_feat_op["details"],
-        )
-        expected_found_feat_op = FeatureOperation(
-            original_columns=expected_found_feat_op["original_columns"],
-            derived_columns=expected_found_feat_op["derived_columns"],
-            operation_type=expected_found_feat_op["operation_type"],
-            encoded_values_map=expected_found_feat_op["encoded_values_map"],
-            encoder=expected_found_feat_op["encoder"],
-            details=expected_found_feat_op["details"],
-        )
-
-        found_feat_operat = dataset_with_operations.find_operation_in_column(
-            feat_operation=feat_op
-        )
-
-        assert isinstance(found_feat_operat, FeatureOperation)
-        assert found_feat_operat == expected_found_feat_op
-
-    def test_find_operation_in_column_not_found(self, request, dataset_with_operations):
-        feat_op = FeatureOperation(
-            original_columns=("fop_original_col_0",),
-            derived_columns=("fop_derived_col_0",),
-            operation_type=OperationTypeEnum.BIN_SPLITTING,
-        )
-
-        found_feat_operat = dataset_with_operations.find_operation_in_column(
-            feat_operation=feat_op
-        )
-
-        assert found_feat_operat is None
-
-    def test_find_operation_in_column_raise_error(
-        self, request, dataset_with_operations
-    ):
-        feat_op = FeatureOperation(
-            OperationTypeEnum.BIN_SPLITTING,
-            original_columns=["fop_original_col_0", "fop_original_col_1"],
-            derived_columns=None,
-        )
-        with pytest.raises(MultipleOperationsFoundError) as err:
-            dataset_with_operations.find_operation_in_column(feat_operation=feat_op)
-
-        assert isinstance(err.value, MultipleOperationsFoundError)
-        assert (
-            "Multiple operations were found. Please provide additional information"
-            in str(err.value)
-        )
-
-    @pytest.mark.parametrize(
-        "original_column, encoder, expected_encoded_columns",
-        [
-            (  # Case 1: Everything specified and found
-                "fop_original_col_0",
-                EncodingFunctions.ONEHOT.value(),
-                ("fop_derived_col_0",),
+                ("metadata_num_col", "metadata_str_col"),
+                ["metadata_num_col"],
+                {"metadata_num_col", "metadata_str_col"},
             ),
-            # Case 2: No encoder specified
-            ("fop_original_col_2", None, ("fop_derived_col_2", "fop_derived_col_3")),
+            # TODO: add case with a FeatureOperation that can accept more than
+            # one derived_column
         ],
     )
-    def test_get_enc_column_from_original_one_col_found(
+    def test_add_operation_with_no_derived_columns(
         self,
-        dataset_with_operations,
-        original_column,
-        encoder,
-        expected_encoded_columns,
-    ):
-        encoded_columns = dataset_with_operations.get_enc_column_from_original(
-            column_name=original_column, encoder=encoder
-        )
-
-        assert isinstance(encoded_columns, tuple)
-        assert len(encoded_columns) == len(expected_encoded_columns)
-        assert set(encoded_columns) == set(expected_encoded_columns)
-
-    @pytest.mark.parametrize(
-        "original_column",
-        [
-            ("fop_derived_col_1"),  # Case 1: column_name in derived_columns
-            ("fop_original_col_10"),  # Case 2: No operation associated
-        ],
-    )
-    def test_get_enc_column_from_original_not_found(
-        self,
-        dataset_with_operations,
-        original_column,
-    ):
-        encoded_columns = dataset_with_operations.get_enc_column_from_original(
-            column_name=original_column, encoder=None
-        )
-
-        assert encoded_columns is None
-
-    def test_get_enc_column_from_original_raise_multicolfound_error(
-        self, dataset_with_operations
+        request,
+        metadata_columns,
+        original_columns,
+        expected_metadata_cols,
     ):
-        with pytest.raises(MultipleOperationsFoundError) as err:
-            dataset_with_operations.get_enc_column_from_original(
-                column_name="fop_original_col_0"
-            )
-
-        assert isinstance(err.value, MultipleOperationsFoundError)
-        assert (
-            "Multiple operations were found. Please provide additional information"
-            in str(err.value)
+        df = DataFrameMock.df_generic(10)
+        dataset = Dataset(
+            df_object=df,
+            metadata_cols=metadata_columns,
         )
+        feat_op = fop.FillNA(columns=original_columns, derived_columns=None, value=0)
 
-    @pytest.mark.parametrize(
-        "encoded_column, encoder, expected_original_columns",
-        [
-            (  # Case 1: Everything specified and found
-                "fop_derived_col_0",
-                EncodingFunctions.ONEHOT.value(),
-                ("fop_original_col_0",),
-            ),
-            # Case 2: No encoder specified
-            ("fop_derived_col_1", None, ("fop_original_col_0", "fop_original_col_1")),
-        ],
-    )
-    def test_get_original_from_enc_column_one_col_found(
-        self,
-        dataset_with_operations,
-        encoded_column,
-        encoder,
-        expected_original_columns,
-    ):
-        original_columns = dataset_with_operations.get_original_from_enc_column(
-            column_name=encoded_column, encoder=encoder
-        )
+        dataset.add_operation(feat_op)
 
-        assert isinstance(original_columns, tuple)
-        assert len(original_columns) == len(expected_original_columns)
-        assert set(original_columns) == set(expected_original_columns)
+        for column in original_columns:
+            # Check if the operation is added to each column
+            assert feat_op in dataset.operations_history[column]
+        assert dataset.metadata_cols == expected_metadata_cols
 
-    @pytest.mark.parametrize(
-        "encoded_column",
-        [
-            ("fop_derived_col_10"),  # Case 1: No operation associated
-            ("fop_original_col_2"),  # Case 2: Column_name in original_columns
-        ],
-    )
-    def test_get_original_from_enc_column_not_found(
-        self,
-        dataset_with_operations,
-        encoded_column,
-    ):
-        original_columns = dataset_with_operations.get_original_from_enc_column(
-            column_name=encoded_column, encoder=None
-        )
+    def test_add_operation_on_previous_one(self, request, dataset_with_operations):
+        feat_op = fop.FillNA(columns=["col1"], derived_columns=["col5"], value=0)
 
-        assert original_columns is None
+        dataset_with_operations.add_operation(feat_op)
 
-    def test_get_original_from_enc_column_raise_multicolfound_error(
-        self, dataset_with_operations
-    ):
-        with pytest.raises(MultipleOperationsFoundError) as err:
-            dataset_with_operations.get_original_from_enc_column(
-                column_name="fop_derived_col_0"
-            )
-
-        assert isinstance(err.value, MultipleOperationsFoundError)
-        assert (
-            "Multiple operations were found. Please provide additional information"
-            in str(err.value)
-        )
+        added_op = dataset_with_operations.operations_history[2]
+        # Check if the previous operations are still present
+        assert len(dataset_with_operations.operations_history) == 3
+        assert isinstance(added_op, fop.FillNA)
+        assert added_op.columns == ["col1"]
+        assert added_op.derived_columns == ["col5"]
 
     def test_to_be_fixed_cols(self):
         df = DataFrameMock.df_multi_type(10)
@@ -818,49 +517,6 @@ class Describe_Dataset:
         assert expected_str == str_
 
 
-class Describe_FeatureOperation:
-    @pytest.mark.parametrize(
-        "feat_op_1_dict, feat_op_2_dict, is_equal_label", eq_featureoperation_combs()
-    )
-    def test_featureoperation_equals(
-        self, feat_op_1_dict, feat_op_2_dict, is_equal_label
-    ):
-        feat_op_1 = FeatureOperation(
-            operation_type=feat_op_1_dict["operation_type"],
-            original_columns=feat_op_1_dict["original_columns"],
-            derived_columns=feat_op_1_dict["derived_columns"],
-            encoder=feat_op_1_dict["encoder"],
-        )
-        feat_op_2 = FeatureOperation(
-            operation_type=feat_op_2_dict["operation_type"],
-            original_columns=feat_op_2_dict["original_columns"],
-            derived_columns=feat_op_2_dict["derived_columns"],
-            encoder=feat_op_2_dict["encoder"],
-        )
-
-        are_feat_ops_equal = feat_op_1 == feat_op_2
-
-        assert are_feat_ops_equal == is_equal_label
-
-    def test_featureoperation_equals_with_different_instance_types(self, request):
-        feat_op_1 = FeatureOperation(
-            operation_type=OperationTypeEnum.BIN_SPLITTING,
-            original_columns=("original_column_2",),
-            derived_columns=("derived_column_1", "derived_column_2"),
-            encoder=EncodingFunctions.ONEHOT.value(),
-        )
-        feat_op_2 = dict(
-            operation_type=OperationTypeEnum.BIN_SPLITTING,
-            original_columns=("original_column_2",),
-            derived_columns=("derived_column_1", "derived_column_2"),
-            encoder=EncodingFunctions.ONEHOT.value(),
-        )
-
-        are_feat_ops_equal = feat_op_1 == feat_op_2
-
-        assert are_feat_ops_equal is False
-
-
 @pytest.mark.parametrize(
     "series_type, expected_col_type_dict",
     [
@@ -962,7 +618,7 @@ def test_read_file(export_dataset_with_operations_to_file_fixture):
     conserved_attributes = imported_dataset.__dict__.keys() - {"_data"}
     for k in conserved_attributes:
         assert imported_dataset.__dict__[k] == expected_imported_dataset.__dict__[k]
-    assert imported_dataset.data.equals(expected_imported_dataset.data)
+    pd.testing.assert_frame_equal(imported_dataset.data, expected_imported_dataset.data)
 
 
 def test_read_file_raise_notshelvefileerror(create_generic_file):
@@ -1050,72 +706,20 @@ def create_generic_shelve_file(tmpdir) -> Path:
 
 
 @pytest.fixture(scope="function")
-def dataset_with_operations() -> Dataset:
+def dataset_with_operations(fillna_col0_col1, fillna_col1_col4) -> Dataset:
     """
-    Create Dataset instance with not empty ``feature_elaborations`` attribute.
-
-    The returned Dataset will have ``feature_elaborations`` attribute
-    initialized with ``feature_elaborations`` argument.
+    Create Dataset instance with not empty ``operations_history`` attribute.
 
     Returns
     -------
     Dataset
         Dataset instance containing FeatureOperation instances
-        in the `feature_elaborations` attribute
+        in the `operations_history` attribute
     """
     dataset = Dataset(df_object=DataFrameMock.df_generic(10))
 
-    feat_operat_list = [
-        FeatureOperation(
-            OperationTypeEnum.BIN_SPLITTING,
-            original_columns=["fop_original_col_0", "fop_original_col_1"],
-            derived_columns=["fop_derived_col_0", "fop_derived_col_1"],
-        ),
-        FeatureOperation(
-            OperationTypeEnum.BIN_SPLITTING,
-            original_columns=["fop_original_col_0", "fop_original_col_1"],
-            derived_columns=["fop_derived_col_0"],
-            encoded_values_map={0: "value_0", 1: "value_1"},
-        ),
-        FeatureOperation(
-            original_columns=("fop_original_col_0", "fop_original_col_1"),
-            derived_columns=("fop_derived_col_1",),
-            operation_type=OperationTypeEnum.CATEGORICAL_ENCODING,
-            encoded_values_map={0: "value_3", 1: "value_4"},
-            encoder=EncodingFunctions.ORDINAL.value(),
-            details={"key_A": "value_A", "key_B": "value_B"},
-        ),
-        FeatureOperation(
-            original_columns=("fop_original_col_0",),
-            derived_columns=("fop_derived_col_0",),
-            operation_type=OperationTypeEnum.CATEGORICAL_ENCODING,
-            encoded_values_map={0: "value_0", 1: "value_1"},
-            encoder=EncodingFunctions.ONEHOT.value(),
-            details={"key_2": "value_2", "key_3": "value_3"},
-        ),
-        FeatureOperation(
-            original_columns=("fop_original_col_0",),
-            derived_columns=("fop_derived_col_0",),
-            operation_type=OperationTypeEnum.CATEGORICAL_ENCODING,
-            encoded_values_map={0: "value_0", 1: "value_1"},
-            encoder=EncodingFunctions.ORDINAL.value(),
-            details={"key_2": "value_2", "key_3": "value_3"},
-        ),
-        FeatureOperation(
-            original_columns=("fop_original_col_2",),
-            derived_columns=("fop_derived_col_2", "fop_derived_col_3"),
-            operation_type=OperationTypeEnum.CATEGORICAL_ENCODING,
-            encoded_values_map={0: "value_3", 1: "value_4"},
-            encoder=EncodingFunctions.ONEHOT.value(),
-            details={"key_A": "value_A", "key_B": "value_B"},
-        ),
-    ]
-    for feat_op in feat_operat_list:
-        for orig_col in feat_op.original_columns:
-            dataset.feature_elaborations[orig_col].append(feat_op)
-
-        for der_col in feat_op.derived_columns:
-            dataset.feature_elaborations[der_col].append(feat_op)
+    dataset.add_operation(fillna_col0_col1)
+    dataset.add_operation(fillna_col1_col4)
 
     return dataset
 
diff --git a/tests/unit/test_dataset.py b/tests/unit/test_dataset.py
index ad421ff..6d10620 100644
--- a/tests/unit/test_dataset.py
+++ b/tests/unit/test_dataset.py
@@ -1,10 +1,12 @@
 import pandas as pd
 import pytest
 
+from trousse import feature_operations as fop
 from trousse.dataset import Dataset, _ColumnListByType
+from trousse.operations_list import OperationsList
 
 from ..dataset_util import DataFrameMock
-from ..unitutil import function_mock, initializer_mock, method_mock, property_mock
+from ..unitutil import function_mock, initializer_mock, method_mock, property_mock, ANY
 
 
 class DescribeDataset:
@@ -214,6 +216,46 @@ class DescribeDataset:
         assert isinstance(data, pd.DataFrame)
         pd.testing.assert_frame_equal(data, expected_df)
 
+    def it_knows_its_operations_history(self, request):
+        function_mock(request, "trousse.dataset.get_df_from_csv")
+        dataset = Dataset(data_file="fake/path")
+
+        history = dataset.operations_history
+
+        assert isinstance(history, OperationsList)
+        assert len(history) == 0
+
+    @pytest.mark.parametrize(
+        "metadata_cols, derived_columns, expected_metadata_cols",
+        [
+            ((), None, set()),
+            (
+                tuple(["metadata_num_col"]),
+                ["derived_metadata_num_col"],
+                {"metadata_num_col", "derived_metadata_num_col"},
+            ),
+            # TODO: add case with a FeatureOperation that can accept more than
+            # one derived_column
+        ],
+    )
+    def it_knows_how_to_add_operation(
+        self, request, metadata_cols, derived_columns, expected_metadata_cols
+    ):
+        operations_list_iadd_ = method_mock(request, OperationsList, "__iadd__")
+
+        expected_df = DataFrameMock.df_generic(10)
+        get_df_from_csv_ = function_mock(request, "trousse.dataset.get_df_from_csv")
+        get_df_from_csv_.return_value = expected_df
+        dataset = Dataset(data_file="fake/path", metadata_cols=metadata_cols)
+        feat_op = fop.FillNA(
+            columns=["metadata_num_col"], derived_columns=derived_columns, value=0
+        )
+
+        dataset.add_operation(feat_op)
+
+        assert dataset.metadata_cols == expected_metadata_cols
+        operations_list_iadd_.assert_called_once_with(ANY, feat_op)
+
 
 class DescribeColumnListByType:
     def it_knows_its_str(self, request):
diff --git a/tests/unit/test_feature_operations.py b/tests/unit/test_feature_operations.py
index 3abca02..2dae208 100644
--- a/tests/unit/test_feature_operations.py
+++ b/tests/unit/test_feature_operations.py
@@ -1,10 +1,8 @@
-from collections.abc import Iterable
-
-import pandas as pd
 import pytest
 
 from trousse import feature_operations as fop
 from trousse.dataset import Dataset
+import pandas as pd
 
 from ..dataset_util import DataFrameMock
 from ..unitutil import ANY, function_mock, initializer_mock, method_mock
@@ -126,235 +124,29 @@ class DescribeFillNa:
         )
         assert pd_fillna_.call_args_list[0][1] == {"inplace": expected_inplace}
 
-    # ====================
-    #      FIXTURES
-    # ====================
-
-    @pytest.fixture
-    def is_sequence_and_not_str_(self, request):
-        return function_mock(
-            request, "trousse.feature_operations.is_sequence_and_not_str"
-        )
-
-
-class DescribeOperationsList:
-    def it_can_construct_itself(self, request):
-        _init_ = initializer_mock(request, fop._OperationsList)
-
-        operations_list = fop._OperationsList()
-
-        _init_.assert_called_once_with(ANY)
-        assert isinstance(operations_list, fop._OperationsList)
-
-    def it_can_iadd_first_featop(self, request, fillna_col0_col1):
-        op_list = fop._OperationsList()
-
-        op_list += fillna_col0_col1
-
-        assert op_list._operations_list == [fillna_col0_col1]
-        for column in ["col0", "col1"]:
-            assert op_list._operations_by_column[column] == [fillna_col0_col1]
-
-    def it_can_iadd_next_featop(self, request, fillna_col0_col1, fillna_col4_none):
-        op_list = fop._OperationsList()
-        op_list._operations_list = [fillna_col0_col1]
-        for column in ["col0", "col1"]:
-            op_list._operations_by_column[column] = [fillna_col0_col1]
-
-        op_list += fillna_col4_none
-
-        assert op_list._operations_list == [fillna_col0_col1, fillna_col4_none]
-        assert op_list._operations_by_column["col0"] == [fillna_col0_col1]
-        assert op_list._operations_by_column["col1"] == [
-            fillna_col0_col1,
-        ]
-        assert op_list._operations_by_column["col4"] == [fillna_col4_none]
-
-    def it_can_getitem_from_int(self, fillna_col0_col1, fillna_col1_col4):
-        op_list = fop._OperationsList()
-        op_list._operations_list = [fillna_col0_col1, fillna_col1_col4]
-        op_list._operations_by_column["col0"] = [fillna_col0_col1]
-        op_list._operations_by_column["col1"] = [fillna_col0_col1, fillna_col1_col4]
-        op_list._operations_by_column["col4"] = [fillna_col1_col4]
-
-        feat_op0_ = op_list[0]
-        feat_op1_ = op_list[1]
-
-        assert isinstance(feat_op0_, fop.FillNA)
-        assert isinstance(feat_op1_, fop.FillNA)
-        assert feat_op0_ == fillna_col0_col1
-        assert feat_op1_ == fillna_col1_col4
-
-    def it_can_getitem_from_str(self, fillna_col0_col1, fillna_col1_col4):
-        op_list = fop._OperationsList()
-        op_list._operations_list = [fillna_col0_col1, fillna_col1_col4]
-        op_list._operations_by_column["col0"] = [fillna_col0_col1]
-        op_list._operations_by_column["col1"] = [fillna_col0_col1, fillna_col1_col4]
-        op_list._operations_by_column["col4"] = [fillna_col1_col4]
-
-        feat_op_col0 = op_list["col0"]
-        feat_op_col1 = op_list["col1"]
-
-        assert isinstance(feat_op_col0, list)
-        assert isinstance(feat_op_col1, list)
-        assert feat_op_col0 == [fillna_col0_col1]
-        assert feat_op_col1 == [fillna_col0_col1, fillna_col1_col4]
-
-    def but_it_raisestypeerror_with_wrong_type(self):
-        op_list = fop._OperationsList()
-
-        with pytest.raises(TypeError) as err:
-            op_list[{"wrong"}]
-
-        assert isinstance(err.value, TypeError)
-        assert "Cannot get FeatureOperation with a label of type set" == str(err.value)
-
     @pytest.mark.parametrize(
-        "column, getitem_return_value, expected_derived_columns",
+        "other, expected_equal",
         [
-            (
-                "col0",
-                [fop.FillNA(columns=["col0"], derived_columns=["col1"], value=0)],
-                ["col1"],
-            ),
-            (
-                "col1",
-                [
-                    fop.FillNA(columns=["col0"], derived_columns=["col1"], value=0),
-                    fop.FillNA(columns=["col1"], derived_columns=["col4"], value=0),
-                    fop.FillNA(columns=["col1"], derived_columns=["col2"], value=0),
-                ],
-                ["col4", "col2"],
-            ),
-            (
-                "col4",
-                [
-                    fop.FillNA(columns=["col1"], derived_columns=["col1"], value=0),
-                    fop.FillNA(columns=["col4"], derived_columns=None, value=0),
-                ],
-                [],
-            ),
+            (fop.FillNA(columns=["col0"], derived_columns=["col1"], value=0), True),
+            (fop.FillNA(columns=["col9"], derived_columns=["col1"], value=0), False),
+            (fop.FillNA(columns=["col0"], derived_columns=["col2"], value=1), False),
+            (dict(), False),
         ],
     )
-    def it_can_get_derived_columns_from_col(
-        self, request, column, getitem_return_value, expected_derived_columns
-    ):
-        op_list = fop._OperationsList()
-        getitem_ = method_mock(request, fop._OperationsList, "__getitem__")
-        getitem_.return_value = getitem_return_value
-
-        derived_columns = op_list.derived_columns_from_col(column)
-
-        assert type(derived_columns) == list
-        assert derived_columns == expected_derived_columns
-
-    def it_can_get_operations_from_derived_column(self, request):
-        op_list = fop._OperationsList()
-        getitem_ = method_mock(request, fop._OperationsList, "__getitem__")
-        fop0 = fop.FillNA(columns=["col4"], derived_columns=["col1"], value=0)
-        fop1 = fop.FillNA(columns=["col1"], derived_columns=["col4"], value=0)
-        fop2 = fop.FillNA(columns=["col4"], derived_columns=None, value=0)
-        getitem_.return_value = [fop0, fop1, fop2]
-
-        operations = op_list._operations_from_derived_column("col4")
-
-        assert type(operations) == list
-        assert operations == [fop1]
-
-    def it_can_get_original_columns_from_derived_column(self, request):
-        op_list = fop._OperationsList()
-        _operations_from_derived_column_ = method_mock(
-            request, fop._OperationsList, "_operations_from_derived_column"
-        )
-        _operations_from_derived_column_.return_value = [
-            fop.FillNA(columns=["col0"], derived_columns=["col1"], value=0)
-        ]
-
-        original_columns = op_list.original_columns_from_derived_column("col1")
-
-        assert type(original_columns) == list
-        assert original_columns == ["col0"]
-
-    def but_it_raises_runtimeerror_with_multiple_operations_found(self, request):
-        op_list = fop._OperationsList()
-        _operations_from_derived_column_ = method_mock(
-            request, fop._OperationsList, "_operations_from_derived_column"
-        )
-        _operations_from_derived_column_.return_value = [
-            fop.FillNA(columns=["col0"], derived_columns=["col1"], value=0),
-            fop.FillNA(columns=["col2"], derived_columns=["col1"], value=0),
-        ]
-
-        with pytest.raises(RuntimeError) as err:
-            op_list.original_columns_from_derived_column("col1")
-
-        assert isinstance(err.value, RuntimeError)
-        assert (
-            "Multiple FeatureOperation found that generated column "
-            "col1... the pipeline is compromised"
-        ) == str(err.value)
-
-    def but_it_raises_runtimeerror_with_zero_operations_found(self, request):
-        op_list = fop._OperationsList()
-        _operations_from_derived_column_ = method_mock(
-            request, fop._OperationsList, "_operations_from_derived_column"
-        )
-        _operations_from_derived_column_.return_value = []
+    def it_knows_if_equal(self, other, expected_equal):
+        feat_op = fop.FillNA(columns=["col0"], derived_columns=["col1"], value=0)
 
-        with pytest.raises(RuntimeError) as err:
-            op_list.original_columns_from_derived_column("col1")
+        equal = feat_op == other
 
-        assert isinstance(err.value, RuntimeError)
-        assert (
-            "No FeatureOperation found that generated column "
-            "col1... the pipeline is compromised"
-        ) == str(err.value)
-
-    def it_knows_its_len(self, fillna_col0_col1, fillna_col1_col4):
-        op_list = fop._OperationsList()
-        op_list._operations_list = [fillna_col0_col1, fillna_col1_col4]
-
-        len_ = len(op_list)
-
-        assert type(len_) == int
-        assert len_ == 2
-
-    def it_can_be_iterated_over(
-        self, fillna_col0_col1, fillna_col1_col4, fillna_col1_col2
-    ):
-        op_list = fop._OperationsList()
-        op_list._operations_list = [
-            fillna_col0_col1,
-            fillna_col1_col4,
-            fillna_col1_col2,
-        ]
-        operations = []
-        for operation in op_list:
-            operations.append(operation)
-
-        assert isinstance(op_list, Iterable)
-        assert operations == [
-            fillna_col0_col1,
-            fillna_col1_col4,
-            fillna_col1_col2,
-        ]
+        assert type(equal) == bool
+        assert equal == expected_equal
 
     # ====================
     #      FIXTURES
     # ====================
 
     @pytest.fixture
-    def fillna_col0_col1(self, request):
-        return fop.FillNA(columns=["col0"], derived_columns=["col1"], value=0)
-
-    @pytest.fixture
-    def fillna_col1_col4(self, request):
-        return fop.FillNA(columns=["col1"], derived_columns=["col4"], value=0)
-
-    @pytest.fixture
-    def fillna_col4_none(self, request):
-        return fop.FillNA(columns=["col4"], derived_columns=None, value=0)
-
-    @pytest.fixture
-    def fillna_col1_col2(self, request):
-        return fop.FillNA(columns=["col1"], derived_columns=["col2"], value=0)
+    def is_sequence_and_not_str_(self, request):
+        return function_mock(
+            request, "trousse.feature_operations.is_sequence_and_not_str"
+        )
diff --git a/tests/unit/test_operations_list.py b/tests/unit/test_operations_list.py
new file mode 100644
index 0000000..23eac9d
--- /dev/null
+++ b/tests/unit/test_operations_list.py
@@ -0,0 +1,262 @@
+from typing import Iterable
+
+import pytest
+
+import trousse.feature_operations as fop
+from trousse.operations_list import OperationsList
+
+from ..unitutil import ANY, initializer_mock, method_mock
+
+
+class DescribeOperationsList:
+    def it_can_construct_itself(self, request):
+        _init_ = initializer_mock(request, OperationsList)
+
+        operations_list = OperationsList()
+
+        _init_.assert_called_once_with(ANY)
+        assert isinstance(operations_list, OperationsList)
+
+    def it_can_iadd_first_featop(self, request, fillna_col0_col1):
+        op_list = OperationsList()
+
+        op_list += fillna_col0_col1
+
+        assert op_list._operations_list == [fillna_col0_col1]
+        for column in ["col0", "col1"]:
+            assert op_list._operations_by_column[column] == [fillna_col0_col1]
+
+    def it_can_iadd_next_featop(self, request, fillna_col0_col1, fillna_col4_none):
+        op_list = OperationsList()
+        op_list._operations_list = [fillna_col0_col1]
+        for column in ["col0", "col1"]:
+            op_list._operations_by_column[column] = [fillna_col0_col1]
+
+        op_list += fillna_col4_none
+
+        assert op_list._operations_list == [fillna_col0_col1, fillna_col4_none]
+        assert op_list._operations_by_column["col0"] == [fillna_col0_col1]
+        assert op_list._operations_by_column["col1"] == [
+            fillna_col0_col1,
+        ]
+        assert op_list._operations_by_column["col4"] == [fillna_col4_none]
+
+    def it_can_getitem_from_int(self, fillna_col0_col1, fillna_col1_col4):
+        op_list = OperationsList()
+        op_list._operations_list = [fillna_col0_col1, fillna_col1_col4]
+        op_list._operations_by_column["col0"] = [fillna_col0_col1]
+        op_list._operations_by_column["col1"] = [fillna_col0_col1, fillna_col1_col4]
+        op_list._operations_by_column["col4"] = [fillna_col1_col4]
+
+        feat_op0_ = op_list[0]
+        feat_op1_ = op_list[1]
+
+        assert isinstance(feat_op0_, fop.FillNA)
+        assert isinstance(feat_op1_, fop.FillNA)
+        assert feat_op0_ == fillna_col0_col1
+        assert feat_op1_ == fillna_col1_col4
+
+    def it_can_getitem_from_str(self, fillna_col0_col1, fillna_col1_col4):
+        op_list = OperationsList()
+        op_list._operations_list = [fillna_col0_col1, fillna_col1_col4]
+        op_list._operations_by_column["col0"] = [fillna_col0_col1]
+        op_list._operations_by_column["col1"] = [fillna_col0_col1, fillna_col1_col4]
+        op_list._operations_by_column["col4"] = [fillna_col1_col4]
+
+        feat_op_col0 = op_list["col0"]
+        feat_op_col1 = op_list["col1"]
+
+        assert isinstance(feat_op_col0, list)
+        assert isinstance(feat_op_col1, list)
+        assert feat_op_col0 == [fillna_col0_col1]
+        assert feat_op_col1 == [fillna_col0_col1, fillna_col1_col4]
+
+    def but_it_raisestypeerror_with_wrong_type(self):
+        op_list = OperationsList()
+
+        with pytest.raises(TypeError) as err:
+            op_list[{"wrong"}]
+
+        assert isinstance(err.value, TypeError)
+        assert "Cannot get FeatureOperation with a label of type set" == str(err.value)
+
+    @pytest.mark.parametrize(
+        "column, operations_from_original_column_return_value, expected_derived_columns",
+        [
+            (
+                "col0",
+                [fop.FillNA(columns=["col0"], derived_columns=["col1"], value=0)],
+                ["col1"],
+            ),
+            (
+                "col1",
+                [
+                    fop.FillNA(columns=["col1"], derived_columns=["col4"], value=0),
+                    fop.FillNA(columns=["col1"], derived_columns=["col2"], value=0),
+                ],
+                ["col4", "col2"],
+            ),
+            (
+                "col4",
+                [
+                    fop.FillNA(columns=["col4"], derived_columns=None, value=0),
+                ],
+                [],
+            ),
+        ],
+    )
+    def it_can_get_derived_columns_from_col(
+        self,
+        request,
+        column,
+        operations_from_original_column_return_value,
+        expected_derived_columns,
+    ):
+        op_list = OperationsList()
+        operations_from_original_column_ = method_mock(
+            request, OperationsList, "operations_from_original_column"
+        )
+        operations_from_original_column_.return_value = (
+            operations_from_original_column_return_value
+        )
+
+        derived_columns = op_list.derived_columns_from_col(column)
+
+        assert type(derived_columns) == list
+        assert derived_columns == expected_derived_columns
+
+    def it_can_get_operations_from_derived_column(self, request):
+        op_list = OperationsList()
+        getitem_ = method_mock(request, OperationsList, "__getitem__")
+        fop0 = fop.FillNA(columns=["col4"], derived_columns=["col1"], value=0)
+        fop1 = fop.FillNA(columns=["col1"], derived_columns=["col4"], value=0)
+        fop2 = fop.FillNA(columns=["col4"], derived_columns=None, value=0)
+        getitem_.return_value = [fop0, fop1, fop2]
+
+        operations = op_list.operations_from_derived_column("col4")
+
+        assert type(operations) == list
+        assert operations == [fop1]
+
+    def it_can_get_operations_from_original_column(self, request):
+        op_list = OperationsList()
+        getitem_ = method_mock(request, OperationsList, "__getitem__")
+        fop0 = fop.FillNA(columns=["col4"], derived_columns=["col1"], value=0)
+        fop1 = fop.FillNA(columns=["col1"], derived_columns=["col4"], value=0)
+        fop2 = fop.FillNA(columns=["col4"], derived_columns=None, value=0)
+        getitem_.return_value = [fop0, fop1, fop2]
+
+        operations = op_list.operations_from_original_column("col4")
+
+        assert type(operations) == list
+        assert operations == [fop0, fop2]
+
+    def it_can_get_original_columns_from_derived_column(self, request):
+        op_list = OperationsList()
+        operations_from_derived_column_ = method_mock(
+            request, OperationsList, "operations_from_derived_column"
+        )
+        operations_from_derived_column_.return_value = [
+            fop.FillNA(columns=["col0"], derived_columns=["col1"], value=0)
+        ]
+
+        original_columns = op_list.original_columns_from_derived_column("col1")
+
+        assert type(original_columns) == list
+        assert original_columns == ["col0"]
+
+    def but_it_raises_runtimeerror_with_multiple_operations_found(self, request):
+        op_list = OperationsList()
+        _operations_from_derived_column_ = method_mock(
+            request, OperationsList, "operations_from_derived_column"
+        )
+        _operations_from_derived_column_.return_value = [
+            fop.FillNA(columns=["col0"], derived_columns=["col1"], value=0),
+            fop.FillNA(columns=["col2"], derived_columns=["col1"], value=0),
+        ]
+
+        with pytest.raises(RuntimeError) as err:
+            op_list.original_columns_from_derived_column("col1")
+
+        assert isinstance(err.value, RuntimeError)
+        assert (
+            "Multiple FeatureOperation found that generated column "
+            "col1... the pipeline is compromised"
+        ) == str(err.value)
+
+    def but_it_raises_runtimeerror_with_zero_operations_found(self, request):
+        op_list = OperationsList()
+        _operations_from_derived_column_ = method_mock(
+            request, OperationsList, "operations_from_derived_column"
+        )
+        _operations_from_derived_column_.return_value = []
+
+        with pytest.raises(RuntimeError) as err:
+            op_list.original_columns_from_derived_column("col1")
+
+        assert isinstance(err.value, RuntimeError)
+        assert (
+            "No FeatureOperation found that generated column "
+            "col1... the pipeline is compromised"
+        ) == str(err.value)
+
+    def it_knows_its_len(self, fillna_col0_col1, fillna_col1_col4):
+        op_list = OperationsList()
+        op_list._operations_list = [fillna_col0_col1, fillna_col1_col4]
+
+        len_ = len(op_list)
+
+        assert type(len_) == int
+        assert len_ == 2
+
+    def it_can_be_iterated_over(
+        self, fillna_col0_col1, fillna_col1_col4, fillna_col1_col2
+    ):
+        op_list = OperationsList()
+        op_list._operations_list = [
+            fillna_col0_col1,
+            fillna_col1_col4,
+            fillna_col1_col2,
+        ]
+        operations = []
+        for operation in op_list:
+            operations.append(operation)
+
+        assert isinstance(op_list, Iterable)
+        assert operations == [
+            fillna_col0_col1,
+            fillna_col1_col4,
+            fillna_col1_col2,
+        ]
+
+    @pytest.mark.parametrize(
+        "other_operation_list, expected_equal",
+        [
+            (
+                [
+                    fop.FillNA(columns=["col0"], derived_columns=["col1"], value=0),
+                    fop.FillNA(columns=["col1"], derived_columns=["col4"], value=0),
+                ],
+                True,
+            ),
+            ([fop.FillNA(columns=["col0"], derived_columns=["col1"], value=0)], False),
+            ([], False),
+        ],
+    )
+    def it_knows_if_equal(
+        self, other_operation_list, expected_equal, fillna_col0_col1, fillna_col1_col4
+    ):
+        op_list = OperationsList()
+        op_list._operations_list = [fillna_col0_col1, fillna_col1_col4]
+        other = OperationsList()
+        other._operations_list = other_operation_list
+        equal = op_list == other
+
+        assert type(equal) == bool
+        assert equal == expected_equal
+
+    def it_knows_not_equal_not_operations_list(self):
+        equal = dict() == OperationsList()
+
+        assert type(equal) == bool
+        assert equal is False