Test driven development with Rust

Test Driven Development (TDD) encourages better software design. When the desired behavior is known and expressible, it’s highly effective to make modular and easily tested code.

Let’s take a look at using TDD with Rust, using release-party as an example.

What we’re changing

Release-party is a command line tool I made for my day job. We’ve got a fair amount of repositories on GitHub: one for each microservice. Our deployments are automated through TravisCI: the master branch is deployed to our testing environment and the release branch is deployed to production.

This is a hybrid of GitHub flow and trunk based development. Release-party automates the process of going to each repository in the organization, seeing if release is behind master and if so, create a new pull request. It quickly became a time sink to do that manually, multiple times a week, and release-party will do the inspection and pull request creation as needed.

One required argument to the tool is the GitHub organization. Until recently, users had to supply the entire GitHub API URL, such as https://api.github.com/orgs/ORG-HERE/repos. To make things easier on users, I modified it to take just the org: ORG-HERE. However, when run with the previous argument of the entire URL, it unceremonously keeled over with an unhelpful error message.

Here’s how it looks when supplying the old style of argument:

RP_GITHUBTOKEN=ghtoken release-party-br --org "https://api.github.com/orgs/ORG-HERE/repos"
thread 'main' panicked at 
'expected repos: "Couldn\'t deserialize repos from github: invalid type: 
map, expected a sequence at line 1 column 1"', src/libcore/result.rs:860:4
note: Run with `RUST_BACKTRACE=1` for a backtrace.

Our goal: be helpful if we are supplied the old style of argument.

How we want it to behave

First pass: we’ll check the org argument to see if it contains a URL. If so, let the user know they just need to supply the org name.

Red, green, refactor

Test first:

#[test]
fn handle_malformed_org() {
  assert_eq!(false, org_is_just_org("https://api.github.com/orgs/ORG-HERE/repos"));
}

org_is_just_org is a new function to check if a string contains just the org name. In this test we pass it the complete API URL, which should fail.

Dogmatic TDD states we should run the tests now and watch it fail. This is done by running cargo test and seeing the compilation error. I prefer a less dogmatic approach: let’s put in the org_is_just_org function, place it in the code base but the function will always return true:

fn org_is_just_org(org: &str) -> bool {
  true
}

It’s called in another function:

fn make_org_url(matches: &clap::ArgMatches) -> String {
    let org = matches
        .value_of("ORG")
        .expect("Please specify a github org");

    if !org_is_just_org(&org) {
        panic!("Please make org just the organization name.")
    }

    format!("https://api.github.com/orgs/{}/repos", org)
}

Now when we run cargo test we see it fail:

test tests::handle_malformed_org ... FAILED
...
failures:

---- tests::handle_malformed_org stdout ----
	thread 'tests::handle_malformed_org' panicked at 'assertion failed: `(left == right)`
  left: `false`,
 right: `true`', src/main.rs:176:8
note: Run with `RUST_BACKTRACE=1` for a backtrace.


failures:
    tests::handle_malformed_org

test result: FAILED. 6 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

On to making the test pass! Let’s modify org_is_just_org:

fn org_is_just_org(org: &str) -> bool {
    if org.contains("https://api.github.com") {
        return false;
    }
    true
}

That looks better. What does cargo test say?

running 7 tests
test github::tests::no_next_link ... ok
test github::tests::has_next_link ... ok
test github::tests::no_requests_left ... ok
test github::tests::plenty_of_requests_left ... ok
test tests::handle_malformed_org ... ok
test tests::get_ignored_repos_happy_path ... ok
test github::tests::finds_next_link ... ok

test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Success!

Since our change was so small, there’s little to nothing to refactor. While we’re in here, let’s also ensure the happy path is tested:

#[test]
fn handle_okay_org() {
    assert_eq!(true, org_is_just_org("ORG-HERE"));
}

We’re green on all the tests.

How we want it to behave, phase two

Panicking with a message of Please make org just the organization name. isn’t super helpful. It’s correct but we can do better. Let’s try to make a suggestion to the user. If they provide https://api.github.com/orgs/ORG-HERE/repos let’s respond with something along the lines of did you mean ORG-HERE?.

Red, green, refactor

Our new test:

