little rust starter hint series: tests and tdd
Rust has a very easy way to test your code. Here you will learn the very basics how to start to solve a problem in a test driven style in rust.
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..
.chars()
returns an iterator of chars.map
apply a closure that does the ascii math..map
apply a closure that converts the char to a String.collect
collects all the entries to a vector.join
will join all string together into one
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.