From Go to Rust - Unit Testing
In this fourth installment of the series, we'll transform some Go tests into Rust tests.
In case you missed anything:
- In the first part of this series, we looked at some fundamentals of Rust, and how they compare to Go.
- In the second part we took a Go web server and reimplemented it in Rust.
- The third part focused on JSON and serialization, as we compared Go's annotation-based encoding to Rust's Serde libraries.
Now in this part, we'll look at testing. Along the way, we'll also see:
- How to create a library package in Rust
- How to use modules, and how they compare to Go packages
- How to use Rust's assertion tools
- How to run tests from Cargo
Go Did Testing Right... Mostly
I am a big fan of Go's approach to testing. Tests are easy to write, easy to run, and live alongside the stuff that they test. Adding benchmarking support was cool. And I like the way that documentation functions get automatically tested (though the implementation is limited to nearly trivial functions).
I also like the fact that Go makes it easy to test private (unexported) functions. I know there's some dogma involved here. I've heard people adamantly claim that private functions should not be tested. But for purely pragmatic reasons, my view is that we should be able to test whatever we want.
But there are a few things about Go's built-in testing that I'm not terribly keen on.
I'll never understand why the Go developers didn't just add an assertions library to the testing library. I've heard the "asserts get abused" line, but I don't find it convincing. That said, it's a shortcoming easily remedied by a decent assert library.
One thing I'm not a fan of in general, though, is using "magic" prefixes or suffixes to determine how to execute a function. I think function name scanning sets a dangerous precedent for how reflection ought to be used. And I find that in practice it results in an arbitrary limitation on what I can actually name my functions. But in spite of my general disdain for that pattern, I've been happy with the way it works in Go's testing suite.
Overall, I think Go makes it amazingly simple to write tests. And compared to languages like Java, Python, JavaScript, and PHP, working with Go tests is a breeze.
So when diving into Rust (reminder: This really is my first go-around with the language), I've been interested to see how Rust's testing stacks up. In this article, we'll focus on unit tests.
Something To Test: Wordutils
For the past few posts, I've been writing small programs. But with testing as the focus, it seems like the right time to try my hand at writing a library. So here's a small wordutils
library:
package wordutils
import (
"bufio"
"strings"
)
// Initials returns a string with the first letter of each word in the given string.
func Initials(phrase string) (string, error) {
wrds, err := words(phrase)
if err != nil {
return "", err
}
initials := ""
for _, word := range wrds {
initials += word[0:1]
}
return strings.ToUpper(initials), nil
}
func words(str string) ([]string, error) {
wordList := []string{}
scanner := bufio.NewScanner(strings.NewReader(str))
scanner.Split(bufio.ScanWords)
for scanner.Scan() {
wordList = append(wordList, scanner.Text())
}
return wordList, scanner.Err()
}
The library above declares two functions: words
, which splits a string into words, and Initials
, which returns a string that is the capitalized version of the first letter of each word in the given string.
While there's no Go standard library function that does what words()
does, I decided to write it as an internal function (non-exported) to give me a point of comparison with Rust. When re-implementing, we'll follow the same scoping rules.
These two functions are easy enough to test. So alongside wordutils.go
, here is the contents of wordutils_test.go
:
package wordutils
import (
"testing"
)
func TestWords(t *testing.T) {
in := "this is the way\n the world ends"
out, err := words(in)
if err != nil {
t.Fatal(err)
}
expect := []string{"this", "is", "the", "way", "the", "world", "ends"}
if len(out) != len(expect) {
t.Fatal("expected same length")
}
for i, word := range out {
if word != expect[i] {
t.Errorf("expected word %d to be %q, got %q", i, expect[i], word)
}
}
}
func TestInitials(t *testing.T) {
in := "not with a bang but a whimper"
expect := "NWABBAW"
out, err := Initials(in)
if err != nil {
t.Fatal(err)
}
if out != expect {
t.Errorf("expected %q, got %q", expect, out)
}
}
There's a simple unit test for each of the functions we wrote. These tests aren't terribly robust (they don't test the error cases), but they're good enough for us to start modeling a Rust implementation.
Creating a Rust Library
Instead of creating an executable with cargo new --bin
, we're going to create a new library. And as a bonus... I just learned that we can initialize a Git repo as part of package creation:
$ cargo new --vcs git --lib wordutils
Created library `wordutils` project
The --lib
flag sets up the package as a library:
wordutils
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── target
└── debug
└── ...
In previous articles, we worked on src/main.rs
. Note that with --lib
, the file created for us is src/lib.rs
. If we take a look inside of it, we'll see that some code was already created for us:
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}
Huh... it's a test scaffold. It's like they knew what I was planning. Let's run the tests just to see what happens. From within the wordutils
directory, we just run cargo test
:
cargo test
Compiling wordutils v0.1.0 (file:///Users/mbutcher/Code/Rust/wordutils)
Finished dev [unoptimized + debuginfo] target(s) in 4.24 secs
Running target/debug/deps/wordutils-9a757f9d84faff12
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests wordutils
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Alright, lest I get ahead of myself... we know that the initial test works. Let's get about the business of writing the library, and we'll return to testing later.
Wordutils: The Rust Version
Again, this is my first attempt at writing a Rust library. So my initial reaction to the contents of lib.rs
was, "Wait... if the tests are in there, where do I put my code?" It turns out that for simple modules like ours, the answer is: put the code and the tests in the same file.
We'll do that first, then later look at ways of breaking things up. To start, we'll leaving the stub test alone and adding our new functions.
fn words(phrase: &str) -> std::str::SplitWhitespace {
return phrase.split_whitespace()
}
pub fn initials(phrase: &str) -> String {
words(phrase).map(
|word| word.chars().next().unwrap()
).collect::<String>().to_uppercase()
}
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}
We've replicated our wordutils.go
functionality in just a few lines of code. And it's exciting, because we've encountered a few new concepts that haven't appeared in previous installments of the series.
The words()
function
The words()
function is a oneliner because Rust's standard library already has a word splitter.
fn words(phrase: &str) -> std::str::SplitWhitespace {
return phrase.split_whitespace()
}
There are two things to note about this function.
First, we return a type called std::str::SplitWhitespace
. The SplitWhitespace
type is the type returned by split_whitespace
. It has several traits associated with it that make it useful as a type.
In Go, it is common to keep the number of types sparse. For something like split_whitespace
, Go developers would likely return a []string
(slice of strings). But in Rust, it is common to return specialized types that can then implement multiple traits. In doing this (as we will see in a moment), we gain added flexibility that leads to concise and readable code.
Second, I chose to return std::str::SplitWhitespace
instead of adding a use std::str::SplitWhitespace
at the top, and then just returning SplitWhitespace
. Either way works, but it is probably more common to add the use
line rather than use a fully qualified name in a return value.
The initials()
function
The second function declared is initials()
. Normally, we'd actually write this as a one-liner, too. But since we're all new to Rust, I figured it would be more readable to expand it into a three-line body:
pub fn initials(phrase: &str) -> String {
words(phrase).map(
|word| word.chars().next().unwrap()
).collect::<String>().to_uppercase()
}
Notice that this function definition begins with pub
. That tells the Rust toolchain that this function is a public (exported) function. Unlike Go, capitalization makes no difference as to the visibility of a function (or anything else). In Rust, modules must use pub
to mark a method as visible outside of the module.
Like Go, though, Rust only has two visibilities: public (with pub
) and private (the default). But the rules for privacy are slightly different in Rust than in Go. According to the Rust visibility documentation:
If an item is private, it can be accessed only by its immediate parent module and any of the parent’s child modules.
In contrast, privacy in Go dictates that only the present package may access a private item. We'll see in a moment why this nuance makes a difference when we write Rust tests.
Now let's look more closely at the function chain we run inside of initials()
:
words(phrase).map(
|word| word.chars().next().unwrap()
).collect::<String>().to_uppercase()
We start by running the words()
function we saw above. The SplitWhitespace
object returned from that implements Iterator. Because of this, we could toss the result into a for
loop:
for word in words("Mary had a little lamb") {
println!("Word: {}", word);
}
See what we did? The SplitWhitespace
type is an iterator (implements std::iter::Iterator
), so we can use it in a for
loop without doing anything special. Go does not have a concept of an iterator, so this code might look surprising.
Rust iterators are more than just a convenience for for
loops, though. An Iterator
has around a dozen useful functions attached to it. And one of them is map()
. The map()
function takes a closure (inline function), runs it on every item in the iterator, and returns the results as a new iterator.
In Rust, closures look different than regular functions. They take the form:
|param1, param2, ...| function_body
We can spread the function body out into a block, if we'd like:
|param1| {
stuff;
more_stuff;
return_val
}
In our code, we call the map()
function and give it a transformation: It takes a word, and returns the first character of that word.
|word| word.chars().next().unwrap()
Note that here (as in many cases in Rust), we let the compiler infer the type of word
. The function is still type safe because Rust can determine at compile time that anything assigned to word
will be a string. (If the type were ambiguous for some reason, we could annotate it |word: &str| ...
)
So word.chars().next()
essentially says "convert this word string to a list of characters, then use next()
to pop the first character." But next()
is safe: Instead of returning a character, it returns an Option<char>
. If there is no character to return, it will send back a None
. We happen to know that all of the words returned from split_whitespace()
have at least one character in them. So instead of testing whether the result of next()
was a Some<char>
or a None
, we can use unwrap()
to just get the char
value.
Note that if for some reason we did get a None
, unwrap()
would cause a panic.
At this point, the words().map()
combo has returned an iterator of chars. We want to turn that into a String
. Believe it or not, this is really easy in Rust because an Iterator
has a function called collect<T>()
that takes an iterator and transforms it into some other collection type (T
).
We could, for example, use collect::<Vec<char>>
to collect our iterator into a vector (list) of characters. And in Rust, a String
happens to be... wait for it... a collection of characters! So all we need to do to transform our character iterator into a String
is call collect::<String>()
. (Recall that ::<T>
, the turbofish, tells a function that takes a generic how to fill out that generic.)
That's it for our two functions. Essentially, we've now reimplemented our Go wordutils
library. It's time to do some testing.
Writing the Tests
We already got a hint of how to write tests when we took our initial look at lib.rs
. We saw a basic test that looked like this:
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}
So let's just roll with it and try to flesh out some actual tests based on that pattern. Here I'm replicating the tests from wordutils_test.go
and also adding a few tests for handling empty strings:
use std::str::SplitWhitespace;
pub fn initials(phrase: &str) -> String {
words(phrase).map(
|word: &str| word.chars().next().unwrap()
).collect::<String>().to_uppercase()
}
fn words(phrase: &str) -> SplitWhitespace {
return phrase.split_whitespace()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_words() {
let input = "this is the way\n the world ends";
let expect = vec!["this", "is", "the", "way", "the", "world", "ends"];
assert_eq!(words(input).collect::<Vec<&str>>(), expect)
}
#[test]
fn test_initials() {
let input = "not with a bang but a whimper";
assert_eq!(initials(input), "NWABBAW");
}
#[test]
fn empty_words() {
let input = "";
let expect: Vec<&str> = Vec::new();
assert_eq!(words(input).collect::<Vec<&str>>(), expect);
}
#[test]
fn empty_initials() {
let input = "";
assert_eq!(initials(input), "");
}
}
If we run cargo test
with the above, we'll see four tests pass. Let's take a quick look at the organization of tests.
Rust Modules, and the test
Module
In Go, packages are determined largely by the package
keyword at the top of each file in a directory. The usual idiom in Go is that a directory name and a package name match. (The history of this is a little complex, as the original version of Go suggested that a directory contains two packages: the base package (foo
) and the testing package (foo_test
). But that idiom was deprecated shortly after Go 1.0.)
In Rust, when people say "package", they usually mean a crate, which is an entire library or application. Libraries are broken up not into packages, but into modules.
While Go mixes testing and non-testing stuff into the same package, and uses compiler magic to distinguish based on file names, Rust is a little different. In Rust, you store your tests (by convention) in the tests
module, decorate the modules with attributes, and then let the toolchain sort and run the tests.
There are noteworthy similarities that Rust and Go share here (especially when contrasted with other popular languages): Tests are stored alongside the code they test. Tests are sorted and executed by the toolchain. Testing support is first-class in the language.
With that in mind, we can take a look at the tests
module:
#[cfg(test)]
mod tests {
use super::*;
// Tests go here
}
So we've created a module inside of wordutils
that is named wordutils::tests
. And we've annotated it with #[cfg(test)]
, which (if I understand things correctly) is the attribute that tells the compiler to only compile this module during a test run. (Compare this with Go build flags.) Like go test
, cargo test
compiles a testing binary and then executes it.
See the line use super::*
? We've seen use
in previous installments of this series. It is used to import names from other modules into the current namespace. Here, we are importing the names from the parent (super) module into the current tests
module. So instead of calling super::words()
, we can simply call words()
.
Remember that in Rust, a private function can be accessed by the parent module, and by all of the parent's submodules. Because of that rule, we can import the words
function, which is private, into the tests
module.
At this point, we've created our testing module and imported the functions we want to test. Let's look at the tests.
Test Functions
Here's the first test function:
#[test]
fn test_words() {
let input = "this is the way the world ends";
let expect = vec!["this", "is", "the", "way", "the", "world", "ends"];
assert_eq!(words(input).collect::<Vec<&str>>(), expect)
}
My Go naming habits have me prefixing the test function with test_
, but as far as I can tell, that's not an idiom in Rust. Maybe calling it words_equal
would have been just as acceptable.
But to make this function a test, we need to prefix it with the #[test]
attribute. This is what indicates that the function a testing target. If I omit this attribute, I'll see an error:
warning: function is never used: `test_words`
--> src/lib.rs:18:5
|
18 | fn test_words() {
| ^^^^^^^^^^^^^^^
|
= note: #[warn(dead_code)] on by default
Inside of a test module I can add utilities, mocks, etc. and as long as I don't label them with #[test]
they will not be mistaken for tests.
In the four test functions I created, I used an assertion macro (assert_eq!
). There are actually four macros that are useful for testing:
assert!
, which asserts that the value istrue
assert_eq!
, which asserts that two values are equalassert_ne!
, which asserts that two values are not equalpanic!
which causes the test to fail
Go has two classes of failure:
t.Error
andt.Fatal
. Standard Rust only has one type of failure.
There's also a #[should_panic]
annotation. Decorate a function with this to indicate that a test is considered passing if and only if it panics.
From here, our tests are straightforward. We merely compare the output of our two functions to the expected results.
Breaking Out Tests into a Separate File
To be honest, I'm not totally sure what the accepted idioms are for breaking tests out into separate files, but it turns out that it is relatively easy to do.
Modules can be split into separate files, and tests are organized into modules. So we can split tests into a separate file like this.
First, here's the main file for the library in lib.rs
:
use std::str::SplitWhitespace;
pub fn initials(phrase: &str) -> String {
words(phrase).map(
|word: &str| word.chars().next().unwrap()
).collect::<String>().to_uppercase()
}
fn words(phrase: &str) -> SplitWhitespace {
return phrase.split_whitespace()
}
#[cfg(test)]
mod tests;
On the last two lines of that file, we declare a testing module, but we don't put anything in the module. This will cause the compiler to look for tests.rs. We can oblige it by putting all the tests in tests.rs
:
use super::*;
#[test]
fn test_words() {
let input = "this is the way\n the world ends";
let expect = vec!["this", "is", "the", "way", "the", "world", "ends"];
assert_eq!(words(input).collect::<Vec<&str>>(), expect)
}
#[test]
fn test_initials() {
let input = "not with a bang but a whimper";
assert_eq!(initials(input), "NWABBAW");
}
#[test]
fn empty_words() {
let input = "";
let expect: Vec<&str> = Vec::new();
assert_eq!(words(input).collect::<Vec<&str>>(), expect);
}
#[test]
fn empty_initials() {
let input = "";
assert_eq!(initials(input), "");
}
Now if we run cargo test
, we'll see the usual testing output:
$ cargo test
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running target/debug/deps/wordutils-9a757f9d84faff12
running 4 tests
test tests::empty_words ... ok
test tests::empty_initials ... ok
test tests::test_initials ... ok
test tests::test_words ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests wordutils
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Again, I'm not sure if this is the preferred way to write tests, but it seems like a nice way to organize things.
Specifying the Test to Run
Here's one final note on running tests with cargo
. In Go, we can select specific tests to run by regular expression: go test -run REGEXP
. With Cargo, you can call specific tests by name:
$ cargo test empty_words
There are several flags (like --exclude
) that can impact which tests are run. But as far as I know, there's no equivalent regular expression version.
Conclusion
The focus of this article has been on how to unit test Rust code, compared to Go. As a Rust neophyte, I have been pleasantly surprised by the similarities. The things I love about Go's testing are also present in Rust's testing. And I find Rust's built-in assertions, module-based tests, and attribute annotations to be elegant.
But unit testing is only one of the kinds of tests we care about. In the next installment of the series, we will see how Rust's testing stands up against Go's when it comes to benchmarks, documentation tests, and functional tests.