https://gitlab.com/tezos/tezos
Raw File
Tip revision: 5db19f0d73819977a7663bcd5674a75b8594b0aa authored by Fedor Sheremetyev on 21 April 2022, 19:23:08 UTC
Jakarta2: vanity nonce
Tip revision: 5db19f0
unit_testing_legacy_code.rst
================================
Making legacy code unit-testable
================================

This guide describes how to make legacy code testable and how to test it.
The following techniques do not require any API change: they concern testing each single module as a **unit** without touching modules depending on it.
These techniques are applicable to **legacy** code: you do not need to have an exact knowledge of every implementation detail of a module to test it.

The goals are:

- to gain confidence in a module as a developer (or even part of test-driven development)
- to document it: tests are examples
- to have a safety net as a maintainer (non-regression testing), e.g., when:
  - adding/changing features
  - performing various forms of refactoring (changing the API or not)

In the rest of this page, "testable" means "unit-testable", for conciseness.

Here "legacy" is a loose term, encompassing:

- existing code written by other developers, not necessarily containing a right amount of built-in unit tests
- code that is only tested by heavy/slow/high level tests, like :doc:`Tezt <tezt>` or :doc:`Python tests <python_testing_framework>` - this is often a symptom of code that is hard to unit-test
- tightly-coupled code (e.g. module ``A`` directly depends on functions of modules ``B``, ``C`` and ``D``, thus unit-testing ``A`` requires setting everything up for ``B``, ``C`` and ``D`` too, which not only makes unit testing harder, but is also complex and brittle)
- code that calls system functions (e.g. ``Unix.chmod``, which requires modifying actual files on the filesystem, also making unit testing harder) or more generally any kind of side-effecting function

Solutions
=========

Testing a module and all its dependencies
-----------------------------------------

Sometimes, legacy modules depending on other modules may be "unit-tested" without modifying them at all.

This approach is technically not "unit" testing, as the module/function is not tested in isolation from its dependencies. However as the intention is close to unit testing, we include it here.

Pros:

- No change at all in business code (not even an ``Internal_for_tests`` module)

Cons:

- Code not tested in isolation
- Tests are harder to write and maintain

Works best when:

- Code and its dependencies are pure, or "pure enough" (e.g. code may contain logging and other unharmful/unimportant side effects)
- Dependency tree is not too deep (e.g. if A depends on B which depends on C which depends on D, then it becomes hard to write/read/maintain unit tests)

Using function records
----------------------

This approach decouples the business code from its dependency functions, either by storing dependency functions in the state - if any - or by taking them as additional parameters in each module function.

Note that this does not decouple types, only functions!
If your module depends on a dependency type that can only be built by having side-effects, then you are forced to have those side effects too in your tests.

As an example, consider the following ``Counter`` module (signature and implementation):

.. code:: ocaml

    module Counter : sig
      type t
      val create_from_file : unit -> t
      val increment_and_save_to_file : t -> t
    end = struct
      type t = {
        counter : int;
      }

      let create_from_file () =
        let counter = int_of_string (Files.read "/tmp/counter.txt") in
        {counter}

      let increment_and_save_to_file t =
        let t = {counter = t.counter + 1} in
        Files.write "/tmp/counter.txt" (string_of_int t.counter);
        t
    end

``Counter`` is hardly unit-testable because of the calls to ``Files.read`` and ``Files.write`` which have the side effect of reading from/writing into ``/tmp/counter.txt`` file.
Thus we extract ``Files.read`` and ``Files.write`` into an argument that can be changed with a dumb ("mock") function in tests.

Store dependencies in state
~~~~~~~~~~~~~~~~~~~~~~~~~~~

One way to achieve this - when your module has a state, usually a type ``t`` - is to store all dependency functions in a field, here ``dependencies``:

.. code:: ocaml

    module Counter : sig
      type t
      val create_from_file : unit -> t
      val increment_and_save_to_file : t -> t

      module Internal_for_tests : sig
        type dependencies = {
          files_read : string -> string;
          files_write : string -> string -> unit;
        }
        val create_from_file : dependencies -> unit -> t
      end
    end = struct
      type dependencies = {
        files_read : string -> string;
        files_write : string -> string -> unit;
      }

      type t = {
        counter : int;
        dependencies : dependencies;
      }

      let create_from_file_internal dependencies () =
        let counter = int_of_string (dependencies.files_read "/tmp/counter.txt") in
        {counter; dependencies}

      let create_from_file = create_from_file_internal {files_read = Files.read; files_write = Files.write}

      let increment_and_save_to_file t =
        let t = {t with counter = t.counter + 1} in
        t.dependencies.files_write "/tmp/counter.txt" (string_of_int t.counter);
        t

      module Internal_for_tests = struct
        type nonrec dependencies = dependencies = {
          files_read : string -> string;
          files_write : string -> string -> unit;
        }

        let create_from_file = create_from_file_internal
      end
    end

