Description
Context
Some exercises require input validation. A simple example (copied below) from the grains exercise.
{
"uuid": "61974483-eeb2-465e-be54-ca5dde366453",
"description": "negative square is invalid",
"property": "square",
"input": { "square": -1 },
"expected": { "error": "square must be between 1 and 64" }
}
At the moment, the Clojure track lacks a consistent approach to what to expect from learners and, therefore, how to implement the tests.
A consistent approach is important to allow learners to focus on the core of the exercise, on the concept which is being presented without needing to learn various ways of error handling that are possible in Clojure. Error handling should be explored with specific examples in an error-handling concept.
For the same reason, we should choose a simple or the simplest approach.
But it should also be idiomatic so that we are not introducing practices which are not natural for the language.
Our options
Option 1 - return error value
Some of the current exercises return an "error value", for example in the phone number exercise have:
(deftest invalid-with-letters
(testing "Invalid with letters"
(is (= "0000000000" (phone/number "523-abc-7890")))))
This could be standardised to include the expected description of the error:
(deftest invalid-with-letters
(testing "Invalid with letters"
(is (= "error: phone number is invalid with letters" (phone/number "523-abc-7890")))))
Pros: The implementation of the phone/number
could be left to the learner. Any flow control would do.
Cons: It is generally not a good practice as error handling might be difficult.
Option 2 - asserts
A relatively common pattern in Clojure is to use assert
s. An example of testing could be the following:
(deftest test-zero-guard
(testing "error handling test"
(is (thrown? AssertionError (divide 2 0)))))
or with the exact message
(deftest test-zero-guard
(testing "error handling test"
(is (thrown-with-msg?
AssertionError
#"Cannot divide by 0!"
(divide 2 0)))))
with the expected implementation implementation something like this:
(defn divide [a b]
(assert (not (zero? b)) "Cannot divide by 0!")
(/ a b))
Pros: Very simple and relatively common approach. Multiple assertions can be added one after the other without increasing nesting.
Cons: Strong voices in the community suggest that assertions use internal Java constructs that were never meant for such use, only for fatal errors that should result in the application stopping immediately.
Option 3 - throwing exceptions
Instead of using assertions we can throw IllegalArgumentException
or a more generic exception with ex-info
. The testing would be the same as in Option 2 - asserts, but the implementation would change to:
(defn divide [a b]
(if (zero? b) (throw (IllegalArgumentException. "Cannot divide by 0!"))
(if (= 3 b) (throw (IllegalArgumentException. "We don't like threes"))
(/ a b))))
or with ex-info
(defn divide [a b]
(if (zero? b) (throw (ex-info "Cannot divide by 0!" {}))
(if (= 3 b) (throw (ex-info "We don't like threes" {}))
(/ a b))))
Pros: Perhaps a more standard approach to error handling, but it requires conditionals, constructing, and throwing exceptions.
Cons: More complex, and with multiple assertions it adds a lot to the actual code for the solution.
Option 4 - semantic error handling
This is similar to error codes, Option 1, but with a little bit more structure. Testing could look like so:
(deftest test-zero-guard
(testing "error handling test"
(is (= {:error "Cannot divide by 0!"}
(divide 2 0)))))
(deftest test-for-three-handling
(testing "we don't like threes"
(is (contains? (divide 2 3) :error))))
With an example implementation code implementing it here:
(defn divide [a b]
(if (zero? b) {:error "Cannot divide by 0!"}
(if (= 3 b) {:error "We don't like threes"}
{:result (/ a b)})))
Pros: An approach that might be familiar from other languages. It provides clear structure to the tests.
Cons: This can be a bit verbose for simple exercises requiring {:result (whatever)}
for the return.