Based on something at work I was looking at the new Medicare identifier (MBI) that the CMS is rolling out starting next year. It seemed like a handy way to do some exercising of the generative testing capabilities of clojure.spec.
The MBI is an 11 character alpha-numeric string The full specification (pdf) shows all the details but the relevant part of the spec looks like this:
There are 4 different types of letter/number combinations that fit into each of the 11 slots. I’ll use the brand-spanking new 1.9 release of clojure with a few require
s.
(ns specking.core
(:require [clojure.spec.alpha :as s]
[clojure.set :refer [difference]]
[clojure.spec.gen.alpha :as gen]))
Now let’s just define those 4 letter/number types as specs (along with a simple char-range
convenience function).
(defn char-range [start end]
(set (map char (range start (inc end)))))
(s/def ::C (char-range 49 57)) ; 49 is \1 and 57 is \9
(s/def ::N (char-range 48 57)) ; 48 is \0
(def excluded-alphas #{\S \L \O \I \B \Z})
(s/def ::A (difference (char-range 65 90) ; 65 is \A and 90 is \Z
excluded-alphas))
(s/def ::AN (s/or :A ::A :N ::N))
With these definitions we can s/def
an ::MBI
spec
(s/def ::MBI (s/cat :pos1 ::C :pos2 ::A :pos3 ::AN
:pos4 ::N :pos5 ::A :pos6 ::AN
:pos7 ::N :pos8 ::A :pos9 ::A
:pos10 ::N :pos11 ::N))
I created a key for each position labeled with its ordinal value. With that I can test a string for validity like this
user> (s/valid? :specking.core/MBI (seq "6KX8DM0RD51"))
true
I need to add a call to seq
for the string so that it will be split into a sequence of characters. What happens if I try to call an invalid MBI? I’ll add an extra 3
at the end so it is now a 12 character string.
user> (s/valid? :specking.core/MBI (seq "6KX8DM0RD513"))
false
user> (s/explain :specking.core/MBI (seq "6KX8DM0RD513"))
In: [11] val: (\3) fails spec: :specking.core/MBI predicate:
(cat :pos1 :specking.core/C :pos2 :specking.core/A :pos3 :specking.core/AN
:pos4 :specking.core/N :pos5 :specking.core/A :pos6 :specking.core/AN
:pos7 :specking.core/N :pos8 :specking.core/A :pos9 :specking.core/A
:pos10 :specking.core/N :pos11 :specking.core/N), Extra input
nil
Note the call to s/valid?
returns false
. I can also use s/explain
to get a detailed output as to what it wrong. Let’s create an MBI with an invalid character at position 9
user> (s/explain :specking.core/MBI (seq "1JK8E17YZ57"))
In: [8] val: \Z fails spec: :specking.core/A at:
[:pos9] predicate: (difference (char-range 65 90) excluded-alphas)
The Z
character is not allowed. You can see it tells me exactly where the error is and how it failed. The s/explain-data
function returns the same as a parseable hashmap.
But there’s more! I can also use the clojure.spec.gen.alpha
functions to create random MBIs for testing. It couldn’t be much simpler in this case.
user> (apply str (gen/generate (s/gen :specking.core/MBI)))
"1KV9NP2QF07"
;Let's get 10 of 'em
user> (repeatedly 10 #(apply str (gen/generate (s/gen :specking.core/MBI))))
("7VU5NJ2NX58" "5G34N68CR83" "3N65Q63TF72" "6R66FN3MD95"
"2QA8PP9FR01" "8NN5H49AQ17" "1T17DH1AX91" "5T61GU2MC64"
"2T16D99CA09" "1W47X41PY58")
The s/gen
function returns a generator for the specified spec. The gen/generate
function uses that generator to output a valid example.
I won’t do it here but with clojure.spec
you’d also be able to write functions that require an MBI parameter or return a valid MBI as output. Pretty cool!