Card game: spec and generative testingλ︎
Define a data specification that represent a deck of playing cards, adding functional specifictations to check the values passed to the functions use to play a card game.
spec generators are used to return varied sample data from those specifications. Function definitions are instrumented and check for correct arguments when those functions are called.
Create a new projectλ︎
Create a new Clojure project using :project/create
from Practicalli Clojure CLI Config or add an alias definition of your choosing to the Clojure CLI user configuration.
Open the src/practicalli/card_game.clj
file and require the clojure.spec.alpha
namespace
Playing card specificationsλ︎
A playing card has a face value and a suit. There are 4 suits in a card deck.
A specification for the possible suits can be defined using literal values
Define a predicate function to check a value conforms to the spec using the pattern matching that is build-in to the Clojure set
data type.
Card game decksλ︎
Suits from different regions are called by different names. Each of these suits can be their own spec.
(spec/def ::suits-french #{:hearts :tiles :clovers :pikes})
(spec/def ::suits-german #{:hearts :bells :acorns :leaves})
(spec/def ::suits-spanish #{:cups :coins :clubs :swords})
(spec/def ::suits-italian #{:cups :coins :clubs :swords})
(spec/def ::suits-swiss-german #{:roses :bells :acorns :shields})
A composite specification called ::card-suits
provides a simple abstraction over all the variations of suits. Using ::card-suits
will be satisfied with any region specific suits.
(spec/def ::card-suits
(spec/or :french ::suits-french
:german ::suits-german
:spanish ::suits-spanish
:italian ::suits-italian
:swiss-german ::suits-swiss-german
:international ::suits-international))
Define an aliasλ︎
Jack queen king are called face cards in the USA and occasionally referred to as court cards in the UK.
Define a spec for ::face-cards
and then define :court-cards
and alias
Any value that conforms to the ::face-card
specification also conforms to the ::court-cards
specification.
Playing card rankλ︎
Each suit in the deck has the same rank of cards explicitly defining a rank
Rank can be defined more succinctly with the clojure.core/range
function. The expression (range 2 11)
will generates a sequence of integer numbers from 2 to 10 (the end number is exclusive, so 11 is not in the sequence).
Using clojure.core/into
this range of numbers can be added to the face card values.
The ::rank
specification now generates all the possible values for playing cards.
The specification only checks to see if a value is in the set, the order of the values in the set is irrelevant.
Playing Cardλ︎
A playing card is a combination of suit and face value, a pair of values, referred to as a tuple.
Clojure spec has a tuple
function, however, we need to define some predicates first
Use the spec with values to see if they conform. Try you own values for a playing card.
Game specsλ︎
Define specifications for data used to represent players and the overall card game.
The player name is a very simple spec.
Score will keep a running total of a player score across games, again a simple integer value.
A player is represented by a hash-map that contains their name, score and the hand they are currently dealt. The hand is a collection of tuples representing a playing card.
Game deck specsλ︎
A card game has a deck of 52 cards, one card for each combination of suit and rank.
The size of the card deck changes over the course of a game, so the deck can contain any number of cards. The deck must contain only cards to be valid.
At this stage in the design, a card game can have any number of players
A game is represented by a hash-map with a collection of players and a card deck
Generative data from Specificationsλ︎
Clojure spec can generate random data which conforms to a specification, highly useful in testing Clojure code with a wide variety of values.
clojure.spec.alpha/gen
returns a generator for the given specification.clojure.spec.gen.alpha/generate
takes that generator and creates a random value that conforms to the specification.clojure.spec.gen.alpha/sample
will generate a collection of random values that each conform to the specification.
Require the clojure spec namespaces to make use of their functions.
(ns practicalli.card-game.clj
(:require [clojure.spec.alpha :as spec]
[clojure.spec.gen.alpha :as spec-gen]
[clojure.spec.test.alpha :as spec-test]))
(spec/def ::suits #{:clubs :diamonds :hearts :spades})
(spec/def ::rank #{:ace 2 3 4 5 6 7 8 9 10 :jack :queen :king})
To generated data based on a specification, first get a generator for a given spec,
generate
will return a value using the specific generator for the specification.
sample
will generate a number of values from the given specification
Card Game dataλ︎
Generate a random value for the ::player
specification
Example
Expected output from generate
```clojure
:practicalli.spec-generative-testingλ︎
{:name "Yp34KE63vAL1eriKN4cBt", :score 225, :dealt-hand ([9 :hearts] [4 :clubs] [8 :hearts] [10 :clubs] [:queen :spades] [3 :clubs] [6 :hearts] [8 :hearts] [7 :diamonds] [:king :spades] [:ace :diamonds] [2 :hearts] [4 :spades] [2 :clubs] [6 :clubs] [8 :diamonds] [6 :spades] [5 :spades] [:queen :clubs] [:queen :hearts] [6 :spades])}
```
Generate a random value for the ::game
specification
Generate a collection of random values that each conform to the specification.
Function Specificationsλ︎
A function specification can contain a specification for the arguments, the return values and the relationship between the two.
The specifications for the function may be composed from previously defined data specifications.
(ns practicalli.card-game
(:require [clojure.spec.alpha :as spec]
[clojure.spec.gen.alpha :as spec-gen]
[clojure.spec.test.alpha :as spec-test]))
(spec/def ::suit #{:clubs :diamonds :hearts :spades})
(spec/def ::rank (into #{:jack :queen :king :ace} (range 2 11)))
(spec/def ::playing-card (spec/tuple ::rank ::suit))
(spec/def ::dealt-hand (spec/* ::playing-card))
(spec/def ::name string?)
(spec/def ::score int?)
(spec/def ::player (spec/keys :req [::name ::score ::dealt-hand]))
(spec/def ::card-deck (spec/* ::playing-card))
(spec/def ::players (spec/* ::player))
(spec/def ::game (spec/keys :req [::players ::card-deck]))
Function definitionλ︎
The card game application has three functions to start with.
(defn regulation-card-deck
"Generate a complete deck of playing cards"
[{:keys [::deck ::players] :as game}]
(apply + (count deck)
(map #(-> % ::delt-hand count) players)))
At the start of function design, the algorithm may still be undefined. Using the specifications and generators mock data can be returned as a placeholder.
(defn deal-cards
"Deal cards to each of the players
Returns updated game hash-map"
[game]
(spec-gen/generate (spec/gen ::game)))
(defn winning-player
"Calculate winning hand by comparing each players hand
Return winning player"
[players]
(spec-gen/generate (spec/gen ::player)))
Example
The expected form of a player won game:
Spec definitionsλ︎
Define a function specification for the deal-cards
function
- argument must be of type
::game
- return type is
::game
- function applies arguments to a game and returns the game
(spec/fdef deal-cards
:args (spec/cat :game ::game)
:ret ::game
:fn #(= (regulation-card-deck (-> % :args :game))
(regulation-card-deck (-> % :ret))))
Define a function specification for the winning-player
function
- argument must be of type
::players
- return type is
::players
Instrument functionsλ︎
Instrumenting functions will wrap a function definition and check the arguments of any call to the instrumented function.
Calling the deal-cards
function with an incorrect argument returns an error that describes where in the specification the error occurred.
Error in an easier to read format
ERROR: #error
{:message "Call to #'practicalli.card-game/deal-cards did not conform to spec:\n\
"fake game data\" - failed:
map? in: [0] at: [:args :game] spec: :practicalli.card-game/game\n",
:data {:cljs.spec.alpha/problems
[{:path [:args :game],
:pred cljs.core/map?,
:val "fake game data",
:via [:practicalli.card-game/game :practicalli.card-game/game],
:in [0]}],
:cljs.spec.alpha/spec #object[cljs.spec.alpha.t_cljs$spec$alpha17968],
:cljs.spec.alpha/value ("fake game data"),
:cljs.spec.alpha/args ("fake game data"),
:cljs.spec.alpha/failure :instrument}}
Organizing function instrumentationλ︎
Instrumenting functions creates a wrapper around the original function definition.
When you change the function definition and evaluate the new code, it replaces the instrumentation of the function. Therefore each time a function is redefined it should be instrumented.
There is no specific way to manage instrumenting a function, however, a common approach is to define a collection of functions to instrument, then use a helper function to instrument all the functions at once.
Bind a name to the collection of function specifications.
Define a simple helper function to instrument all the functions in the collection.
Refactoring the code may involve a number of changes benefit from instrumentation being switched off until its complete. The unstrument
function will remove instrumentation from all the functions in the collection.
Koacha Test Runner can include functional specifications
Koacha test runner can manage the testing of function specifications and is especially useful for managing unit level testing with specifications.