That mockingbird won't sing: a mock API server in Rust

That mockingbird won't sing: a mock API server in Rust

It’s better to be a fake somebody than a real nobody.
“The Talented Mr. Ripley”

Faking it till you make it isn’t always the best strategy in life, but it can be a useful one in software engineering. In A hard rain’s a-gonna fall, we wrote some Rust code that sends requests to the Weatherstack API, and decodes its responses. So, how happy are we about the correctness of that code?

We’ve put it all into a magic function named get_weather, and we call it like this:

fn main() -> Result<()> {
    let args = Args::parse();
    let location = args.location.join(" ");
    let weather = get_weather(&location, &args.api_key)?;
    println!("{weather}");
    Ok(())
}

(Listing weather_2)

It looks believable, sure, and it does work. But I sense you might still be feeling a little bit unconvinced about the way we so glibly finessed testing get_weather.

We said, if you remember, that we couldn’t test that function because we don’t know what weather conditions the real API will return: that depends on what the weather is when we run the test!

So, in Elephants for breakfast we decided to break up get_weather into two smaller functions, each with its own test. Then we rewrote the get_weather function to use these two subcomponents.

On reflection, though, can we really be confident that get_weather works properly? I mean, it looks correct:

pub fn get_weather(
    location: &str,
    api_key: &str,
) -> Result<Weather> {
    let resp = request(location, api_key).send()?;
    let weather = deserialize(&resp.text()?)?;
    Ok(weather)
}

(Listing weather_2)

Also, when we run the real program, it does successfully call Weatherstack and produce believable results. So there can’t be too much wrong with get_weather. Can there?

If you’ve read my book The Secrets of Rust: Tools, you’ll have noticed that there’s a strong emphasis on testing all the way through. We use tests as a thinking tool, a design tool, and of course to check that our code works properly (and continues to work, even when we fiddle with it over time).

It’s great to have tests, so doesn’t it feel like a bit of a step backwards now to be writing a function without one? It does to me. Let’s dig into that a little.

What could be wrong?

Sometimes manual testing and inspection is good enough, but it’s worth taking a minute or two to think about what might still be missing here.

When I’m wondering how to test something, I usually ask a question like “What could be wrong?” What bugs could there be in get_weather that we wouldn’t detect with the individual tests for request and deserialize?

Well, one obvious one is that we might have forgotten to call request or deserialize! Even the world’s greatest and best-tested functions are no use if you don’t call them.

Also, we could imagine ways in which we might call them wrongly. For example, we could accidentally write:

let resp = request(api_key, location).send()?;

Rust will absolutely not stop us doing this, because both parameters are &str, so it’s fine—syntactically—to swap them around. We’ll end up with the wrong request, and the call to Weatherstack certainly won’t work, but our existing unit tests won’t detect this problem at all. After all, they call the functions correctly, but they can’t validate that other functions do so.

Similarly, we could call deserialize with the wrong argument, or forget to return its result, or make any number of other silly mistakes which nevertheless are bound to happen in a big enough codebase, in a big enough team, or over a long enough span of time.

Why don’t we want to make API calls in tests?

So I completely sympathise with your unhappiness about the current approach to testing get_weather. Can’t we do better? Ideally, we’d like to test this function the same way we’d test any other function: by calling it. So what’s the problem with that?

One problem we identified earlier is that we don’t know exactly what weather conditions the API will return, but that’s not the only thing stopping us. We also don’t really want to make HTTP calls to external servers as part of our tests, because that’s awfully slow (relatively speaking). Okay, one call won’t break the bank, but it’s not an approach that would work well in large test suites with lots of dependent services and endpoints.

We want our tests to be fast, but even more importantly, we want them to be reliable, and the internet just isn’t reliable in general. We might not be in range of a WiFi or cellular connection, or that connection might be slow and flaky, or insecure (we’re passing our secret API key over the wire, after all). Even if everything at our end is working perfectly, Weatherstack itself might be down, or slow, or faulty.

And it doesn’t make sense that our tests would fail in that case, because as long as our code is correct, the tests should always pass.

A fake server using httpmock

So we want to call get_weather, but we don’t want to make a request to the real Weatherstack API. Thinking about it, though, what are we really testing here? Is it critical that get_weather makes its request to Weatherstack specifically, or would any HTTP server do?

As long as it returns the kind of JSON data we expect, in fact, any server would do. What’s to stop us setting up our own local HTTP server, pointing get_weather at it, and having it return some canned JSON we prepared earlier—for example, the data we’re already using in the deserialize test?

There’s nothing at all to stop us doing this, and it sounds like a good idea. In fact, there’s a crate for exactly this:

cargo add --dev httpmock

The httpmock crate makes it easy to set up a simple HTTP server for tests, with a local URL on a random port, and configure it to respond to various types of requests.

This pattern is sometimes called a test “double” or “fake”, though it’s not, strictly speaking, a fake. It’s a real server: it’s just not the Weatherstack server. It emulates, or “mocks” a subset of the API server’s behaviour so that we can test our code against it.

We’ll also want the http crate, which gives us some handy status code constants to test against, like StatusCode::OK:

cargo add --dev http

Kicking the tyres

Let’s first see if we can use this to test a simple GET request, with no parameters, and respond with a “hello” message:

use httpmock::{Method, MockServer};
use http::StatusCode;

#[test]
fn mock_server_responds_with_hello() {
    let server = MockServer::start();
    server.mock(|when, then| {
        when.method(Method::GET);
        then.status(StatusCode::OK).body("hello");
    });
    let resp = reqwest::blocking::Client::new()
        .get(server.base_url())
        .send()
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK, "wrong status");
    assert_eq!(resp.text().unwrap(), "hello", "wrong message");
}

To create the test server, we call MockServer::start(). We now have a working HTTP server listening on a random local port, but it’s not very useful yet, because it responds to every request with “404 Not Found”.

Configuring routes on the mock server

To fix that, we’ll tell the mock server how to respond to certain requests:

server.mock(|when, then| {
    when.method(Method::GET);
    then.status(StatusCode::OK).body("hello");
});

We’re saying when you receive any GET request, no matter what the path, then send a response whose status is OK, and whose body is the string “hello”. Ignore all other requests (or rather, continue to respond to them with “404 Not Found”).

So, let’s make that request and see what happens:

let resp = reqwest::blocking::Client::new()
    .get(server.base_url())
    .send()
    .unwrap();

How do we know what URL to make the request to? The server itself tells us, when we call server.base_url().

Now we have a response that we can check to see if it’s what it should be:

assert_eq!(resp.status(), StatusCode::OK, "wrong status");
assert_eq!(resp.text().unwrap(), "hello", "wrong message");

Mocking the real API

That was a good confidence builder. We think we should now be able to extend this to a more complicated mock server that can check its query parameters, and respond with data from a file instead of a string literal. Here goes:

#[test]
fn get_weather_fn_makes_correct_api_call() {
    let server = MockServer::start();
    server.mock(|when, then| {
        when.method(Method::GET)
            .path("/current")
            .query_param("query", "London,UK")
            .query_param("access_key", "dummy api key");
        then.status(StatusCode::OK)
            .header("content-type", "application/json")
            .body_from_file("tests/data/ws.json");
    });

Again, we start the mock server and use its mock method to tell it what kind of request to expect (“GET /current”) and what parameters the query should have.

When the server gets this request, it should respond with OK status, a “Content-Type” header of application/json (meaning that the body will contain JSON data), and the data itself from our test file.

Injecting the base URL

This looks good, and now we’re ready to call get_weather. But there’s a problem:

let weather = get_weather("London,UK", "dummy api key").unwrap();

Where do we pass in the mock server’s URL? I mean, get_weather doesn’t take any other parameters, and if we don’t do something it will just go ahead and call the real Weatherstack API, which we don’t want. We need some way for the test to inject this extra information into the client code.

What’s the right way to solve this problem? You might like to have a think about it. Let’s look at one wrong answer first:

let weather = get_weather(&server.base_url(), "London,UK",
    "dummy api key").unwrap();

I mean, no? This get_weather function is out of control with all the paperwork we’re forced to pass to it. An abstraction is supposed to conceal things, but the machinery is dangerously exposed here.

We’ve made this function slightly easier to test by adding the URL parameter, but as a result we’ve actually made it worse for real users, who now have to write:

let weather = get_weather("https://api.weatherstack.com/current",
    "London,UK", "real api key").unwrap(); // wat?

Why should users have to tell us the Weatherstack API URL? Shouldn’t we already know? This just seems weird and unnecessary, and indeed it is.

I don’t mean to sound ungrateful to httpmock, which is terrific. It’s very handy for us to be able to create a mock server to test against. But if we had to make our API worse in order to use it, that would be a shame.

What we’d like is for our get_weather function to use the real API by default, so that users don’t have to pass the Weatherstack URL when calling the function for real. But we’d also like to be able to tell it the URL of our mock server when we call it in tests. So how would that work?

As usual, this slightly contrived cliffhanger will be resolved in the next post. Don’t miss it!

When doomed stubs attack: blockchain voting and proof of work

When doomed stubs attack: blockchain voting and proof of work

0