Test-driven development explained

Test-driven development is a practice that has been around for a long time. It's a simple concept: write tests before you write the code. This might sound counterintuitive, but it's a powerful way to ensure that your code is correct and that it does what it' s supposed to do.

The basic idea is to write a test that fails, then write the code that makes the test pass. This is often referred to as the "Red-Green-Refactor" cycle. First, you write a test that fails (the "Red" part). Then you write the code that makes the test pass (the "Green" part). Finally, you refactor the code to make it better (the "Refactor" part).

This might sound like a lot of extra work, but it has several benefits. First, it forces you to think about what you want the code to do before you write it. This can help you avoid writing unnecessary code, and it can help you catch bugs early. Second, it gives you a safety net for making changes to your code. If you have a good set of tests, you can make changes to your code with confidence, knowing that the tests will catch any problems. Finally, it can help you write better code.

When applied correctly, TDD will lead to a codebase that is more maintainable, more reliable, and easier to work with.

Writing tests in Rust

Rust has a built-in testing framework that makes it easy to write tests. You can write tests in the same file as your code, and you can use the #[test] attribute to mark a function as a test.

There is a section in the Rust book that discusses the mechanics of testing in Rust in more detail: How to Write Tests.

Let's look at a simple example. Suppose we want a function that parses a comma-separated string into a Vec of Strings. There is also the requirement that empty fields should be skipped. We can start by writing a test for this function:

fn main() {}


#[cfg(test)]
mod tests {
    /// This test validates that we can parse comma-separated input like: "Tom,Dick,,Harry" into
    /// a Vec of Strings. Empty fields should be skipped.
    #[test]
    fn can_parse_fields() {
        let fields = parse_fields("");
    }
}

What you will notice is that the parse_fields function does not exist yet. We can use the IDE to generate the function for us:

fn main() {}

fn parse_fields(csv: &str) -> Vec<String> {
    todo!()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// This test validates that we can parse comma-separated input like: "Tom,Dick,,Harry" into
    /// a Vec of Strings. Empty fields should be skipped.
    #[test]
    fn can_parse_fields() {
        let fields = parse_fields("");
    }
}

The todo! macro is a placeholder that will cause the test to fail with a message that the function is not yet implemented. If we run the test, we will see that it fails.

As you can see, we start by testing the simplest case we can think of; an empty string in this case.

So let's implement the function with just enough code to make the test pass:

fn main() {}

fn parse_fields(csv: &str) -> Vec<String> {
    vec![]
}

#[cfg(test)]
mod tests {
    use super::*;

    /// This test validates that we can parse comma-separated input like: "Tom,Dick,,Harry" into
    /// a Vec of Strings. Empty fields should be skipped.
    #[test]
    fn can_parse_fields() {
        let fields = parse_fields("");
        assert!(fields.is_empty())
    }
}

Okay! We have a successful test. Now we continue by adding the next logical case; be careful not to get excited and jump right to the final test case! Add just enough code to make the test pass.

fn main() {}

fn parse_fields(csv: &str) -> Vec<String> {
    if csv.is_empty() {
        return vec![];
    }

    vec![csv.to_string()]
}

#[cfg(test)]
mod tests {
    use super::*;

    /// This test validates that we can parse comma-separated input like: "Tom,Dick,,Harry" into
    /// a Vec of Strings. Empty fields should be skipped.
    #[test]
    fn can_parse_fields() {
        let fields = parse_fields("");
        assert!(fields.is_empty());

        let fields = parse_fields("Tom");
        assert_eq!(fields.len(), 1);
        assert_eq!(fields.first().unwrap(), "Tom");
    }
}

Even though these steps seem trivial, you have already identified the special case of an empty string that does not need any further processing.

Let's add the next test.

fn main() {}

