Describing one checkable's prerequisites

Trevor Wennblom edited this page Oct 23, 2013 · 10 revisions
Clone this wiki locally

Executable examples

Here is an example of using a prerequisite in the development of a function that combines the :test-paths and :source-paths keys in a Leiningen project file. We know we'll eventually have to read the project file, but let's defer worrying about that. Instead, we simply declare there'll someday be a function named read-project-file:

(unfinished read-project-file)

unfinished is similar to Clojure's declare in that it can take multiple arguments and define a var for each one of them. Unlike declare, it binds the var to a function that blows up if called:

user=> (read-project-file)
Error #'read-project-file has no implementation, but it was called like this:
(read-project-file )  midje.util.exceptions/user-error (exceptions.clj:13)

Let's begin with an empty fetch-project-paths and write a fact:

(defn fetch-project-paths []
  )

(fact "test and source paths are returned, tests first"
  (fetch-project-paths) => ["test1" "test2" "source1"]
  (provided
     (read-project-file) => {:test-paths ["test1" "test2"]
                             :source-paths ["source1"]}))

Note the somewhat peculiar syntax. The provided form follows the checkable; it isn't enclosed in it. A provided form can contain several prerequisites. Each of them describes a function call with arguments. When that function is called with matching arguments, it is made to return the right-hand side of the arrow.

Checking this fact will fail because it predicted a triplet but the actual value was nil:

FAIL "test and source paths are returned, tests first" at (prerequisites__the_basics.clj:15)
    Expected: ["test1" "test2" "source1"]
      Actual: nil
false

However, it also fails because the prerequisite function was never called:

FAIL at (prerequisites__the_basics.clj:17)
These calls were not made the right number of times:
    (read-project-file) [expected at least once, actually never called]

The combination of checkable and prerequisite made two checkable claims, neither of which succeed:

  • fetch-project-paths will return a certain value.
  • In doing so, it will call read-project-file in a certain way.

We can now implement fetch-project-paths. Just for fun, we'll do it in a silly way:

(defn fetch-project-paths []
    (flatten ((juxt :test-paths :source-paths) (read-project-file))))

The fact now succeeds.

We're not done with the implementation, though. What if the project file doesn't exist? In that case, we want fetch-project-paths to return ["test"]. It might be better to do that in a separate fact, but I'm going to tack it on to the existing one to show how checkables with prerequisites stack up in a fact:

(fact "fetch-project-paths"
  (fetch-project-paths) => ["test1" "test2" "source1"]
  (provided
     (read-project-file) => {:test-paths ["test1" "test2"]
                             :source-paths ["source1"]})

  (fetch-project-paths) => ["test"]
  (provided
    (read-project-file) =throws=> (Error. "boom!")))

Notice that I've defined something new about the not-yet-implemented read-project-file: its behavior when no file exists. Throwing an Error may not be the best choice, but it lets me show the =throws=> arrow. See below for more about prerequisite arrows.

When this fact is checked, the result is this:

FAIL "fetch-project-paths" at (prerequisites__the_basics.clj:20)
    Expected: ["test"]
      Actual: java.lang.Error: boom!
              as_documentation...(prerequisites__the_basics.clj:14)

Now we can fix the code:

(defn fetch-project-paths []
  (try
    (flatten ((juxt :test-paths :source-paths) (read-project-file)))
  (catch Error ex
    ["test"])))

The fact will now check out. And here's a prettier version of the test:

(facts "about fetch-project-paths"
  (fact "returns the project file's test and source paths, in that order"
    (fetch-project-paths) => ["test1" "test2" "source1"]
    (provided
      (read-project-file) => {:test-paths ["test1" "test2"]
                              :source-paths ["source1"]}))
  (fact "returns [\\"test\\"] if there is no project file."
    (fetch-project-paths) => ["test"]
    (provided
      (read-project-file) =throws=> (Error. "boom!"))))

Metaconstants

Generally, I don't like constants like "test1" and the like in my facts. In the one above, they're harmless, but sometimes when you read them, you wonder: "Is this just a randomly-chosen value, or is there something special about it?—something that matters to what the function under test does?"

To help you avoid such questions, Midje provides metaconstants. A metaconstant explicitly has no properties except identity and whatever properties you assign it via a prerequisite. Here's an alternative to the first of the two facts above:

(fact "fetch-project-paths returns the project file's test and source paths, in that order"
  (fetch-project-paths) => [..test1.. ..test2.. ..source..]
  (provided
    (read-project-file) => {:test-paths [..test1.. ..test2..]
                            :source-paths [..source..]}))

This leaves no doubt that the actual names have nothing to do with fetch-project-paths: all that can matter is the contents of two vectors.

To show how metaconstants work with prerequisites, consider a function that computes a grade point average from a list of courses:

(fact "GPA is weighted by credit hours"
  (gpa [{:credit-hours 1, :grade 5}
        {:credit-hours 2, :grade 3}])
  => (roughly 3.66 0.01))

Simple enough. However, the University administration has a new requirement for GPA calculation: the child of a wealthy alumnus gets an automatic half point increase in GPA.

gpa will need to take an argument describing the student. However, we don't want to assume too much about the data structure. In fact, we want to assume nothing more than that it provides the information gpa needs.

Here, then, is a fact that both checks the requirement and ensures our code won't be overly coupled to the student data structure:

(fact
  (let [correct-gpa 3.66
        tolerance 0.01
        coursework [{:credit-hours 1, :grade 5}
                    {:credit-hours 2, :grade 3}]]

    (gpa ..student.. coursework) => (roughly correct-gpa tolerance)
    (provided (child-of-wealthy-alumnus? ..student..) => false)
    
    (gpa ..student.. coursework) => (roughly (+ correct-gpa 0.5) tolerance)
    (provided (child-of-wealthy-alumnus? ..student..) => true)))