Note that the direct calls to ``Files.read`` and ``Files.write`` were replaced with indirect calls to ``dependencies.files_read`` and field ``t.dependencies.files_write``:

- They are set to ``Files.[read|write]`` in the business constructor ``Counter.create_from_file``
- They are changed at will in the test constructor ``Counter.Internal_for_tests.create_from_file``

Also note that while the API was extended with test artifacts under the ``Internal_for_tests`` sub-module, the public API is otherwise unchanged, thus keeping this refactoring local - you do not need to change any call sites!

Now we can test this module without any side effect:

.. code:: ocaml

    let test () =
      let counter_value_written = ref "" in
      let fake_files_read file_name = "41" in
      let fake_files_write file_name text =
        counter_value_written := text
      in
      let counter = Counter.Internal_for_tests.create_from_file {files_read = fake_files_read; files_write = fake_files_write} () in
      let _ = Counter.increment_and_save_to_file counter in
      Alcotest.(check string) "counter value was incremented in file" !counter_value_written "42"

Taking dependencies in function argument
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

An alternative solution, more verbose but not requiring any "state" value available in each function, is to take the dependencies directly as an additional function argument:

.. code:: ocaml

    module Counter : sig
      type t
      val create_from_file : unit -> t
      val increment_and_save_to_file : t -> t

      module Internal_for_tests : sig
        type dependencies = {
          files_read : string -> string;
          files_write : string -> string -> unit;
        }

        val create_from_file : dependencies -> unit -> t
        val increment_and_save_to_file : dependencies -> t -> t
      end
    end = struct
      type dependencies = {
        files_read : string -> string;
        files_write : string -> string -> unit;
      }

      type t = {
        counter : int;
      }

      let business_dependencies = {
        files_read = Files.read;
        files_write = Files.write;
      }

      let create_from_file_internal dependencies counter =
        let counter = int_of_string (dependencies.files_read "/tmp/counter.txt") in
        {counter}

      let create_from_file = create_from_file_internal business_dependencies

      let increment_and_save_to_file_internal dependencies t =
        let t = {counter = t.counter + 1} in
        dependencies.files_write "/tmp/counter.txt" (string_of_int t.counter);
        t

      let increment_and_save_to_file t = increment_and_save_to_file_internal business_dependencies t

      module Internal_for_tests = struct
        type nonrec dependencies = dependencies = {
          files_read : string -> string;
          files_write : string -> string -> unit;
        }

        let create_from_file = create_from_file_internal
        let increment_and_save_to_file = increment_and_save_to_file_internal
      end
    end

Note that the direct calls to ``Files.read`` and ``Files.write`` were replaced with indirect calls to arguments ``dependencies.files_read`` and ``dependencies.files_write``:

- They are set to ``Files.[read|write]`` in each business function (``create_from_file`` and ``increment_and_save_to_file``)
- They are changed at will in the test function ``Counter.Internal_for_tests.[create_from_file|increment_and_save_to_file]``

As in the previous solution, notice that the public API has not changed - save for additional APIs in ``Internal_for_tests``.

Now we can test this module without any side effect:

.. code:: ocaml

    let test () =
      let counter_value_written = ref "" in
      let fake_files_read file_name = "41" in
      let fake_files_write file_name text =
        counter_value_written := text
      in
      let mock_dependencies = Counter.Internal_for_tests.{
        files_read = fake_files_read;
        files_write = fake_files_write;
      } in
      let counter = Counter.Internal_for_tests.create_from_file mock_dependencies () in
      let _ = Counter.Internal_for_tests.increment_and_save_to_file mock_dependencies counter in
      Alcotest.(check string) "counter value was incremented in file" !counter_value_written "42"

Pros and Cons for Function records
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Works best when:

- Dependency types are not too hard to build

Pros:

- No side-effecting function is called (they are replaced with mocks)
- Enables validating the arguments passed to mock functions (e.g. ``counter_value_written``) have the right value
- Independent of the dependency depth (for functions): if ``A`` calls ``B.f`` which calls ``C.g``, your mock of ``B.f`` will never call ``C.g``

Cons:

- All dependency types remain, so if it is difficult/side-effectful to create those values, testing remains difficult/not so unitary
- Adds a bit of boilerplate in ``Internal_for_tests`` module
- Adds a bit of indirection, by introducing indirect calls to dependency functions. The associated performance overhead should be negligible in most practical cases. There also is a slight decrease in code readability, but documenting this unit-testability pattern should avoid many headaches.

