4.5

Easy to Test

In the Pure Functions section, we discovered that it’s much easier to check an Elm function for correctness than one written in a language that lacks purity. In this section, we will find out why that’s the case. We will also write quite a few tests to make sure that our implementation of various functions in the RippleCarryAdder module is correct. First, we need to learn how to write tests in Elm.

Setting Up Our Project for Tests

Before we can write tests in Elm, we need to first set some things up in our project using a tool called elm-test. Let’s install it by running the following command from the beginning-elm directory in terminal.

$ npm install -g elm-test

In the Installing Node.js section, we learned that one of the reasons we had to install Node.js was so that we could use NPM (Node.js Package Manager) to install tools and packages needed for building front-end web applications. A good number of tools in the Elm ecosystem are also distributed as NPM packages. elm-test is one of them. It allows us to run Elm tests from the terminal or a continuous integration server.

Now that elm-test has been installed, we can use it to set up our project for writing tests. Run the following command from the beginning-elm directory in terminal.

$ elm-test init

The above command creates a new directory called tests inside beginning-elm. The figure below shows the contents of that directory.

Let’s go through them one by one.

elm-package.json

It’s best practice to use a separate elm-package.json file for tests. This allows us to keep the dependencies for tests and application code separate. The elm-package.json file located at the root directory (beginning-elm) should only contain packages that are meant to be used in a production environment. The tests/elm-package.json contains packages such as elm-community/elm-test that are meant to be used only in a test environment.

The elm-community/elm-test package is different from the elm-test command-line tool we used earlier. The former is an Elm package for writing tests whereas the latter is a NPM package for running those tests in the terminal.

Let’s see what’s inside the tests/elm-package.json file.

{
    "version": "1.0.0",
    "summary": "Test Suites",
    "repository": "https://github.com/user/project.git",
    "license": "BSD3",
    "source-directories": [
        "..",
        "../elm-examples",
        "."
    ],
    "exposed-modules": [],
    "dependencies": {
        "eeue56/elm-html-test": "5.1.3 <= v < 6.0.0",
        "elm-community/elm-test": "4.0.0 <= v < 5.0.0",
        "elm-lang/core": "5.0.0 <= v < 6.0.0",
        "elm-lang/html": "2.0.0 <= v < 3.0.0",
        "elm-lang/http": "1.0.0 <= v < 2.0.0"
    },
    "elm-version": "0.18.0 <= v < 0.19.0"
}

It looks very similar to beginning-elm/elm-package.json. Here it is again for comparison:

{
    "version": "1.0.0",
    "summary": "A sample app for learning web application development with Elm.",
    "repository": "https://github.com/user/project.git",
    "license": "BSD3",
    "source-directories": [
        ".",
        "elm-examples"
    ],
    "exposed-modules": [],
    "dependencies": {
        "elm-lang/core": "5.0.0 <= v < 6.0.0",
        "elm-lang/html": "2.0.0 <= v < 3.0.0",
        "elm-lang/http": "1.0.0 <= v < 2.0.0"
    },
    "elm-version": "0.18.0 <= v < 0.19.0"
}

The test version of elm-package.json differs from the one used in production in three ways:

Difference #1: It adds .. to the source-directories section. .. represents a parent directory, whereas . represents the current directory. elm-test adds both of them to the source-directories section so that any .elm file located inside the tests or beginning-elm directory can be accessed from our tests.

Difference #2: It prepends ../ to elm-examples in the source-directories section because the tests directory is inside beginning-elm. That means to get to elm-examples, which is also inside beginning-elm, it has to go up one directory. ../elm-examples essentially means go up to the parent directory and look for elm-examples.

Difference #3: It adds two new packages:

The Elm version used in both test and production versions must match. Packages that are common in both also need to have the same versions. Otherwise, the behavior verified in tests may not match the one exhibited in production.

elm-stuff

The elm-test init command automatically installs all packages listed in tests/elm-package.json and puts them in the tests/elm-stuff directory.

Example.elm

The elm-test init command also creates a sample test file called Example.elm. We’ll be using this file to understand how Elm tests work. Here is how it looks:

module Example exposing (..)

import Expect exposing (Expectation)
import Fuzz exposing (Fuzzer, int, list, string)
import Test exposing (..)


suite : Test
suite =
    todo "Implement our first test. See http://package.elm-lang.org/packages/elm-community/elm-test/latest for how to do this!"

