Course Notes of Peter Staab

This is where Symbolic Computation class notes are found.

Follow me on GitHub

Chapter 14: Creating A Module and Performing Unit Tests

Return to all notes

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:

  1. Create a hand that is a full house, called h1. Run @test is_full_house(h1)
  2. Create another that is a full house and test it.
  3. 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 to is_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.