little rust starter hint series: tests and TDD

Today we going to solve the Daily Challenge #47 - Alphabets in rust by applying test driven development (TDD) methodologies. Don't worry we will do very little steps and the full code is available as repo (link at the end).

For those that are familiar with rspec like testing frameworks as karma or mocha are known in Javascript I will provide a nice bonus and show how we can use the rspec syntax as candy to make our test code more expressive and readable.

the problem statement#

first things first, the problem statement from the challenge is

In today's challenge, you are asked to replace every letter with its position in the alphabet for a given string where 'a' = 1, 'b'= 2, etc. For example: alphabet_position("The sunset sets at twelve o' clock.") should return 20 8 5 19 21 14 19 5 20 19 5 20 19 1 20 20 23 5 12 22 5 15 3 12 15 3 11 as a string.

first test first#

let's start a new project by cargo new and let's prepare the source files

cargo new dev_challenge_47
cd dev_challenge_47 && mkdir -f tests
touch tests/alphabet_position_test.rs

So now let's start with translating the requirement into code, that is for now

for a given string where 'a' = 1

// test/alphabet_position_test.rs
use dev_challenge_47::alphabet_position;    // <-- we don't have this function yet

#[test]                                     // <-- how to define a test in rust
fn it_should_replace_the_a_with_1() {        // <-- we express the requirement as name
    let replaced = alphabet_position("a");
    assert_eq!(replaced, "1");              // <-- we assert that the both are equal
}

At this point the code will even not compile, so lets write the minimal implementation to get failing tests but a compiling project.

// src/lib.rs
pub fn alphabet_position(s: &str) -> String {
    String::from("Hello")
}

So we just return a predefined "Hello" String and that's it.

When we run now cargo test then we will get a lot of output but also

test it_should_ignore_non_characters ... FAILED

Nice, we got our first test failing.

let the test pass#

Let's go and fix our first test by changing the code. We could do this now by the very naive implementation that would be replace 'a' with '1' but that step I would skip and jump to the refactoring of that. Where we would use like ascii math: x - 'a' + 1

// src/lib.rs
pub fn alphabet_position(s: &str) -> String {
    s.chars()                             // <-- get an iterator over all chars
        .map(|x| -> u8 { x as u8 - 'a' as u8 + 1 })   // <-- substract the ascii value of 'a'
        .map(|x| -> String { x.to_string() })         // <-- convert the char to a String
        .collect::<Vec<String>>()                     // <-- collect a Vector of String
        .join(" ")                                    // <-- join the Strings by whitespace
}

Here are a lot of things to learn..

After rerun cargo test we see that our little test is now green.

next iteration#

as you might guess already, now we go back to our tests and write another one that. That is basically the cycle of TDD, we just write as many tests and code so that little by little we hit all the requirements. This time we add this test here:

// test/alphabet_position_test.rs
#[test]
fn it_should_replace_the_capital_a_with_1() {
    let replaced = alphabet_position("A");
    assert_eq!(replaced, "1");
}

Note: I'm so lazy that I don't want to rerun cargo test all over, this is why I use cargo watch -x check -x test that watches for changes on any file and will rerun the tests automatically. Checkout the docs to install that.

Now let's fix the failing test again

// src/lib.rs
pub fn alphabet_position(s: &str) -> String {
    s.to_lowercase()      // <-- adding this line
        .chars()
        .map(|x| -> u8 { x as u8 - 'a' as u8 + 1 })
        .map(|x| -> String { x.to_string() })
        .collect::<Vec<String>>()
        .join(" ")
}

Now we lower case it all, so that our math will not fail.

another iteration#

ok before we hit to the full sentence lets have a look at corner cases:

// test/alphabet_position_test.rs
#[test]
fn it_should_ignore_non_characters() {
    let replaced = alphabet_position("'a a. 2");
    assert_eq!(replaced, "1 1");
}

ok let's fix that test as well, by skipping all non letters:

pub fn alphabet_position(s: &str) -> String {
    s.to_lowercase()
        .chars()
        .filter(|x| x.is_alphabetic())        // <-- adding this line here
        .map(|x| -> u8 { x as u8 - 'a' as u8 + 1 })
        .map(|x| -> String { x.to_string() })
        .collect::<Vec<String>>()
        .join(" ")
}

alright, I couldn't think of any more corner cases, so it's always good to verify a full test case if you have one. Luckily we got one in the challenge description for free:

#[test]
fn it_should_replace_the_sentence() {
    let replaced = alphabet_position("The sunset sets at twelve o' clock.");
    assert_eq!(
        replaced,
        "20 8 5 19 21 14 19 5 20 19 5 20 19 1 20 20 23 5 12 22 5 15 3 12 15 3 11"
    );
}

And as you can see that test is as well green without any code changes. Well done :)

Specs ftw#

as mentioned above I want to show how we can rewrite our tests now with rspec like syntax to make it even more expressive. For this I will use a crate called speculate that comes with that handy candy. Let's add that dependency.

$ cat >> Cargo.toml
[dev-dependencies]
speculate = "0.1"
^D

For the sake of demonstration I will create a new test file (touch test/alphabet_position_spec.rs) and keep the old one. But you can also refactor the existing one.

// test/alphabet_position_spec.rs
use dev_challenge_47::alphabet_position;
use speculate::speculate;

speculate! {                  // <-- it's a macro but who cares
    describe "alphabet_position" {
        it "should replace 'a' with 1" {
            assert_eq!(alphabet_position("a"), "1");
        }
        it "should replace 'A' with 1" {
            assert_eq!(alphabet_position("A"), "1");
        }
        it "should ignore non characters" {
            assert_eq!(alphabet_position("'a a. 2"), "1 1");
        }
        it "should replace the full test sentence" {
            assert_eq!(
                alphabet_position("The sunset sets at twelve o' clock."),
                "20 8 5 19 21 14 19 5 20 19 5 20 19 1 20 20 23 5 12 22 5 15 3 12 15 3 11"
            );
        }
    }
}

That looks very nice to my Eyes, how about yours?

The only thing that I have to complain about is the out put when running cargo test, it's not that nice as you might be used to it from karma or others.

running 4 tests
test speculate_0::alphabet_position::test_should_ignore_non_characters ... ok
test speculate_0::alphabet_position::test_should_replace_A_with_1 ... ok
test speculate_0::alphabet_position::test_should_replace_a_with_1 ... ok
test speculate_0::alphabet_position::test_should_replace_the_full_test_sentence ... ok

You can still get it, and the code gets more expressive and readable, that is for me important enough to use this nice crate.

used versions:

$ cargo --version && rustc --version
cargo 1.37.0 (9edd08916 2019-08-02)
rustc 1.37.0 (eae3437df 2019-08-13)

Please don't forget to share your feedback, and let me know what was your learning if there was any.

The entire code can be found on github