It imports three modules:

  • Expect allows us to specify how we expect our code to behave.
  • Fuzz allows us to write fuzz tests.
  • Test allows us to create and manage tests.

Example.elm also includes a placeholder for implementing our first test inside the value called suite. In software development, a test suite is a collection of tests that verify a program’s behavior.

A test file uses the exact same syntax as a code file. The functions and other values in a test file follow all the formatting rules that apply to normal functions and values we’ve seen elsewhere.

The code above shows that suite returns a value of type Test. Since we haven’t covered type system yet, we’ll continue to omit type annotations in this section as well to avoid any confusion.

.gitignore

elm-test init may also create a file called .gitignore which specifies files and directories that Git should ignore. Since we aren’t using Git to track our project, you can safely delete it.

Writing Our First Unit Test

Now that all the packages needed for writing and running tests are installed and imported, we’re ready to write our first test. Let’s replace the placeholder implementation inside suite in Example.elm with a real unit test.

suite =
    test "two plus two equals four"
        (\_ -> Expect.equal 4 (2 + 2))

To execute this test all we have to do is run elm-test from the beginning-elm directory in terminal.

$ elm-test

You should see an output like this:

Reproducing a Test Result

Notice the following instructions in the above output.

To reproduce these results, run: elm-test --fuzz 100 --seed <seed-value>

It’s meant for a fuzz test. We’ll go over what this instruction means in the A Simple Fuzz Test section later in this chapter.

Now let’s understand how the test we wrote earlier actually works. The following diagram explains the syntax for writing a unit test in Elm.

The test function requires us to wrap our test in an anonymous function. That’s why we can’t just write our test like this:

test "two plus two equals four"
    (Expect.equal 4 (2 + 2))

This has to do with the internal implementation of elm-test. To make things easier, elm-test wants to have control over when and how tests get executed. By putting a test inside a function we’re giving up that control because when we define a function, Elm doesn’t execute it immediately. It waits for some other code to apply it first.

In the Function section, we learned that every function in Elm, including anonymous functions, must receive at least one argument. So what argument does elm-test pass to the anonymous function we used earlier to wrap our test? To find out, let’s look at the test function’s type signature.

As it turns out, the argument passed to our anonymous function is ().

Unit Type

Sine the argument given to the anonymous function never gets used inside the test, elm-test doesn’t even bother to pass an argument that can be used in a meaningful way. It just passes an empty value represented by (), which means an empty tuple in Elm. It’s also known as the unit type. It’s often used to represent an empty value. When we enter () in the repl, its type is also displayed as ().

> ()
() : ()

We will cover types in detail in the Type System section, but for now think of it as something that represents a collection of values that have similar properties. For example, the type Int represents numbers with no fractional part: -1, 0, 1, 2, etc. And the type Bool represents logical values True and False.

Elm uses the () symbol to represent both value and type. There can only ever be one value of the unit type: (). That’s because the number of elements in a tuple determines its type. For example, the following two tuples have completely different types even though they both contain the same type of values.

> ( 1, 2 )
(1,2) : ( number, number1 )

> ( 1, 2, 3 )
(1,2,3) : ( number, number1, number2 )

For two tuples to be of the same type, they must contain the same number and type of values. In contrast, two lists containing the same type of values but different lengths have the exact same type.

> [ 1, 2 ]
[1,2] : List number

> [ 1, 2, 3 ]
[1,2,3] : List number

() vs _

It’s common in Elm to use an underscore (_) when we want to ignore a parameter. Here’s a simple anonymous function that always returns 0:

> List.map (\_ -> 0) [ 1, 2, 3 ]
[0,0,0] : List number

> List.map (\_ -> 0) [ 'a', 'b', 'c' ]
[0,0,0] : List number

If you don’t remember how the List.map function works, go ahead and refresh your memory by reviewing the Mapping a List section.

The (\_ -> 0) anonymous function doesn’t use its parameter to compute a return value. So instead of giving a name to the parameter, it just uses _.

On the surface, _ and () seem to be used for the same purpose: ignoring a parameter. But if we look deeper, there’s a subtle difference. Although (\_ -> 0) ignores its parameter, it allows us to pass any type of value to it. In above example, we first gave it numbers and then characters. Will it still allow us to pass any type of value if we switched to () from _? Let’s find out.

> List.map (\() -> 0) [ 1, 2, 3 ]

