Description
Haskell exercises as complete projects
What we have now
Currently we are providing most of the exercises as single files containing tests, written with HUnit
or QuickCheck
.
This is the way it is for historic reasons, but we should discuss if it is the best approach to follow.
Advantages
- As simple as it gets.
- It's very flexible, if you know what you are doing.
Disadvantages
- Needs GHC and all the dependencies previously installed.
- Leaves the problem of dealing with cabal hell to the users.
- Demands exercises compatible with multiple GHC versions and libraries.
- Automatic tests are complex and do not necessarily reflect the user's environment.
- Forces us to keep an updated list of used packages in
INSTALLATION.md
andREADME.md
. - It makes harder to write beautiful tests and sample solutions, because they usually depend on multiple packages, newer compiler features and modern libraries.
What we should have
I don't think the state of things is desirable. Ideally, I would love to:
- Make users' lives easier, leaving them just solving the exercises, without caring about compiler versions, libraries or anything else.
- Allow contributors to create beautifully written tests and sample solutions, using a lot of modern libraries and compiler extensions.
- Optionally provide benchmarks for some exercises, so that advanced users could easily check performance.
- Minimize maintenance and simplify automated tests, checking only for the current and next version.
Sounds good? Read on!
Alternative solution
Let's use leap
as an example. It could have those files:
stack.yaml
packages.yaml
src/LeapYear.hs
test/LeapTest.hs
bench/LeapBench.hs
The benchmark is here just to illustrate that it's possible to have one. I would not recommend using this functionality so soon because of commercialhaskell/stack#1870
stack.yaml
resolver: lts-6.3
stack.yaml
has just the resolver that we would be using for all exercises. This means ghc-7.10.3 and a frozen, curated set of packages that work well with it.
packages.yaml
name: leap
dependencies:
- base
library:
exposed-modules: LeapYear
source-dirs: src
dependencies:
tests:
test:
main: LeapTest.hs
source-dirs: test
dependencies:
- leap
- tasty
- tasty-hunit
benchmarks:
bench:
main: LeapBench.hs
source-dirs: bench
dependencies:
- leap
- criterion
package.yaml
lists all the dependencies used by the solution, tests and benchmarks. It's a slimmed down and much more readable version of a .cabal
file. This format is used by hpack and stack.
test/LeapTest.hs
import Test.Tasty (defaultMain, testGroup)
import Test.Tasty.HUnit (testCase, (@?=) )
import LeapYear (isLeapYear)
main :: IO ()
main = defaultMain tests
where
tests = testGroup "isLeapYear tests" $ test <$> cases
test (label, year, expected) = testCase label (isLeapYear year @?= expected)
cases = [ ("leap year" , 1996, True )
, ("standard and odd year" , 1997, False)
, ("standard even year" , 1998, False)
, ("standard nineteenth century", 1900, False)
, ("standard eighteenth century", 1800, False)
, ("leap twenty fourth century" , 2400, True )
, ("leap y2k" , 2000, True ) ]
test/LeapTest.hs
contains the tests. Using tasty
with tasty-hunit
allow us to write test that are at the same time shorter and more readable. Note that we used <$>
without importing Control.Applicative
, because we are sure that in lts-6.3 it will be already imported in Prelude
. We don't need to care about compatibility with older GHCs.
bench/LeapBench.hs
import LeapYear (isLeapYear)
import Criterion.Main (bench, bgroup, defaultMain, whnf)
main = defaultMain [ bgroup "isLeapYear" $ makeBench <$> years]
where
makeBench x = bench (show x) $ whnf isLeapYear x
years = [1900, 2000, 2001, 2004]
bench/LeapBench.hs
can implement a benchmark, so if an advanced user cares about the performance of his/her implementation, it can be easily run with stack bench
. Here we used criterion, and we don't need to worry about users having to install it, because stack will take care of that if the user decides to run it.
src/LeapYear.hs
module LeapYear (isLeapYear) where
isLeapYear :: Integer -> Bool
isLeapYear year = undefined
src/LeapYear.hs
is just a stub solution, so that the user knows exactly what needs to be implemented. See #181 for a discussion about why.
Running the tests
This is where this solution shines. The user just needs to run:
$ stack test
Stack will download and install all the dependencies needed, including the compiler, and run the tests.
Maintenance
- There would be no need to keep updated package lists in
INSTALLATION.md
andREADME.md
. .travis.yml
could be greatly simplified. We would probably only test against our current resolver and the latest lts or nightly, to know earlier about any changes needed to upgrade to the newest snapshots.- We could simply remove all those files:
_test/
_test/bootstrap.sh
_test/check-exercises.hs
_test/dependencies
_test/dependencies.txt
_test/stackalize
Disadvantages
- Needs stack previously installed.
- Demands the user to list additional packages it wants to use in an exercise in
packages.yaml
(stack handles downloading and installation).
Migration
I think that everything we would need is already in place:
- We have all the dependencies in
dependencies.txt
- Stack is already being used in Travis-CI
- Some tracks (C, rust, scala) already use sub-directories, so it should work.
- Stack is currently the recommended way to install Haskell, and it also comes installed in the newer versions of the Haskell Platform.
So... Why not?
Is there any solid reason for us to keep things the way they are?
Are we following this path because it is the best or just because we are comfortable with it?
If you read everything without sleeping, please leave a comment or at least add a reaction.
👍 👎 😄 🎉 😕 ❤️ ❓