fn parse_fields(csv: &str) -> Vec<String> {
    if csv.is_empty() {
        return vec![];
    }

    csv.split(',')
        .map(|s| s.to_string())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// This test validates that we can parse comma-separated input like: "Tom,Dick,,Harry" into
    /// a Vec of Strings. Empty fields should be skipped.
    #[test]
    fn can_parse_fields() {
        let fields = parse_fields("");
        assert!(fields.is_empty());

        let fields = parse_fields("Tom");
        assert_eq!(fields.len(), 1);
        assert_eq!(fields.first().unwrap(), "Tom");

        let fields = parse_fields("Tom,Dick");
        assert_eq!(fields.len(), 2);
        assert_eq!(fields.first().unwrap(), "Tom");
        assert_eq!(fields.get(1).unwrap(), "Dick");
    }
}

To make this test pass, we had to add some extra logic and introduced the split method. Next, we'll introduce the special case of an empty field.

fn main() {}

fn parse_fields(csv: &str) -> Vec<String> {
    if csv.is_empty() {
        return vec![];
    }

    csv.split(',')
        .map(|s| s.to_string())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// This test validates that we can parse comma-separated input like: "Tom,Dick,,Harry" into
    /// a Vec of Strings. Empty fields should be skipped.
    #[test]
    fn can_parse_fields() {
        let fields = parse_fields("");
        assert!(fields.is_empty());

        let fields = parse_fields("Tom");
        assert_eq!(fields.len(), 1);
        assert_eq!(fields.first().unwrap(), "Tom");

        let fields = parse_fields("Tom,Dick");
        assert_eq!(fields.len(), 2);
        assert_eq!(fields.first().unwrap(), "Tom");
        assert_eq!(fields.get(1).unwrap(), "Dick");

        let fields = parse_fields("Tom,,Dick");
        assert_eq!(fields.len(), 2);
        assert_eq!(fields.first().unwrap(), "Tom");
        assert_eq!(fields.get(1).unwrap(), "Dick");
    }
}

As you see, this will make our test fail. Adding the filter function will weed out the empty fields as required.

fn main() {}

fn parse_fields(csv: &str) -> Vec<String> {
    if csv.is_empty() {
        return vec![];
    }

    csv.split(',')
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// This test validates that we can parse comma-separated input like: "Tom,Dick,,Harry" into
    /// a Vec of Strings. Empty fields should be skipped.
    #[test]
    fn can_parse_fields() {
        let fields = parse_fields("");
        assert!(fields.is_empty());

        let fields = parse_fields("Tom");
        assert_eq!(fields.len(), 1);
        assert_eq!(fields.first().unwrap(), "Tom");

        let fields = parse_fields("Tom,Dick");
        assert_eq!(fields.len(), 2);
        assert_eq!(fields.first().unwrap(), "Tom");
        assert_eq!(fields.get(1).unwrap(), "Dick");

        let fields = parse_fields("Tom,,Dick");
        assert_eq!(fields.len(), 2);
        assert_eq!(fields.first().unwrap(), "Tom");
        assert_eq!(fields.get(1).unwrap(), "Dick");
    }
}

Now that we have some non-trivial logic, let's see if we can refactor our code without breaking the tests.

fn main() {}

fn parse_fields(csv: &str) -> Vec<String> {
    csv.split(',')
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// This test validates that we can parse comma-separated input like: "Tom,Dick,,Harry" into
    /// a Vec of Strings. Empty fields should be skipped.
    #[test]
    fn can_parse_fields() {
        let fields = parse_fields("");
        assert!(fields.is_empty());

        let fields = parse_fields("Tom");
        assert_eq!(fields.len(), 1);
        assert_eq!(fields.first().unwrap(), "Tom");

        let fields = parse_fields("Tom,Dick");
        assert_eq!(fields.len(), 2);
        assert_eq!(fields.first().unwrap(), "Tom");
        assert_eq!(fields.get(1).unwrap(), "Dick");

        let fields = parse_fields("Tom,,Dick");
        assert_eq!(fields.len(), 2);
        assert_eq!(fields.first().unwrap(), "Tom");
        assert_eq!(fields.get(1).unwrap(), "Dick");
    }
}