------------------ TYPE MISMATCH ------------------------
The 2nd argument to function `map` is causing a mismatch.

3|   List.map (\() -> 0) [ 1, 2, 3 ]
                         ^^^^^^^^^^^
Function `map` is expecting the 2nd argument to be:

    List ()

But it is:

    List number

No it doesn’t. (\() -> 0) only accepts a value of type (), nothing else.

> List.map (\() -> 0) [ (), (), () ]
[0,0,0] : List number

_ means any value, whereas () means only unit type value. By replacing _ with (), we severely limited our input options. If you just want to ignore a parameter without limiting the types of input values that can be passed to your function, then use _. On the other hand, if no meaningful argument will ever get passed to your function, then use (). Why then are we using _ instead of () in our test?

test "two plus two equals four"
    (\_ -> Expect.equal 4 (2 + 2))

That’s because we know elm-test only ever passes the unit type to our anonymous function. Therefore, whether we use () or _ doesn’t really make any difference and since _ is easier to type we went with that.

Failed Test

Let’s change the expected value in our test to find out how the output looks when a test fails. Replace 4 with 3 in Example.elm.

suite =
    test "two plus two equals four"
        (\_ -> Expect.equal 3 (2 + 2))

Run elm-test from the beginning-elm directory in terminal and you should see the following output.

The output shows the actual value before the expected value, but in our test the actual value is placed after the expected value. Can we rewrite our test so that it closely resembles the output? Yes we can. Update the test in Example.elm like this:

suite =
    test "two plus two equals four"
        (\_ -> (2 + 2) |> Expect.equal 3)

The |> operator passes the result from the expression on the left as the last argument to the function on the right. If you’re fuzzy on how |> works, go back and review the Forward Function Application section. Now the output closely resembles our test.

(2 + 2) |> Expect.equal 3  -- Our test

Because of this resemblance, it’s easier to notice that our expected result is incorrect. When we wrote our test like this:

Expect.equal 3 (2 + 2)

Our test code wasn’t in the same order as our test output, so it was harder to visually compare them and find the source of the error. Change the expected result back to 4 to make sure that the test is passing.

|> improves the readability even more in a complex test. Here’s an example:

guardianNames =
    test "only 2 guardians have names with less than 6 characters"
        (\_ ->
            let
                guardians =
                    [ "Star-lord", "Groot", "Gamora", "Drax", "Rocket" ]
            in
                guardians
                    |> List.map String.length
                    |> List.filter (\x -> x < 6)
                    |> List.length
                    |> Expect.equal 2
        )

Add that test to the bottom of Example.elm and run elm-test from the beginning-elm directory in terminal. All tests should pass.

The anonymous function given to the test function as an argument is like any other function, except it doesn’t have a name. We can use anything inside it that’s allowed in a normal function including a let expression.

The test inside guardianNames verifies that there are only two guardians whose names have less than six characters in them. We applied three different transformations to the original list to get to the final number. As you can see, the |> operator significantly improved the readability of our test. When a test contains a complex computation like this, the anonymous function tends to take the following shape:

\() ->
    someComputation
        |> producing
        |> actualValue
        |> Expect.equal expectedValue

If we didn’t use the |> operator, our test would be very hard to read because English reads left to right, but the function logic starts on the far right in this instance. Plus you would have to be careful to evaluate all the parentheses in the right order.

Expect.equal 2
    (List.length (List.filter (\x -> x < 6) (List.map String.length guardians)))

Maintaining a Suite of Well Written Tests

When a test fails, it could mean two things: either we made a mistake writing the test or the logic inside the function (or expression) under test is incorrect. If the logic is incorrect, we obviously have to fix it. If the test itself is incorrect, we should either fix or remove it. In our first test, the expression 2 + 2 will never produce 3. Therefore, the test is incorrect.

A suite of well written tests play a critical role in refactoring the existing code. It gives us the confidence to rearrange our code so that we can make it easier to read and maintain. If we make a mistake, a test will catch it. For that reason, it’s important to keep our test suite clean by removing any incorrectly written failing tests.

Refactoring
“Refactoring is the process of changing a software system in such a way that it does not alter the external behavior of the code yet improves the internal structure.” - Martin Fowler

Improving Our Tests with <|

The |> operator improved the readability of our tests quite a bit. We can make them even better by replacing the parentheses around anonymous functions with <|. Update the tests in Example.elm like this:

suite =
    test "two plus two equals four" <|
        \_ -> (2 + 2) |> Expect.equal 4


guardianNames =
    test "only 2 guardians have names with less than 6 characters" <|
        \_ ->
            let
                guardians =
                    [ "Star-lord", "Groot", "Gamora", "Drax", "Rocket" ]
            in
                guardians
                    |> List.map String.length
                    |> List.filter (\x -> x < 6)
                    |> List.length
                    |> Expect.equal 2

<| is useful for avoiding parentheses. Here is another example from the Backward Function Application section.

main =
    Html.text (toString (add 5 (multiply 10 (divide 30 10))))

We can replace those pesky parentheses with <| like this:

main =
    Html.text <| toString <| add 5 <| multiply 10 <| divide 30 10

Ah! Much better!

Grouping Similar Tests with describe

Let’s rename suite to additionTests and add one more test to it in Example.elm.

additionTests =
    describe "Addition"
        [ test "two plus two equals four" <|
            \_ -> (2 + 2) |> Expect.equal 4
        , test "three plus four equals seven" <|
            \_ -> (3 + 4) |> Expect.equal 7
        ]

Run elm-test from the beginning-elm directory in terminal and you should see three passing tests. The describe function is used to group related tests. Notice that we didn’t use <| on the line that contains describe. That’s because describe doesn’t need to wrap its second argument (a list of tests) in parentheses.

More Expectations

The Expect module also provides functions for expressing expectations other than equality. Let’s find out what they are. Add the following tests to the bottom of Example.elm.

comparisonTests =
    describe "Comparison"
        [ test "2 is not equal to 3" <|
            \_ -> 2 |> Expect.notEqual 3
        , test "4 is less than 5" <|
            \_ -> 4 |> Expect.lessThan 5
        , test "6 is less than or equal to 7" <|
            \_ -> 6 |> Expect.atMost 7
        , test "9 is greater than 8" <|
            \_ -> 9 |> Expect.greaterThan 8
        , test "11 is greater than or equal to 10" <|
            \_ -> 11 |> Expect.atLeast 10
        , test "a list with zero elements is empty" <|
            \_ ->
                (List.isEmpty [])
                    |> Expect.true "expected the list to be empty"
        , test "a list with some elements is not empty" <|
            \_ ->
                (List.isEmpty [ "Jyn", "Cassian", "K-2SO" ])
                    |> Expect.false "expected the list not to be empty"
        ]

Run elm-test from the beginning-elm directory in terminal and you should see that all tests have passed.

The tests above are self-explanatory except the last two. Expect.true and Expect.false require us to pass a string as the first argument. When a test fails, that string is used to explain why the test failed. Make the test that uses Expect.true fail by passing a non-empty list like this:

comparisonTests =
    describe "Comparison"
        .
        .
        , test "a list with zero elements is empty" <|
            \_ ->
                (List.isEmpty [ "The Ancient One" ])
                    |> Expect.true "expected the list to be empty"
        .
        .

Run elm-test once again and you should see the string we gave to Expect.true being used as an explanation for why the test failed.

Remove "The Ancient One" from the list in above test so that it passes again. The Expect module provides even more ways to express expectations. You can learn all about them here.

Now that we’re familiar with how to write tests in Elm, let’s turn our attention to testing various functions in the RippleCarryAdder.elm file we created in the Solving Complex Problems with Simple Functions section.

Testing the inverter Function

Let’s start with the inverter function which is the easiest one to test. To refresh your memory, here’s how the function looks:

inverter a =
    case a of
        0 ->
            1

        1 ->
            0

        _ ->
            -1

Create a new file called RippleCarryAdderTests.elm in the beginning-elm/tests directory and add the code below to it.

module RippleCarryAdderTests exposing (..)

import Expect exposing (Expectation)
import Fuzz exposing (..)
import Test exposing (..)
import RippleCarryAdder exposing (..)


inverterTests =
    describe "Inverter"
        [ test "output is 0 when the input is 1" <|
            \_ ->
                inverter 0
                    |> Expect.equal 1
        , test "output is 1 when the input is 0" <|
            \_ ->
                inverter 1
                    |> Expect.equal 0
        ]

Each test case in inverterTests represents a row from an inverter’s truth table we saw in the Inverter section.

Run the following command from beginning-elm directory in terminal to execute the tests inside inverterTests.