To choose between the field and the argument:

- If your module already has a kind of "state" (usually a type ``t``), then add a ``dependencies`` field
- Else add a ``dependencies`` argument - but this requires duplicating each function, which ends up being very verbose if you have several functions
- If your "state" value (usually a value of type ``t``) is passed to a polymorphic function like ``=`` or ``compare`` (which throw on function fields, and are famous for being an anti-pattern), and it is not possible for you to fix this anti-pattern, then either switch to function arguments, or wrap in an object.

Using functors
--------------

This approach decouples the business code from its dependency modules.
Note that unlike the Function records solution, this decouples both dependency functions **and abstract types**!

Consider the following code: it is similar to the previous ``Counter`` example but this time, the ``Files`` dependency module (which could be another module, a third party library, or even the ``Stdlib``) also has an abstract type ``t``:

.. code:: ocaml

    (* The dependency *)
    module Files : sig
      type t
      val openf : string -> t
      val write : t -> string -> unit
      val close : t -> unit
      (* Many other functions and types *)
    end = struct (* omitted implementation *) end

    module Counter : sig
      type t
      val create : int -> t
      val increment_and_save_to_file : t -> t
    end = struct
      type t = {
        counter : int;
      }

      let create counter = {counter}

      let increment_and_save_to_file t =
        let t = {counter = t.counter + 1} in
        let file = Files.openf "/tmp/counter.txt" in
        Files.write file (string_of_int t.counter);
        Files.close file;
        t
    end

The technique is to transform ``Counter`` into a functor that takes a module looking like ``Files`` in argument - but which can now be changed in tests!

.. code:: ocaml

    module Counter : sig
      module type S = sig
        type t
        val create : int -> t
        val increment_and_save_to_file : t -> t
      end

      include S

      module Internal_for_tests : sig
        module type FILES = sig
          type t
          val openf : string -> t
          val write : t -> string -> unit
          val close : t -> unit
        end
        module Make (Files : FILES) : S
      end
    end = struct
      module type S = sig
        type t
        val create : int -> t
        val increment_and_save_to_file : t -> t
      end

      module type FILES = sig
        type t
        val openf : string -> t
        val write : t -> string -> unit
        val close : t -> unit
      end

      module Make (Files : FILES) = struct
        type t = {
          counter : int;
        }

        let create counter = {counter}

        let increment_and_save_to_file t =
          let t = {counter = t.counter + 1} in
          let file = Files.openf "/tmp/counter.txt" in
          Files.write file (string_of_int t.counter);
          Files.close file;
          t
      end

      (* Do not be mistaken: here [Files] refers to the real, business [Files] module! *)
      include Make (Files)

      module Internal_for_tests = struct
        module type FILES = FILES

        module Make = Make
      end
    end

As you can see, this is significantly more verbose!

However, now we can freely change/mock not only the dependency functions ``Files.[openf|close|write]``, but also the implementation of type ``Files.t``!

.. code:: ocaml

    let test () =
      let written_content = ref "" in
      let module Counter = Counter.Internal_for_tests.Make (struct
        type t = unit
        let openf _ = ()
        let close _ = ()
        let write _ content = written_content := content
      end) in
      let counter = Counter.create 41 in
      let _ = Counter.increment_and_save_to_file counter in
      Alcotest.(check string) "counter value was incremented in file" !written_content "42"

While the real ``Files.t`` type probably contained a file descriptor, our mock module has no side effect outside of the test!

Note on verbosity: some things are duplicated because of OCaml MLI syntax, e.g. module type declaration.
This can be partially mitigated by using `the \_intf trick <https://www.craigfe.io/posts/the-intf-trick>`__ but this in turn induces a bit more complexity, use with caution.

Works best when:

- You need to decouple (abstract) types, not only functions. For example, because building values of those types adds too much complexity, or requires side-effects.
- There are linear dependencies in modules (``A`` depends on ``B`` which depends on ``C``, but ``A`` does not depend on ``C``)

Pros:

- Everything but exposed and private dependency types are mocked
- Enables validating the arguments passed to mock functions (e.g. ``counter_value_written``) have the right value
- Independent of the dependency depth (for functions): if ``A`` calls ``B`` which calls ``C``, your mock of ``B`` will never call ``C`` nor refer to its abstract types

Cons:

- Verbosity
- Additional complexity (functors, module types)
- Does not scale well in more complex dependencies (``A`` depends on ``B`` and ``C`` types, and ``B`` also depends on ``C`` types) as it induces a lot of destructive substitutions and module noise to convince the typechecker that ``C.t`` in ``A`` is the same as ``C.t`` in ``B``
- Does not work for exposed and private (exposed in read-only) dependency types
back to top