Chapter 14: Creating A Module and Performing Unit Tests
In Chapter 6 we learned about adding and loading packages or modules. These were generally official ones. In this section, we will now learn how to write our own module. We will learn how to do this by creating a PlayingCards
module with all of the types and functions associated with it. It is helpful to read through the julia documentation on modules.
To begin with, let’s look at a module template:
module PlayingCards
export Card,Hand,is_full_house
##definitions of types and functions
end
As you can see above, a module has a name (in this case PlayingCards
) and ends with the keyword end
. We will next put a number of types and functions inside the module, but in order for someone loading the module, you need to export anything to be used.
The following is a more detailed version of the module:
module PlayingCards
import Base.show
export Card,Hand,is_full_house
ranks = ['A','2','3','4','5','6','7','8','9','T','J','Q','K']
suits = ['\u2660','\u2661','\u2662','\u2663']
mutable struct Card
rank::Int
suit::Int
# construct a card based on the rank and suit
function Card(r::Int,s::Int)
1 <= r <=13 || throw(ArgumentError("The rank must be an integer between 1 and 13."))
1 <= s <= 4 || throw(ArgumentError("The suit must be an integer between 1 and 4."))
new(r,s)
end
# construct a card based on the number in a deck
function Card(i::Int)
1 <= i <= 52 || throw(ArgumentError("The argument must be an integer between 1 and 52"))
i%13==0 ? new(13,div(i,13)) : new(i%13,div(i,13)+1)
end
# construct a card based on a string representation of the card
function Card(str::String)
length(str)==2 || throw(ArgumentError("The string should only be 2 characters"))
local r = findfirst(a->a==str[1],ranks)
r != nothing && 1 <= r <= 13 || throw(ArgumentError(string("The first character should be one of ",join(ranks,","))))
local s=findfirst(a->a==str[2],suits)
s != nothing && 1<= s <= 4 || throw(ArgumentError(string("The second character should be one of ",join(suits,","))))
new(r,s)
end
end
mutable struct Hand
cards::Array{Card,1}
function Hand(cards::Array{Card,1})
new(cards)
end
function Hand(cards::Array{String,1})
new(map(Card,cards))
end
function Hand(s::String)
new(map(Card,map(String,split(s,','))))
end
end
function Base.show(io::IO, c::Card)
print(io,string(ranks[c.rank],suits[c.suit]))
end
function Base.show(io::IO, h::Hand)
print(io,string("[",join(map(c->string(ranks[c.rank],suits[c.suit]),h.cards),",")),"]")
end
function is_full_house(h::Hand)
local cranks=sort(map(c->c.rank,h.cards))
cranks[2]==cranks[1] && cranks[5]==cranks[4] && (cranks[3]==cranks[2] || cranks[4]==cranks[3]) &&
cranks[2] != cranks[4]
end
end #module PlayingCards
Open a new file in jupyter or juliabox and copy-paste the above module into the empty file. It will need to be called PlayingCards.jl
.
Loading the module
This is a module/package, which like other packages, need to be loaded with either the using
or import
keyword. Since this is just a file in the current directory, we first run the file and then load it
include("PlayingCards.jl")
using .PlayingCards
where the .
represents a local (current directory) module.
Debugging a module
All is great now if you have a bug-free module, but even the best programmers are not capable of bug-free code the first time. Notice that if you make changes to the module, then the changes won’t be present. And if you reload the file and the module, from the lines:
include("PlayingCards.jl")
using .PlayingCards
The first line should report:
WARNING: replacing module PlayingCards.
and any syntax errors that you may have, and the second line reports:
WARNING: using PlayingCards.Hand in module Main conflicts with an existing identifier.
WARNING: using PlayingCards.Card in module Main conflicts with an existing identifier.
but any changes won’t be made. (Try altering the Base.show function on a Card
). If you want changes to be able to be used, you will need to restart the kernel.
So while debugging a module, we will use the Revise
package instead. Make sure you add it first, then restart the kernel, then enter
using Revise
includet("PlayingCards.jl")
using PlayingCards
where includet
is a command that tracks changes and automatically reloads after any changes.
Unit Tests
Writing a module is important, but making sure it does what it is supposed to is just as important. At first, when you start writing code, you typically make sure there are no bugs, but after time, code changes and you’re not sure that the code still works. The notion of a unit test is a set of tests to determine if you get out from the code what you expect. This is a language-independent idea and should be created once you write any level of substantial code.
To run a test in Julia, first import the Test
package:
using Test
To do a test, we’ll use the @test
macro and it’s a good idea to check out the Julia documentation on Test. For this module, let’s first test that the constructor is working using the isa
function.
@test isa(Card(1,3),Card)
should return Test Passed
. Recall also that we can pass in an integer between 1 and 52, so
@test isa(Card(24),Card)
should also return Test Passed
. If we try to create a card that is not valid, then we won’t get a Test Passed. For example:
@test isa(Card(78),Card)
will return Error During Test
. A better way to test for this is using the @test_throws
method to test if an error is thrown:
@test_throws ArgumentError Card(78)
returns
TTest Passed
Thrown: ArgumentError
Here’s another nice test that will check if the two different ways to create Cards are the same. For this we will need a way to test if two Cards are equal.
function isequal(x::Card,y::Card)
x.rank==y.rank&&x.suit==y.suit
end
then create two cards
c1=Card(2,3)
which returns 2♢
c2=Card(28)
which also returns 2♢
and test if they are the same with
@test isequal(Card(2,3),Card(28))
Exercise
We will won’t to test that the is_full_house
method is working. To do this:
- Create a hand that is a full house, called
h1
. Run@test is_full_house(h1)
- Create another that is a full house and test it.
- Create a third hand that is not a full house and test it. To get the test to return passed, put
!
in front of your call tois_full_house
.
Creating a test suite.
Often in a set of tests, there are mulitple tests that go together. For example, if we just want to test that the construction of Cards are working, we can create a test set in the following:
@testset "Legal Card Constructor" begin
@test isa(Card(1,3),Card)
@test isa(Card(45),Card)
@test isa(Card("3\u2660"),Card)
end;
where all three methods to construct a card are used and all should work. If you run this, you should get:
Test Summary: | Pass Total
Legal Card Constructor | 3 3
which just shows that we passed all of the tests. If one of them doesn’t pass, you will get information about the one that wasn’t passed. Try changing one of the tests above to get an illegal card.
Putting all tests in a file
The Playing Card test file is a better way to run a set of tests. To use this, download the file, put it in the current directory of juliabox or jupyter and then run it with:
include("test-playing-cards.jl")
and you should see:
Test Summary: | Pass Total
Legal Card Constructor | 4 4
Test Summary: | Pass Total
Illegal Cards throws exceptions | 5 5
Test Summary: | Pass Total
Legal Hand Constructor | 3 3
Test Summary: | Pass Total
Illegal Hand throws exceptions | 5 5
Test Summary: | Pass Total
Card Tests | 3 3
Test Summary: | Pass Total
Full House | 2 2
printed out.
Modules and Unit Tests
Once you have enough code to write a module, the first thing should be to start writing unit tests to make sure it is correct. In fact, good programming practice is to write the API (Application Programming Interface), which is just the function signatures with no funtion bodies, then the test cases before any working code is written.
In any case, once you have unit tests working, you should write and revise any code and after any changes are made, rerun any unit tests.