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(())
}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)
}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!