$ elm-test tests/RippleCarryAdderTests.elm

elm-test allows us to run tests in a specific file. All we have to do is give it a filename. When we run elm-test without any arguments, it goes through all .elm files in the beginning-elm/tests directory and runs every test in them. For the remainder of this section, we’ll be focusing on testing the RippleCarryAdder module. Therefore, going forward we’ll be running the tests in RippleCarryAdderTests.elm only.

Testing the andGate Function

Testing the andGate function is very similar to testing inverter. We just need to account for all four possible outcomes. Here’s how the andGate function looks:

andGate a b =
    Bitwise.and a b

Add the following tests to the bottom of RippleCarryAdderTests.elm.

andGateTests =
    describe "AND gate"
        [ test "output is 0 when both inputs are 0" <|
            \_ ->
                andGate 0 0
                    |> Expect.equal 0
        , test "output is 0 when the first input is 0" <|
            \_ ->
                andGate 0 1
                    |> Expect.equal 0
        , test "output is 0 when the second input is 0" <|
            \_ ->
                andGate 1 0
                    |> Expect.equal 0
        , test "output is 1 when both inputs are 1" <|
            \_ ->
                andGate 1 1
                    |> Expect.equal 1
        ]

Like inverterTests, each test in andGateTests represents a row from an AND gate’s truth table.

Run elm-test tests/RippleCarryAdderTests.elm from the beginning-elm directory in terminal to execute the tests inside andGateTests.

Exercise 4.5.1

Using andGateTests as a reference, write tests for the orGate function whose definition looks like this:

orGate a b =
    Bitwise.or a b

Here’s the truth table for an OR gate:

After you’ve written the tests, don’t forget to run them to make sure that they all pass.

Testing the halfAdder Function

A half adder is slightly more complex because it produces two outputs: the sum and carry-out.

halfAdder a b =
    let
        d =
            orGate a b

        e =
            andGate a b
                |> inverter

        sumDigit =
            andGate d e

        carryOut =
            andGate a b
    in
        { carry = carryOut
        , sum = sumDigit
        }

Even though it produces multiple outputs, the tests for the halfAdder function look very similar to the andGate tests. Add the following tests to the bottom of RippleCarryAdderTests.elm.

halfAdderTests =
    describe "Half adder"
        [ test "sum and carry-out are 0 when both inputs are 0" <|
            \_ ->
                halfAdder 0 0
                    |> Expect.equal { carry = 0, sum = 0 }
        , test "sum is 1 and carry-out is 0 when the 1st input is 0 and the 2nd input is 1" <|
            \_ ->
                halfAdder 0 1
                    |> Expect.equal { carry = 0, sum = 1 }
        , test "sum is 1 and carry-out is 0 when the 1st input is 1 and the 2nd input is 0" <|
            \_ ->
                halfAdder 1 0
                    |> Expect.equal { carry = 0, sum = 1 }
        , test "sum is 0 and carry-out is 1 when both inputs are 1" <|
            \_ ->
                halfAdder 1 1
                    |> Expect.equal { carry = 1, sum = 0 }
        ]

Each test case in halfAdderTests represents a row from the half adder’s truth table.

Run elm-test tests/RippleCarryAdderTests.elm from the beginning-elm directory in terminal to execute the tests inside halfAdderTests.

Testing the fullAdder Function

Here’s how the fullAdder function looks:

fullAdder a b carryIn =
    let
        firstResult =
            halfAdder b carryIn

        secondResult =
            halfAdder a firstResult.sum

        finalCarry =
            orGate firstResult.carry secondResult.carry
    in
        { carry = finalCarry
        , sum = secondResult.sum
        }

It takes one more input (carryIn) compared to the halfAdder function. Therefore, we have to account for all eight combinations of inputs as shown in the truth table below.

Add the following tests to the bottom of RippleCarryAdderTests.elm.