#[test]
fn suggestion_for_org() {
    assert_eq!("Try this", 
        suggest_org_arg("https://api.github.com/orgs/ORG-HERE/repos").unwrap());
}

New function:

fn suggest_org_arg(org: &str) -> Result<String, String> {
    Err("Can't make a suggestion".to_owned())
}

And the new function slotted in where we want it:

fn make_org_url(matches: &clap::ArgMatches) -> String {
    let org = matches
        .value_of("ORG")
        .expect("Please specify a github org");

    if !org_is_just_org(&org) {
        match suggest_org_arg(&org) {
            Ok(suggestion) => panic!("Try this for the org value: {}", suggestion),
            Err(_) => panic!("Please make org just the organization name."),
        }
    }

    format!("https://api.github.com/orgs/{}/repos", org)
}

We can see our test fail:

...
test tests::suggestion_for_org ... FAILED
...

failures:

---- tests::suggestion_for_org stdout ----
	thread 'tests::suggestion_for_org' panicked at 'called `Result::unwrap()` on an `Err` value: "Can\'t make a suggestion"', src/libcore/result.rs:906:4
note: Run with `RUST_BACKTRACE=1` for a backtrace.


failures:
    tests::suggestion_for_org

test result: FAILED. 8 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

And to make it pass:

fn suggest_org_arg(org: &str) -> Result<String, String> {
    if org.starts_with("https://api.github.com/orgs/") && org.ends_with("/repos") {
        return Ok("Try this".to_owned());
    }
    Err("Can't make a suggestion".to_owned())
}
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Yes! We now have a passing test. But the test doesn’t actually test our end state: it doesn’t return the suggestion. Let’s fix that:

#[test]
fn suggestion_for_org_happy() {
    assert_eq!("Try this: ORG-HERE", 
        suggest_org_arg("https://api.github.com/orgs/ORG-HERE/repos").unwrap());
}

cargo test will show us that fails because our implementation doesn’t return the suggestion. On to that:

fn suggest_org_arg(org: &str) -> Result<String, String> {
    if org.starts_with("https://api.github.com/orgs/") && org.ends_with("/repos") {
        let suggestion = org.replace("https://api.github.com/orgs/", "").replace("/repos", "");
        return Ok(format!("Try this: {}", suggestion).to_owned());
    }
    Err("Can't make a suggestion".to_owned())
}

cargo test shows all green:

test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

For completeness we’ll also test some error cases for the function:

#[test]
fn suggestion_for_org_sad() {
    assert_eq!(true, suggest_org_arg("https://api.github.com/orgs/ORG-HERE/").is_err());
    assert_eq!(true, suggest_org_arg("http://api.github.com/orgs/ORG-HERE/").is_err());
    assert_eq!(true, suggest_org_arg("api.github.com/orgs/ORG-HERE/repos").is_err());
}

Once again a cargo test shows the new test demonstrates our function works as expected:

running 10 tests
test github::tests::no_next_link ... ok
test github::tests::has_next_link ... ok
test github::tests::no_requests_left ... ok
test github::tests::plenty_of_requests_left ... ok
test tests::handle_malformed_org ... ok
test tests::handle_okay_org ... ok
test tests::get_ignored_repos_happy_path ... ok
test tests::suggestion_for_org_happy ... ok
test tests::suggestion_for_org_sad ... ok
test github::tests::finds_next_link ... ok

test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

The proof of the pudding is in the eating

One last manual check to make sure it’s all plumbed corectly:

RP_GITHUBTOKEN=foo cargo run -- --org https://api.github.com/orgs/my-org-name/repos
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/release-party-br --org 'https://api.github.com/orgs/my-org-name/repos'`
thread 'main' panicked at 'Try this for the org value: Try this: my-org-name', src/main.rs:66:30

We’ve done it! When passed --org 'https://api.github.com/orgs/my-org-name/repos' we return with 'Try this for the org value: Try this: my-org-name'.

Future work

The first issue is the format: 'Try this for the org value: Try this: my-org-name' repeats Try this and it should be cleaned up.

Second, panicking still looks ugly and the work we just did is still a bit hidden due to that. There’s a ticket for fixing that behavior later.

Confidence!

We’ve added new behavior using composable functions that are easily tested. Our tests are written and will always be around to ensure if we incorrectly change something, they’ll catch it.

This gives us better confidence our code is correct. Code on!