Exercises:

  1. Unlikely as it may seem to you, class warrior that you are, a legacy admission might have the work ethic to get good marks, ones leading to, say, a GPA of 4.7. Our thumb-on-the-scale computation would produce a GPA of 5.2, which is higher than the maximum possible. Update the fact to make 5.0 the maximum result of the gpa function. Improve the existing implementation.

  2. You might have found updating the fact somewhat annoying or tedious. There's an old saying: if the testing is hard, the problem is probably in your design. In this case, I think it's a problem that gpa smooshes two responsibilities together: that of making a fair GPA calculation, and that of cheating in some cases. Split the existing fact into two parts: one that describes fair calculations done by fair-gpa, and one that describes how gpa uses fair-gpa. The second fact should use a ..coursework.. metaconstant.

Prerequisites use a variant of extended equality to match arguments

Prerequisites don't just check that a function was called. They check that it was called with particular arguments. Most often, arguments are given explicitly and compared with equality. Here's a typical, if somewhat contrived, example:

(unfinished g)

(defn f [w1 w2] (inc (g (str w1 ", " w2))))

(fact 
  (f "hello" "world") => 13
  (provided 
    (g "hello, world") => 12))

If the calculation of g's argument were slightly changed, the fact would fail because it was called incorrectly:

FAIL at (NO_SOURCE_FILE:4)
You never said #'g would be called with these arguments:
    ("hello: world")

You don't always want exact argument matching. For that reason, Midje uses something close to extended equality for argument matching. For example, when you use a regular expression as a prerequisite argument, the comparison is not done using equality, but rather re-find. So, if all we care is that the argument to g contains both of f's arguments, we could write this:

(fact 
  (f "hello" "world") => 13
  (provided 
    (g #"hello.*world") => 12))

Calls to g will now return 12 if given "hello, world", "hello: world", or "hello! world of mine".

You can also use predefined checkers. For example, if you're testing a function f that does floating point computations to calculate the argument to g, you might want to use roughly:

   (provided 
     (g (roughly 5.0 0.01)) => 89))

Or if you don't actually care what argument g is called with, you can use anything:

   (provided 
     (g anything) => 89))

Argument matching isn't quite the same as extended equality, though. It differs in the handling of functions other than predefined checkers. Given how common higher-order functions are in Clojure, it's much more likely that a function as an argument is meant to be taken literally than to be used for matching. Therefore, in the following:

(fact 
  (function-under-test 3) => ..hilbertian-result..
  (provided
    (hilbertian even?) => ..hilbertian-result..))

... the prerequisite predicts that hilbertian will be called with the function even?, rather than with an even number. If you actually want a plain function to be used as an argument matcher, wrap it in as-checker:

  (provided
    (hilbertian (as-checker even?)) => ..hilbertian-result..))

To summarize: There's a difference between the extended equality used on the right-hand side of checkables and that used for argument matching in prerequisites. The difference is entirely in the handling of ordinary Clojure functions, those not explicitly marked as "official" Midje checkers. You can temporarily mark an ordinary function with as-checker. To make a permanently marked checker with the same status as roughly or anything, see defining checkers for use in prerequisites.

Call counts

Prerequisites defined by provided must be called once and may be called more than once. You can, however, adjust that expectation. Here's how you insist that a prerequisite be called exactly two times:

   (provided
     (f 5) => 50 :times 2)

You can use sequences, including lazy sequences, to describe a range of times a prerequisite must be called.

   (provided
     (f 5) => 50 :times [2 3]
     (f 4) => 40 :times (range 3 33))

To say "this call is optional", use this idiom:

   (provided
     (f 5) => 50 :times (range))

Just for grins, you can also give a function:

   (provided
     (f 1) => 1 :times even?))

The function is given the actual count of how often the function was called. If it returns a truthy value, all is well.

The "not-called" case

Suppose you want to predict that a function will never be called. That's awkward for two reasons:

  1. You have to describe the arguments the function would be called with, were it ever called, which it's not supposed to be.
  2. You have to provide the value the function would return, were it ever called, which it's not supposed to be.

So:

   (provided
    (f anything) => irrelevant :times 0))

Sorry. This will eventually be fixed.

Shorthand for prerequisite chaining

To make up for the previous section's awkwardness, here's a case where Midje works in a friendly way.

Consider this code:

(defn function-under-test [n]
 (inc (happens-second (happens-first 1 n))))

Let's suppose we want to get function-under-test right before working on happens-second and happens-first. We could work from this fact:

(fact
  (function-under-test 5) => 101
  (provided
    (happens-first 1 5) => ..some-result..
    (happens-second ..some-result..) => 100))

This is too wordy. What matters for understanding the relationships between functions is that happens-second works on the result of happens-first. What that result is doesn't matter to function-under-test (only to the two subsidiary functions). So there's no reason to give it a name: the two lines about its production and use just add clutter. We want one line. Like this:

(fact
  (function-under-test 5) => 101
  (provided
    (happens-second (happens-first 1 5)) => 100))

Midje expands that single prerequisite into the two prerequisites of the previous version, generating the intermediate metaconstant for you.

An annoyance: argument arity

Suppose you have a function with four arguments. For the fact you're writing, all you care about is the first one. You wish you could write this:

(provided
  (f 3 & anything) => 8)

You can't. You have to mention each argument specifically:

(provided
  (f 3 anything anything anything) => 8)

Sorry. We're working on implementing the first form.

Subject to the constraint that you have to mention each argument, variable-arity argument lists are not a problem. That is, you can write facts like this:

(fact (f 1) => 8
  (provided
    (g 1) => 3
    (g 1 2 3 4 5) => 50))