fullAdderTests =
    describe "Full adder"
        [ test "sum and carry-out are 0 when both inputs and carry-in are 0" <|
            \_ ->
                fullAdder 0 0 0
                    |> Expect.equal { carry = 0, sum = 0 }
        , test "sum is 1 and carry-out is 0 when both inputs are 0, but carry-in is 1" <|
            \_ ->
                fullAdder 0 0 1
                    |> Expect.equal { carry = 0, sum = 1 }
        , test "sum is 1 and carry-out is 0 when the 1st input is 0, the 2nd input is 1, and carry-in is 0" <|
            \_ ->
                fullAdder 0 1 0
                    |> Expect.equal { carry = 0, sum = 1 }
        , test "sum is 0 and carry-out is 1 when the 1st input is 0, the 2nd input is 1, and the carry-in is 1" <|
            \_ ->
                fullAdder 0 1 1
                    |> Expect.equal { carry = 1, sum = 0 }
        , test "sum is 1 and carry-out is 0 when the 1st input is 1, the 2nd input is 0, and the carry-in is 0" <|
            \_ ->
                fullAdder 1 0 0
                    |> Expect.equal { carry = 0, sum = 1 }
        , test "sum is 0 and carry-out is 1 when the 1st input is 1, the 2nd input is 0, and the carry-in is 1" <|
            \_ ->
                fullAdder 1 0 1
                    |> Expect.equal { carry = 1, sum = 0 }
        , test "sum is 0 and carry-out is 1 when the 1st input is 1, the 2nd input is 1, and the carry-in is 0" <|
            \_ ->
                fullAdder 1 1 0
                    |> Expect.equal { carry = 1, sum = 0 }
        , test "sum is 1 and carry-out is 1 when the 1st input is 1, the 2nd input is 1, and the carry-in is 1" <|
            \_ ->
                fullAdder 1 1 1
                    |> Expect.equal { carry = 1, sum = 1 }
        ]

Like all other tests we’ve written so far, the tests inside fullAdderTests represent rows from the full adder’s truth table. Run elm-test tests/RippleCarryAdderTests.elm from the beginning-elm directory in terminal to execute the tests inside fullAdderTests.

The test descriptions in fullAdderTests are too long. It makes reading the tests difficult. We can improve the readability by grouping similar tests inside a describe block. Rewrite fullAdderTests as shown below.

fullAdderTests =
    describe "Full adder"
        [ describe "when both inputs are 0"
            [ test "and carry-in is 0 too, then both sum and carry-out are 0" <|
                \_ ->
                    fullAdder 0 0 0
                        |> Expect.equal { carry = 0, sum = 0 }
            , test "but carry-in is 1, then sum is 1 and carry-out is 0" <|
                \_ ->
                    fullAdder 0 0 1
                        |> Expect.equal { carry = 0, sum = 1 }
            ]
        , describe "when the 1st input is 0, and the 2nd input is 1"
            [ test "and carry-in is 0, then sum is 1 and carry-out is 0" <|
                \_ ->
                    fullAdder 0 1 0
                        |> Expect.equal { carry = 0, sum = 1 }
            , test "and carry-in is 1, then sum is 0 and carry-out is 1" <|
                \_ ->
                    fullAdder 0 1 1
                        |> Expect.equal { carry = 1, sum = 0 }
            ]
        , describe "when the 1st input is 1, and the 2nd input is 0"
            [ test "and carry-in is 0, then sum is 1 and carry-out is 0" <|
                \_ ->
                    fullAdder 1 0 0
                        |> Expect.equal { carry = 0, sum = 1 }
            , test "and carry-in is 1, then sum is 0 and carry-out is 1" <|
                \_ ->
                    fullAdder 1 0 1
                        |> Expect.equal { carry = 1, sum = 0 }
            ]
        , describe "when the 1st input is 1, and the 2nd input is 1"
            [ test "and carry-in is 0, then sum is 0 and carry-out is 1" <|
                \_ ->
                    fullAdder 1 1 0
                        |> Expect.equal { carry = 1, sum = 0 }
            , test "and carry-in is 1, then sum is 1 and carry-out is 1" <|
                \_ ->
                    fullAdder 1 1 1
                        |> Expect.equal { carry = 1, sum = 1 }
            ]
        ]

They are much easier to read now. It’s recommended that you group similar tests inside a describe block whenever you can. We should always look for ways to improve the readability of our tests. In addition to verifying the behavior of our code, tests also act as a documentation describing how that code should behave. Therefore, improving the readability of our tests is just as important as improving the readability of our code.

We can nest as many describe blocks as we want. Here’s fullAdderTests rewritten again with multiple describe blocks:

fullAdderTests =
    describe "Full adder"
        [ describe "when both inputs are 0"
            [ describe "and carry-in is 0"
                [ test "both sum and carry-out are 0" <|
                    \_ ->
                        fullAdder 0 0 0
                            |> Expect.equal { carry = 0, sum = 0 }
                ]
            , describe "but carry-out is 1"
                [ test "sum is 1 and carry-out is 0" <|
                    \_ ->
                        fullAdder 0 0 1
                            |> Expect.equal { carry = 0, sum = 1 }
                ]
            ]
        , describe "when the 1st input is 0"
            [ describe "and the 2nd input is 1"
                [ describe "and carry-in is 0"
                    [ test "sum is 1 and carry-out is 0" <|
                        \_ ->
                            fullAdder 0 1 0
                                |> Expect.equal { carry = 0, sum = 1 }
                    ]
                , describe "and carry-in is 1"
                    [ test "sum is 0 and carry-out is 1" <|
                        \_ ->
                            fullAdder 0 1 1
                                |> Expect.equal { carry = 1, sum = 0 }
                    ]
                ]
            ]
        , describe "when the 1st input is 1"
            [ describe "and the 2nd input is 0"
                [ describe "and carry-in is 0"
                    [ test "sum is 1 and carry-out is 0" <|
                        \_ ->
                            fullAdder 1 0 0
                                |> Expect.equal { carry = 0, sum = 1 }
                    ]
                , describe "and carry-in is 1"
                    [ test "sum is 0 and carry-out is 1" <|
                        \_ ->
                            fullAdder 1 0 1
                                |> Expect.equal { carry = 1, sum = 0 }
                    ]
                ]
            ]
        , describe "when the 1st input is 1"
            [ describe "and the 2nd input is 1"
                [ describe "and carry-in is 0"
                    [ test "sum is 0 and carry-out is 1" <|
                        \_ ->
                            fullAdder 1 1 0
                                |> Expect.equal { carry = 1, sum = 0 }
                    ]
                , describe "and carry-in is 1"
                    [ test "sum is 1 and carry-out is 1" <|
                        \_ ->
                            fullAdder 1 1 1
                                |> Expect.equal { carry = 1, sum = 1 }
                    ]
                ]
            ]
        ]

If you run elm-test tests/RippleCarryAdderTests.elm from the beginning-elm directory in terminal, you’ll see the following error.

elm-test forces us to provide a unique description for each test that are on the same level. This applies to describe as well. This rule is actually good for detecting misplaced tests. The test cases inside the last describe "when the 1st input is 1" block should really be moved to the one above it. Let’s do that.

fullAdderTests =
    describe "Full adder"
        [ describe "when both inputs are 0"
            [ describe "and carry-in is 0"
                [ test "both sum and carry-out are 0" <|
                    \_ ->
                        fullAdder 0 0 0
                            |> Expect.equal { carry = 0, sum = 0 }
                ]
            , describe "but carry-out is 1"
                [ test "sum is 1 and carry-out is 0" <|
                    \_ ->
                        fullAdder 0 0 1
                            |> Expect.equal { carry = 0, sum = 1 }
                ]
            ]
        , describe "when the 1st input is 0"
            [ describe "and the 2nd input is 1"
                [ describe "and carry-in is 0"
                    [ test "sum is 1 and carry-out is 0" <|
                        \_ ->
                            fullAdder 0 1 0
                                |> Expect.equal { carry = 0, sum = 1 }
                    ]
                , describe "and carry-in is 1"
                    [ test "sum is 0 and carry-out is 1" <|
                        \_ ->
                            fullAdder 0 1 1
                                |> Expect.equal { carry = 1, sum = 0 }
                    ]
                ]
            ]
        , describe "when the 1st input is 1"
            [ describe "and the 2nd input is 0"
                [ describe "and carry-in is 0"
                    [ test "sum is 1 and carry-out is 0" <|
                        \_ ->
                            fullAdder 1 0 0
                                |> Expect.equal { carry = 0, sum = 1 }
                    ]
                , describe "and carry-in is 1"
                    [ test "sum is 0 and carry-out is 1" <|
                        \_ ->
                            fullAdder 1 0 1
                                |> Expect.equal { carry = 1, sum = 0 }
                    ]
                ]
            , describe "and the 2nd input is 1"
                [ describe "and carry-in is 0"
                    [ test "sum is 0 and carry-out is 1" <|
                        \_ ->
                            fullAdder 1 1 0
                                |> Expect.equal { carry = 1, sum = 0 }
                    ]
                , describe "and carry-in is 1"
                    [ test "sum is 1 and carry-out is 1" <|
                        \_ ->
                            fullAdder 1 1 1
                                |> Expect.equal { carry = 1, sum = 1 }
                    ]
                ]
            ]
        ]

