Using Rust to Speed Up Your Ruby Apps: Part 4 — Testing

In the first two blog posts of this series we discussed the why and the how of using Rust to speed up Ruby apps. In part three we took a look at error handling between Ruby and Rust code. Now let’s take a look at testing.

Testing Ruby Code That Calls Rust

There isn’t that much that needs to be said specifically about testing Rust code that is accessed via a Ruby app, especially unit tests. You can unit test your Rust library just like any other Rust application. You can also unit test your Ruby code as you normally would, mocking out the calls to your Rust library.

Integration tests are always a good idea, but even more so when a dynamically typed language is passing objects to a statically typed language via FFI. There are no restrictions on what type an attribute can be on a Ruby object that gets passed into a Rust library. In Rust you will need to know how to handle all the possible types your Ruby code might throw at you. Integration tests should help you catch potential type impedance issues before you start seeing panics in your production logs.

We use RSpec at Vericred as our Ruby testing framework. The only thing that made our Ruby integration tests slightly tricky was the fact that our Rust library inserts rows into our PostgreSQL database. We use the DatabaseCleaner gem to reset our database between tests. By default we use the transaction strategy which encapsulates database inserts as part of a test in a transaction that gets rolled back after the test. This doesn’t play nice when things outside of the ActiveRecord database connection insert data in the database (like a Rust library). The solution for this is straightforward, use the truncation strategy for just the tests that test the integration with our Rust library. This will ensure that data inserted by the Rust library will be present long enough for you to test it in RSpec. Just make sure the database is cleaned after the test. The maintainers of DatabaseCleaner provide a thorough example here.

Writing Tests in Rust

The documentation team at Rust can do a much better job of teaching you how to write tests in Rust than us, but we’ll quickly highlight the basics. A test function is more or less the same as any other function in Rust except the function signature doesn’t define any arguments or return types. A test function will have a #[test] annotation above it and will only be compiled and executed when you run the cargo test command. The assert and assert_eq macros are similar to expectations in RSpec in that they let you assert the code you’re testing returns correct results. Here is a basic example:

fn double_it(x: isize) -> isize {
  isize * 2
}

#[test]
fn double_it_test() {
    let result = double_it(1024);

    assert_eq!(2048, result);
}

Simple enough, right? It may not print output to the screen as pretty as RSpec, but it certainly gets the job done.

Mocking in Rust

Now let’s say our double_it function calls another function that we’d like to mock. Implementing a mocking library in a statically typed language like Rust is more complicated than a dynamic language like Ruby. Nevertheless, there are a few crates out there that can help us do the job. We currently use Mocktopus at Vericred. At the time we started implementing our Rust library it was the best option available. Mockall is a newer library that offers more features than Mocktopus and appears to be more actively developed. Be sure to check out both libraries and pick the one best suited for your needs. We’re going to take a look at an example with Mocktopus.

#![cfg_attr(test, feature(proc_macro_hygiene))]

#[cfg(test)]
use mocktopus::macros::*;

fn double_it(x: isize) -> isize {
    doubler(x)
}

#[cfg_attr(test, mockable)]
fn doubler(x: isize) -> isize {
    x * 2
}

#[cfg(test)]
use mocktopus::mocking::*;

#[test]
fn double_it_test() {
    doubler.mock_safe(|_x| {
        MockResult::Return(2048)
    });

    let result = double_it(1024);

    assert_eq!(2048, result);
}

In addition to including mocktopus you also have to enable the proc_macro_hygiene feature, which is nightly-only. While Mocktopus requires that you execute your tests using Rust nightly, your actual non-test code can still use stable. In order to mark a function (or entire module, or impl definition) mockable you conditionally set the mockable attribute using cfg_attr. Also, make sure you include mocktopus as a dev-dependency. Taking these steps will ensure that mocks will not be compiled into release builds, which would likely cause performance issues.

Using Mocktopus Across Multiple Targets

It’s not uncommon for a Rust project to include multiple targets. An example of this would be a binary target (or multiple binaries) and a lib target that creates a library imported by the binaries. What if you wanted to mock a call to a function in the library made from the binary? Unfortunately, #[cfg(test)] is not enabled for dependencies, so the above example will not work. But, we can make some simple modifications to achieve the desired effect.

First we need to make Mocktopus an optional dependency on [dependencies] instead of a plain old dependency under [dev-dependencies]. And then we’re going to add a mockable feature. Your Cargo.toml file should change from this:

[dev-dependencies]
mocktopus = "*"

to this:

[dependencies]
mocktopus = { version = "*", optional = true }

[features]
mockable = ["mocktopus"]

Next we replace the test flag passed to cfg attributes with our mockable feature like this:

#[cfg(any(feature = "mockable", mockable))]
use mocktopus::macros::*;

fn double_it(x: isize) -> isize {
    doubler(x)
}

#[cfg_attr(feature = "mockable", mockable)]
fn doubler(x: isize) -> isize {
    x * 2
}

We can enable the mocking feature with a command line flag: cargo +nightly test --features "mockable".

Hopefully, by leveraging Rust’s type system, ownership model and robust testing capabilities, you’ll be able to improve performance and stability of your Ruby apps without introducing a litany of new bugs.