Soccer Table in Haskell: Releasing
Part 4: Testing and Packaging
Having setup the project in the first, implemented the library in the second, and created an interactive program in the third part of this series, the project shall be finished and, wrapped up for release in this fourth and final article, which covers the following ground:
- Unit tests covering the library portion of the code shall be implemented,
- documentation shall be written for the package and the exported library symbols,
- the dependency versions shall be pinned for reproducible builds, and
- the package shall be released to Hackage: The Haskell Package Repository.
Writing Unit Tests
There are various libraries for unit testing in Haskell. HUnit is the traditional option, similar to JUnit. Hspec is a more modern variant using a friendly DSL to specify test cases. I decided to use Hspec due to its somewhat lighter and more reader-friendly syntax.
The Cabal file needs to be extended by another section to configure the test suite (soccer-table.cabal):
test-suite soccer-table-test
import: compilation
type: exitcode-stdio-1.0
build-depends: soccer-table
, base
, containers
, hspec
hs-source-dirs: test
main-is: Spec.hs
The base and containers modules will be needed, too, the latter of which is for testing the resulting Map when grouping table entries by team. The test cases go into the test folder, with the main entry point being defined in the file Spec.hs, i.e. test/Spec.hs. The type defines a small execution wrapper for testing that will call the actual tests. (This option will display the tests result on the standard output and generate an exitcode for further processing by the caller.)
Hspec allows for automatic spec discovery so that files containing test cases do not need to be listed manually in Spec.hs—a task that is repetitive and easily gets forgotten. However, for our minor use case at hand, the test files are hard-wired into Spec.hs, which looks as follows:
module Main (main) where
import qualified Test.Hspec as HS (Spec, describe, hspec)
import qualified FormattingSpec
import qualified SoccerTableSpec
main :: IO ()
main = HS.hspec spec
spec :: HS.Spec
spec = do
HS.describe "Formatting" FormattingSpec.spec
HS.describe "SoccerTable" SoccerTableSpec.spec
Since the tests are run as an executable, the module is called Main and provides an according main function. Qualified imports are used to import Spec, describe, and hspec from the Hspec library. This is not idiomatic but didactic: the HS prefix points to functionality imported from Hspec.
The two test modules FormattingSpec and SoccerTableSpec, which contain the actual test code, are imported in their entirety. The main function delegates the test execution to the test runner function called spec, which lists the two test modules using describe. This function takes a descriptive string as its first parameter and a reference to each module’s spec function.
I will not show the entire test code, but just a short excerpt from test/SoccerTableSpec.hs:
module SoccerTableSpec (spec)
where
import qualified Test.Hspec as HS (Spec, describe, it, shouldBe)
import qualified SoccerTable as ST (GameResult(..), fromRawResult)
spec :: HS.Spec
spec = do
HS.describe "parse result" $ do
HS.it "parses a raw game result" $ do
ST.fromRawResult "Foo 3:2 Bar" `HS.shouldBe` Just ST.GameResult
{ ST.homeTeam = "Foo"
, ST.awayTeam = "Bar"
, ST.homeGoals = 3
, ST.awayGoals = 2
}
HS.it "won't parse an invalid game result" $ do
ST.fromRawResult "Hello, World!" `HS.shouldBe` Nothing
The test module only exports the spec function, which has the return type HS.Spec (again: qualified imports for didactic reasons). This spec function allows for grouping of related test cases, i.e. multiple invocations of the same function covering different outcomes. The individual test cases are then written as HS.it blocks, which also require a textual description.
The assertion is done using the HS.shouldBe function, which is inlined as an operator. The first test case covers a successful attempt at parsing a game result, while the second case passes nonsense to the ST.fromRawResult function, which shall result in Nothing.
The full test code can be found in the test folder.
Writing Documentation
Haddock is a tool that extracts annotated comments from Haskell source code and generates documentation from them. This documentation will be linked from Hackage. If an exported symbol provides no such comments, generic documentation is shown.
Haskell comments start with -- and go until the end of the line. Haddock comments for functions start with the prefix -- |, e.g. for formatTable (src/Formatting.hs):
-- |The 'formatTable' function formats the sorted list of table entreies as a league table.
formatTable :: [ST.TableEntry] -> String
The module itself requires multi-line comments starting with {-| and ending with -}:
{-|
Module: Formatting
Description: Formats League Tables
This module provides functionality to format soccer league tables.
-}
module Formatting (formatTable) where
-- …
It is a good idea to run Haddock locally in order to figure out if all the exported symbols are documented:
cabal install haddock
cabal haddock
Output (shortened):
Haddock coverage:
100% ( 8 / 8) in 'SoccerTable'
100% ( 2 / 2) in 'Formatting'
Cabal Package Documentation
The Cabal file itself has various fields for documentation:
extra-doc-files: references to documentation within the repositorycategory: specifies the domain of the softwaresynopsis: roughly explains what the software is doingdescription: describes the software in more detail in a multi-line comment
Those fields have been provided as follows in soccer-table.cabal:
extra-doc-files: README.md
category: utilities
synopsis: Create League Tables from Soccer Game Results
description:
The soccer-table program processes a directory of text files containing
soccer game results. This directory is given as a command line argument. Each
of the files must contain lines of the following form:
HOME-TEAM HOME-GOALS:AWAY-GOALS AWAY-TEAM
For example:
Manchaster City 0:0 Sunderland
Arsenal 0:3 Fulham
Manchester United 1:0 Crystal Palace
A league table is generated from those results and printed to the screen.
The README.md file contains usage instructions. Often a file called CHANGELOG.md is provided, which describes the changes done to the software for each released version. This is omitted here, but can be provided with a subsequent release that contains noteworthy changes.
License and Repository
No license was picked initially, so the field license had the value none. For such a trivial program, the MIT license looks appropriate to me. The full license text shall be provided in the repository. The field license-file shall reference to the respective file name:
license: MIT
license-file: LICENSE
The actual license text to be put into the LICENSE file can be obtained via the links on the SPDX website. Usually, copyright information such as author and year have to be provided manually.
Since the first point of contact for Haskell software is Hackage rather than GitHub, the repository shall be referred to with an additional section in the Cabal file:
source-repository head
type: git
location: https://github.com/patrickbucher/soccer-table-haskell
Multiple such sections could be defined, e.g. an additional mirror repository.
Pinning Versions
So far, dependencies such as base, containers, regex-base or hspec just have been specified plainly, i.e. without any version number. However, without any such restrictions, Cabal will just pick the most recent version automatically, which might introduce breaking changes later on. Therefore, the working versions used during development shall be pinned in the Cabal file.
There are various ways to define version constraints. The most convenient and flexible way is to use the ^>= operator together with a version number. This constraint allows for updated minor and bugfix versions that are still compatible. For packages defined using the Haskell Package Versioning Policy, the first two numbers (generation.major) must be the same, while the second two numbers (minor.patch) can be newer ones.
But how do I figure out which versions have been used so far? The answer is: using the following command:
cabal freeze
This command generates a file called cabal.project.freeze, which contains a list of all the dependencies used in the project (including transitive ones) with their exact version number. For reproducible builds, this file shall be included in the repository.
The versions picked from that file can be used in the Cabal file to pin the versions to the set of compatible options (showing only the build-depends portion of each section):
library
build-depends: base ^>= 4.19.0.0
, containers ^>= 0.6.0
, regex-base ^>= 0.94.0.0
, regex-posix ^>= 0.96.0.0
test-suite soccer-table-test
build-depends: soccer-table
, base ^>= 4.19.0.0
, containers ^>= 0.6.0
, hspec ^>= 2.11.17
executable soccer-table
build-depends: soccer-table
, base ^>= 4.19.0.0
, extra ^>= 1.8.0
, directory ^>= 1.3.0.0
, split ^>= 0.2.0
Pinning the dependencies can be automated using the cabal gen-bounds command:
cabal gen-bounds
Use it to double-check if bounds have been set for all dependencies, in which case it will print:
Resolving dependencies...
Congratulations, all your dependencies have upper bounds!
If those bounds are missing, as in this modified version of our library section…
library
import: compilation
exposed-modules: SoccerTable
, Formatting
build-depends: base
, containers
, regex-base
, regex-posix
hs-source-dirs: src
…the cabal gen-bounds command will output appropriate bounds “as a starting point”, as the verbose output states (omitted here):
Resolving dependencies...
[…]
base >= 4.19.2 && < 4.20,
containers >= 0.6.8 && < 0.7,
regex-base >= 0.94.0 && < 0.95,
regex-posix >= 0.96.0 && < 0.97,
Those constraints mean the same as the ones defined above using the ^>= operator.
Releasing to Hackage
With test cases, documentation, and dependency versions ready, the package can finally be published to Hackage. Before doing so, we need to make sure that the package is in a proper state:
cabal check
This could produce various warnings. If everything is fine, the output looks as follows:
No errors or warnings could be found in the package.
Those same checks are performed when uploading a package as a candidate. The browser variant also provides a preview of the web interface for the package.
Publishing a package requires an account. After signing up, you first need to be authorized as an uploader, for which the verification email describes all the necessary steps. (After writing a friendly but concise email and waiting for an hour or two, I was already authorized.)
Once you are authorized, the actual package, which is a .tar.gz archive, can be built:
cabal sdist
The artifact will end up in the dist/newstyle/dsist folder. Here, it is named soccer-table-0.1.0.0.tar.gz.
Now it can be uploaded as follows:
cabal upload dist-newstyle/sdist/soccer-table-0.1.0.0.tar.gz
You will be prompted interactively for your username and password. (See cabal help upload for alternative ways to provide your credentials.) The command will print the URL to the candidate version of the package release.
Once you are happy with the way the package is presented to the outside world, run the upload command once more, but this time with the additional --publish flag:
cabal upload --publish dist-newstyle/sdist/soccer-table-0.1.0.0.tar.gz
Now the package is available as soccer-table-0.1.0.0 on Hackage. After updating the local package index, it can be installed using Cabal:
cabal update
cabal install soccer-table
It is a good practice to tag the repository with the version defined in the Cabal file:
git tag v0.1.0.0
git push --tags
Conclusion and Next Steps
We went from a problem and the Cabal project boilerplate in the first article over the library and executable implementation in the second and third article to unit testing, documentation, and packaging in this fourth article.
Now it is time to use those acquired skills for more productive uses than calculating and printing soccer league tables. There is already some more useful code awaiting packaging and publication, e.g. my implementation of the board games reversi and spot.
The former example already contains an abandoned attempt to package Haskell code using Cabal. This failure let me frustrated with Haskell, which I then did not pick up again for half a year. Having passed that hurdle, my Haskell journey can go on now.
And so might yours, dear reader, which I would like to thank for reading this article series.