Now if you run elm-test tests/RippleCarryAdderTests.elm once again from the beginning-elm directory in terminal, you should see that all tests have passed.

We shouldn’t go overboard with nesting describe blocks, though. Writing deeply-nested describe blocks could get tedious. That said, nesting does make the output read a little bit better when a test fails. If you try to make a test fail in the example above that has fewer describe blocks, the output will look like this:

Compare that to the output below that shows a failing test from the example that has deeply-nested describe blocks.

There are no set guidelines in Elm for how deep the nesting should go, so use whatever level of nesting you find most readable.

Exercise 4.5.2

See if you can improve the readability of tests in halfAdderTests by adding more describe blocks.

Testing the rippleCarryAdder Function

A 4-bit ripple carry adder has nine inputs and five outputs as shown in the truth table below.

That means there are 512 (2 ^ 9) different permutations of the inputs we can test for. That’s a lot of tests. So the question becomes, do we have to write tests to account for each member in the input set of a function? The answer depends on how much confidence do we have in our function. If we know what our function does is quite straightforward then we might not need as much testing, but if it’s fairly complex with many edge cases then we might need to write more tests to cover those edge cases. In any case, it’s rare to have a function that warrants 100% test coverage.

“I get paid for code that works, not for tests, so my philosophy is to test as little as possible to reach a given level of confidence.” - Kent Beck

One good thing about testing in Elm is that we can combine different types of tests to achieve the right level of confidence in our code. So far we’ve only written unit tests. Elm also offers another type of testing called fuzz testing. It’s a form of testing where the same test is run over and over with randomly generated inputs. In contrast, a unit test runs the code under test only once with one input scenario, and then verifies that the output is correct.

The rippleCarryAdder function is a good candidate for fuzz testing, but we’ll have to wait until the Fuzz Testing section to find out how to write those tests. For now, let’s write a few unit tests which will supplement the fuzz tests we will write later. Add the following tests to the bottom of RippleCarryAdderTests.elm.

rippleCarryAdderTests =
    describe "4-bit ripple carry adder"
        [ describe "given two binary numbers and a carry-in digit"
            [ test "returns the sum of those numbers and a carry-out digit" <|
                \_ ->
                    rippleCarryAdder 1001 1101 1
                        |> Expect.equal 10111
            ]
        , describe "when the 1st input is 1111, and the 2nd input is 1111"
            [ test "and carry-in is 0, the output is 11110" <|
                \_ ->
                    rippleCarryAdder 1111 1111 0
                        |> Expect.equal 11110
            , test "and carry-in is 1, the output is 11111" <|
                \_ ->
                    rippleCarryAdder 1111 1111 1
                        |> Expect.equal 11111
            ]
        , describe "when the 1st input is 0000, and the 2nd input is 0000"
            [ test "and carry-in is 0, the output is 0000" <|
                \_ ->
                    rippleCarryAdder 0 0 0
                        |> Expect.equal 0
            , test "and carry-in is 1, the output is 0001" <|
                \_ ->
                    rippleCarryAdder 0 0 1
                        |> Expect.equal 1
            ]
        ]

Run elm-test tests/RippleCarryAdderTests.elm from the beginning-elm directory in terminal to execute the tests inside rippleCarryAdderTests.

We wrote one test to communicate what the function actually does, and two more to test boundary cases. Unit tests are generally useful for testing a specific scenario that either represents an edge case or input boundary. The first test verifies that the rippleCarryAdder function generates an expected output when we add two binary numbers chosen by us at random. The last four tests verify that a correct output is generated when we add inputs with all 1s and 0s.

In the last two tests, we had to use single digits to represent the binary numbers with leading zeros because elm-format gets rid of zeros in the front when a file is saved. The tests still work because rippleCarryAdder pads zeros in the front if the input number doesn’t have four digits in it.

Summary

Throughout this section we saw many examples that proved our claim that writing tests for pure functions is quite straightforward. All we have to do is properly identify values from the input set, feed those values into the function repeatedly, and verify that the result belongs to the output set. We don’t need to worry about checking whether the function modified its state or caused any other side effects.

Back to top

New chapters are coming soon!

Sign up for the Elm Programming newsletter to get notified!

* indicates required
Close