Unit Testing Arbitrary Interactive Clojure Command Line Interfaces (CLIs) with cli4clj – Illustrated using the Example of the Clojure REPL

In my previous posts, I primarily focused on cli4clj itself and on CLIs created with cli4clj. In this post, I want to address a bit the applicability of the cli4clj unit testing functionality for testing “third-party” CLIs. I will do this by showing how to easily unit test the Clojure REPL with the cli4clj unit testing functionality.

As examples for illustrating the working principles in this post, I will use the source code of unit tests that I recently added to the cli4clj repository. The examples will start simple and will be enhanced step by step in order to provide a hopefully fluent and clear line of argumentation.

In the examples, the input commands will be stored in the “in-cmds” vector, which represents the sequence of inputs that are consecutively entered into the REPL. The output is stored in the “out” var. The expected output that will be emitted is shown as the expected string value in the “(test/is (= “expected string” out))” expressions.

At first, I consider a simple execution of the Clojure REPL with cli4clj. This can be considered as a sort of “Hello World” execution example and is shown in the listing below:

(test/deftest clojure-repl-stdout-no-op-test
  (let [in-cmds [""]
        out (cli-tests/test-cli-stdout
              clojure.main/repl
              in-cmds)]
    (test/is
      (= "user=> user=>" out))))

This example illustrates the basic capability of executing the Clojure REPL with cli4clj. One annoying property is that the REPL prompt output “user=> user=>” will clutter up the output. The problem of the REPL prompt cluttering up the output will get even worse when the complexity of the considered scenarios increases.

In the next step, the problem of the REPL prompt showing up in the output will be tackled. For getting rid of the REPL prompt, the function used for printing the prompt can be set to emit an empty string. In the following listing, the “str” function is used, which, when called without arguments, returns the empty string, in order to remove the REPL prompt:

(test/deftest clojure-repl-stdout-no-op-no-prompt-test
  (let [in-cmds [""]
        out (cli-tests/test-cli-stdout
              #(clojure.main/repl :prompt str)
              in-cmds)]
    (test/is (= "" out))))

With the REPL prompt output out of the way, it is time to actually do something. In the next example, the “inc” function is used as a kind of “Hello World” for actually executing some functionality:

(test/deftest clojure-repl-stdout-inc-no-prompt-test
  (let [in-cmds ["(inc 1)"]
        out (cli-tests/test-cli-stdout
              #(clojure.main/repl :prompt str)
              in-cmds)]
    (test/is (= "2" out))))

In the next step, I show a slightly more complex example for actually doing things with the REPL. This example includes the definition of a var, the use of the var, and the execution of a function that prints to stdout:

(test/deftest clojure-repl-stdout-def-inc-println-no-prompt-test
  (let [in-cmds ["(def x 21)"
                 "(inc x)"
                 "(println x)"]
        out (cli-tests/test-cli-stdout
              #(clojure.main/repl :prompt str)
              in-cmds)]
    (test/is (=
               (cli-tests/expected-string
                 ["#'user/x"
                  "22"
                  "21"
                  "nil"])
               out))))

Instead of the expected result string, as used in the earlier examples, this example uses “cli-tests/expected-string” to build that string. “cli-tests/expected-string” simply takes care of inserting the proper line separator in between the strings given to it in the argument vector.

The example demonstrates that the REPL works as expected. A var can be defined, used, and println outputs the string an returns nil as expected.

However, there is one peculiarity that may not be obvious at first but may become critical later. In this example execution, the var “x” is defined in the “user” namespace and the namespace is hard-coded in the expected string. Under certain circumstances, e.g., when running the tests with “lein cloverage”, however, the namespace in which “x” will be declared will be different and thus the test will fail.

Luckily, this problem can be easily fixed by using the “*ns*” global variable to get the current namespace. This is shown in the following listing:

(test/deftest clojure-repl-stdout-def-inc-println-no-prompt-test
  (let [in-cmds ["(def x 21)"
                 "(inc x)"
                 "(println x)"]
        out (cli-tests/test-cli-stdout
              #(clojure.main/repl :prompt str)
              in-cmds)]
    (test/is (=
               (cli-tests/expected-string
                 [(str "#'" *ns* "/x")
                  "22"
                  "21"
                  "nil"])
               out))))

In this example, the hard-coded string “#’user/x” was replaced with a dynamically constructed string based on “*ns*”: “(str “#'” *ns* “/x”)”. I tested this version with “lein test” and “lein cloverage”.

Last but not least, I also show the use of the recently added string-latch. The string-latch can be particularly useful for “stepping” through the execution of a test.

I will not cover details of the string-latch here. For the discussion here, it is sufficient to say that the string latch “sl” waits for the defined strings in a step-wise way. Once one string was matched, it waits for the next string to occur and so on. Thereby, an optional function can be defined that will be executed when the corresponding string matches. An example for this is shown in the listing below:

(test/deftest clojure-repl-stdout-def-inc-println-no-prompt-string-latch-test
  (let [in-cmds ["(def x 21)"
                 "(inc x)"
                 "(println x)"]
        val-0 (atom nil)
        val-1 (atom nil)
        val-2 (atom nil)
        sl (cli-tests/string-latch
             [[(str "#'" *ns* "/x") #(reset! val-0 %)]
              ["22" #(reset! val-1 %)]
              ["21" #(reset! val-2 %)]
              "nil"])
        out (cli-tests/test-cli-stdout
              #(clojure.main/repl :prompt str)
              in-cmds
              sl)]
    (test/is (= [(str "#'" *ns* "/x")] @val-0))
    (test/is (=
               [(str "#'" *ns* "/x")
                cli/*line-sep*
                "22"]
               @val-1))
    (test/is (=
               [(str "#'" *ns* "/x")
                cli/*line-sep*
                "22"
                cli/*line-sep*
                "21"]
               @val-2))
    (test/is (=
               (cli-tests/expected-string
                 [(str "#'" *ns* "/x")
                  "22"
                  "21"
                  "nil"])
               out))))

In this example, the string latch is used to set the atom vars “val-0” to 2. The vars are set to the vector of strings that were so far observed by the string-latch “sl”. It can be seen how the strings accumulate with increasing val-x index.

Please note that the namespace was not hard-coded but that the approach using *ns* was used. Furthermore, note that the string latch also contains the new-line characters, which are matched via the cli/*line-sep* placeholder.

Summary

In this post, I used the Clojure REPL as example to show that the unit testing functionality provided by cli4clj can also be used for testing more interactive CLI applications than ones created with cli4clj. So far, I did not encounter limitations such that from my current point of view, it should be possible to test any interactive Clojure CLI application with cli4clj.

I hope that you consider this post and cli4clj useful. As usual, constructive feedback, criticism, and comments are always welcome.

This entry was posted in cli4clj, Libs. and tagged , , , . Bookmark the permalink.

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.