Skip to main content

Chapter 23 Creating Modules and using Unit Tests

Throughout this text, we have added and loaded 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 from Chapter 20 as well as the rootfinding functions and structs of Chapter 10. As needed, you should review the material in those chapters.

Section 23.1 The Revise

Although this isn’t necessary, the Revise package makes developing a module much easier. You should add and the load it with
using Revise
This will allow changes to a module updated automatically. You can use this with either the REPL or a Jupyter notebook.

Section 23.2 Creating a Module

To begin with, let’s look at a module template:
module PlayingCards
## 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. We have used the naming convention of capital letters (often called Pascal case) for a Module name.
We will take all of the structs, constructors and functions associated with playing cards that we developed earlier and place them in the module.
module PlayingCards
import Base.show
export Card, Hand, isFullHouse
ranks = ['A','2','3','4','5','6','7','8','9','T','J','Q','K']
suits = ['\u2660','\u2661','\u2662','\u2663']
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"))
      new(mod1(i,13), div(i-1,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
struct Hand
  cards::Array{Card,1}
  # constructors
  Hand(cards::Array{Card,1}) = new(cards)
  Hand(cards::Array{String,1}) = new(map(Card,cards))
  Hand(s::String) = new(map(Card,map(String,split(s,','))))
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 isFullHouse(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 text file in jupyter and copy-paste the above module into the empty file. It will need to be called PlayingCards.jl and should be in the same directory as the file you’re working on. Note: in most editors, it is important to have the .jl suffix on the filename so the coloring/formatting is correct for the file.
To load this module, we will do:
includet("PlayingCards.jl")
using .PlayingCards
where the includet function is in the Revise package. It includes the file and the t stands for tracking. What will happen if you update anything in the file and then make any call to those that are in the package, you will get the new revised code without restarting the kernel or reloading the package.
Here are some things to note about the module:
  • Line 3: import Base.show will import the show command from Base, and will be needed to develop good format for cards and hands.
  • Line 5: export Card, Hand, isFullHouse says what will be available if the module is loaded.
  • Staring on Line 10, the Card struct is defined as we saw in Chapter 12. We have added an additional constructor starting at line 28, that creates a card based on a string.
  • Staring on Line 39, the Hand struct is defined as we saw in Chapter 12. This one is a bit different in that chapter because we have added to additional constructors.
  • the functions Base.show functions for both Card and Hand are defined on lines 48 and 52. The command Base.show is automatically loaded, so these didn’t need to be exported.
  • the isFullHouse functions is defined starting on line 56.
Once you have saved and successfully loaded the module, let’s test it a bit. First let’s build some of the hands that we did in Chapter 20:
fh1 = Hand([Card(4,1),Card(4,3),Card(4,4),Card(7,1),Card(7,2)])
fh2 = Hand([Card(4,1),Card(4,3),Card(7,4),Card(7,1),Card(7,2)])
fh3 = Hand([Card(2,1),Card(4,3),Card(4,4),Card(7,1),Card(7,2)])
and the last hand should look like [2♠,4♢,4♣,7♠,7♡].

Check Your Understanding 23.1.

(a)
Add the isOneSuit, hasRun, royalFlush, isTwoPair and runTrials functions from Chapter 20 to the module.
(b)
Add the royalFlush, isTwoPair and runTrials functions to be exported (top of the module).
(c)
Create a hand that is a Royal Flush poker hand and one that is not.
(d)
Create a hand that is a Two Pair poker hand and one that is not.
(e)
Test that the runTrials function is working by testing the functions.

Section 23.3 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,4),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 Test Passed.
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. Add the following to the Playing Cards module.
isequal(x::Card,y::Card) = x.rank==y.rank && x.suit==y.suit
and test if two cards are the same with
@test isequal(Card(2,3),Card(28))
and this returns Test Passed.

Check Your Understanding 23.2.

Write a test that the isFullHouse method is working. To do this:
(a)
Create a hand that is a full house, called h1. Run @test isFullHouse(h1).
(b)
Create another hand called h2 that is a full house and test it.
(c)
Create a third hand called h3 that is not a full house and test it. To get the test to return passed, perform @test !isFullHouse(h3).

Section 23.4 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  Time
Legal Card Constructor |    3      3  0.2s
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.

Subsection 23.4.1 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 jupyter and then run it with:
include("test-playing-cards.jl")
and you should see the following results:
Test Summary:          | Pass  Total  Time
Legal Card Constructor |    4      4  0.0s
Test Summary:                   | Pass  Total  Time
Illegal Cards throws exceptions |    5      5  0.0s
Test Summary:          | Pass  Total  Time
Legal Hand Constructor |    3      3  0.0s
Test Summary:                  | Pass  Total  Time
Illegal Hand throws exceptions |    5      5  0.0s
Test Summary: | Pass  Total  Time
Card Tests    |    3      3  0.0s
Test Summary: | Pass  Total  Time
Full House    |    2      2  0.0s
indicating that all tests passed.
Check Your Understanding 23.3.
(a)
Create a test set with at least 3 hands to test for Royal Flush.
(b)
Create a test set with at least 3 hands to test for Two Pair.
(c)
Add these test sets to the test-playing-cards.jl file.

Section 23.5 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 function 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.

Section 23.6 A Rootfinding Module

We now develop a rootfinding module based on what we’ve developed in Chapter 10, Chapter 11 and Chapter 12. We will pull a few of the structs and functions in as:
module Rootfinding
using ForwardDiff
import Base.show
export Root, newton
struct Root
  root::Float64    #  approximate value of the root
  x_eps::Float64   #  estimate of the error in the x variable
  f_eps::Float64   #  function value at the root f(root)
  num_steps::Int   #  number of steps the method used
  converged::Bool  #  whether or not the stopping criterion was reached
  max_steps::Int   #  the maximum number of steps allowed
end
function Base.show(io::IO,r::Root)
  str = r.converged ? """The root is approximately x̂ = $(r.root)
    An estimate for the error is $(r.x_eps)
    with f(x̂) = $(r.f_eps)
    which took $(r.num_steps) steps""" :
    """The root was not found within $(r.max_steps) steps.
    Currently, the root is approximately x̂ = $(r.root).
    An estimate for the error is $(r.x_eps)
    with f(x̂) = $(r.f_eps)."""
  print(io,str)
end
function newton(f::Function, x0::Number; tol=1e-6, max_steps=10)
  tol > 0 || throw(ArgumentError("The parameter tol much be positive"))
  max_steps > 0 || throw(ArgumentError("The parameter max_steps much be positive"))
  local dx
  for i = 1:max_steps
    dx = f(x0)/ForwardDiff.derivative(f, x0)
    x0 -= dx
    abs(dx) < 1e-6 && return Root(x0, dx, f(x0), i, true, max_steps)
  end
  Root(x0, dx, f(x0), max_steps, false, 10)
end
end # module Rootfinding
Recall that once this is saved then enter:
include("Rootfinding.jl")
using .Rootfinding
and now we can use these functions:
root = newton(x -> x^2-5,1)
returns
The root is approximately x̂ = 2.2360688956433634
An estimate for the error is 9.18143385206549e-7
with f(x̂) = 4.106063730802134e-6
which took 4 steps

Subsection 23.6.1 A Test Suite for Rootfinding

One of the themes of the text is knowing when computational algorithms succeed and fail. We have seen throughout a few chapters now that Newton’s method fails for functions without roots. It is important to check how well it is working.
In an exercise in Chapter 11, you were asked to try to find \(\sqrt{10}\text{.}\) We will do this formally now with a test. Recall, if you haven’t yet, enter using Test.
If we want to test Newton’s method, we need to choose a function that has a root that we know. You may think that since \(f(x)=x^2-2\) has a root at \(\sqrt{2}\text{,}\) we can test that. Let’s try.
A simple test may be
val = newton(x -> x^2-2,1)
@test val.root == sqrt(2)
which results in Test Failed.
The problem occurs when checking equality between two floating-point numbers. You can see in the error the two values and although they are identical for about 10 digits, they are not exactly the same. In order for any pairs of numbers to be equal, all bits must be the same and that is hard to achieve with floating points.
Instead, we will test for approximate equality.
val = newton(x -> x^2-2,1)
@test abs(val.root-sqrt(2)) < 1e-6
which returns Test Passed
Alternatively, we can use the nice approximate test of
@test val.root≈sqrt(2)
where the symbol can be entered with typing \approx then hitting TAB.
We also want to make sure that it returns appropriately when there is no root. We’ll return to the function \(f(x)=x^2+1\) and look for a root. We will build up a test set to test a number of things:
@testset "function with no root" begin
  val = newton(x -> x^2+1,2)
  @test !val.converged
  @test val.num_steps == val.max_steps
end
returns
Test Summary:         | Pass  Total  Time
function with no root |    2      2  0.0s