It turns out that we can now safely remove the check for the empty string, without breaking any of our test cases. Let's finish the exercise by adding the originally-requested input and any corner case we can think of.

fn main() {}

fn parse_fields(csv: &str) -> Vec<String> {
    csv.split(',')
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// This test validates that we can parse comma-separated input like: "Tom,Dick,,Harry" into
    /// a Vec of Strings. Empty fields should be skipped.
    #[test]
    fn can_parse_fields() {
        let fields = parse_fields("");
        assert!(fields.is_empty());

        let fields = parse_fields("Tom");
        assert_eq!(fields.len(), 1);
        assert_eq!(fields.first().unwrap(), "Tom");

        let fields = parse_fields("Tom,Dick");
        assert_eq!(fields.len(), 2);
        assert_eq!(fields.first().unwrap(), "Tom");
        assert_eq!(fields.get(1).unwrap(), "Dick");

        let fields = parse_fields("Tom,,Dick");
        assert_eq!(fields.len(), 2);
        assert_eq!(fields.first().unwrap(), "Tom");
        assert_eq!(fields.get(1).unwrap(), "Dick");

        let fields = parse_fields("Tom,Dick,,Harry");
        assert_eq!(fields.len(), 3);
        assert_eq!(fields.first().unwrap(), "Tom");
        assert_eq!(fields.get(1).unwrap(), "Dick");
        assert_eq!(fields.get(2).unwrap(), "Harry");

        let fields = parse_fields(",Tom, Dick,, ,Harry,");
        assert_eq!(fields.len(), 3);
        assert_eq!(fields.first().unwrap(), "Tom");
        assert_eq!(fields.get(1).unwrap(), "Dick");
        assert_eq!(fields.get(2).unwrap(), "Harry");
    }
}

The original input tested fine, but it looks like we missed a corner case: a field containing spaces. Let's make the final adjustment to our code and verify that all of our test cases pass.

fn main() {}

fn parse_fields(csv: &str) -> Vec<String> {
    csv.split(',')
        .map(|s| s.trim())
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// This test validates that we can parse comma-separated input like: "Tom,Dick,,Harry" into
    /// a Vec of Strings. Empty fields should be skipped.
    #[test]
    fn can_parse_fields() {
        let fields = parse_fields("");
        assert!(fields.is_empty());

        let fields = parse_fields("Tom");
        assert_eq!(fields.len(), 1);
        assert_eq!(fields.first().unwrap(), "Tom");

        let fields = parse_fields("Tom,Dick");
        assert_eq!(fields.len(), 2);
        assert_eq!(fields.first().unwrap(), "Tom");
        assert_eq!(fields.get(1).unwrap(), "Dick");

        let fields = parse_fields("Tom,,Dick");
        assert_eq!(fields.len(), 2);
        assert_eq!(fields.first().unwrap(), "Tom");
        assert_eq!(fields.get(1).unwrap(), "Dick");

        let fields = parse_fields("Tom,Dick,,Harry");
        assert_eq!(fields.len(), 3);
        assert_eq!(fields.first().unwrap(), "Tom");
        assert_eq!(fields.get(1).unwrap(), "Dick");
        assert_eq!(fields.get(2).unwrap(), "Harry");

        let fields = parse_fields(",Tom, Dick,, ,Harry,");
        assert_eq!(fields.len(), 3);
        assert_eq!(fields.first().unwrap(), "Tom");
        assert_eq!(fields.get(1).unwrap(), "Dick");
        assert_eq!(fields.get(2).unwrap(), "Harry");
    }
}

I hope you see that by following this approach, your code gradually improved as more test cases were covered. Because the steps were small, there was no need to think about the final solution from the beginning.

Your code evolved, and you ended up with a function that met the requirements, and all the test cases to prove that the code worked correctly.

In the next chapter, we'll look at how Test-driven-development can help you structure your code better.

Reference material