Monday, May 07, 2012

Clojure: Conditionally Importing

I recently ran into a test that needed (org.joda.time.DateTime.) to always return the same time - so it could easily be asserted against. This situation is fairly common, so it makes sense to add support to expectations. However, I didn't want to force a joda-time dependency on everyone who wanted to use expectations. Luckily, Clojure gives me the ability to conditionally import dependencies.

The test looked something like the following code snippet.
(scenario
 (handle-fill (build PartialFill))
 (expect {:px 10 :size 33 :time 1335758400000} (in (first @fills))))
note: build is a fn that creates domain objects (I do a lot of Java Interop).

The test builds a PartialFill domain object, and passes it to handle-fill. The handle-fill fn converts the domain object to a map and conj's the new map onto the fills vector (which is an atom).

The build fn creates a PartialFill who's time is (DateTime.), which means you can either not test the time or you need to freeze the time. Joda time makes it easy to freeze time by calling DateTimeUtils/setCurrentMillisFixed, so you could write the test as:
(scenario
 (DateTimeUtils/setCurrentMillisFixed 100)
 (handle-fill (build PartialFill))
 (expect {:px 10 :size 33 :time 100} (in (first @fills)))
 (DateTimeUtils/setCurrentMillisSystem))
Of course, that would cause issues if expect ever failed, as failure throws an exception and the time reset would never occur. Even if that wasn't the case, the time related code takes away from what I'm really testing - so I set out to create a declarative approach from within expectations.

The syntax for the freezing time is fairly straightforward, and the original test can be written as what follows (with expectations 1.4.3 & up)
(scenario
 :freeze-time "2012-4-30"
 (handle-fill (build PartialFill))
 (expect {:px 10 :size 33 :time 1335758400000} (in (first @fills))))
Writing the code to freeze time with expectations is trivial enough, but it's only relevant if you actually use joda-time. Since not everyone uses joda-time, it seemed like the best option was to make expectations import joda only if you actually use :freeze-time within your code.

The code linked here shows the conditional imports: https://github.com/jaycfields/expectations/blob/f2a8687/src/clojure/expectations/scenarios.clj#L81-L86

Even if you're not familiar with macros it should be obvious that I'm importing and using the joda-time classes conditionally. At macro expansion time I'm checking for the presence of freeze-time, and if it does exist the necessary classes are imported. If freeze-time is never found, the joda-time classes will never be imported, and no joda-time dependency will need to live within your project.

I always assumed this would be possible, but I'd never actually attempted to put this type of code together. I'm definitely happy with how the code turned out - I can freeze time in expectations if I want, but I don't force anyone to use joda.

6 comments:

  1. Oh that's very nice! I can use that immediately in my tests at World Singles to simplify how we do a few scenarios!

    ReplyDelete
  2. Hi Sean,
    Good to hear. I'm using it at DRW as well, so it should be all good. Though, it's possible that I've missed a corner case. Let me know if you find any issues.

    Cheers, Jay

    ReplyDelete
  3. Nice!

    The link it the post points to the master branch, and I suspect master will change over time. Here's a link to the code as it exists today (i.e., as of commit f2a8687).

    Hopefully this will be helpful to people reading this post in the future, after the master branch has moved on and lines 81-86 no longer point to the code in question.

    ReplyDelete
  4. Cool. thanks Jason. I updated the post...

    ReplyDelete
  5. Why are you importing at all? You don't use the short name, and the long one is available whether you import or not. Using the macro to avoid referring to the joda classes is fine, but the import isn't buying you anything as far as I can tell (and might conceivably be mucking up the caller's namespace, but probably isn't).

    ReplyDelete
  6. Unknown, I've always import the short name, and just assumed that you'll still need to import if you use the long name. Still you can switch to using short names in the example and the idea still holds true. Thanks for the tip.

    Cheers, Jay

    ReplyDelete

Note: Only a member of this blog may post a comment.