Rust HTTP Testing with httpmock

Published August 18, 2020, updated September 21, 2020 #rust #http #mock #test #api

HTTP mocking libraries allow you to simulate HTTP responses so you can easier test code that depends on third-party APIs. This article shows how you can use httpmock to do this in Rust.

Why HTTP-Mocking?

At some point, every developer had the need to test calls to external APIs. This is especially important in (micro)service-oriented environments where services typically require access to several external dependencies, such as APIs, authentication providers, data sources, etc. These services are not always available to you. This is where mocking/stubbing tools can fill the gaps. 

HTTP mocking libraries in particular allow you to simulate the behavior of HTTP-based services. These libraries usually provide you a simple HTTP server and tools to configure it for custom request/response scenarios. We will see examples of such scenarios in the following sections.

API mocking tools can be useful for many reasons, but the most obvious one is when writing automated tests. They are also useful during prototyping or when trying to simulate some odd behavior that is otherwise hard to achieve with the real system.

What we are going to do

In the rest of this article, we will explore how we can use the httpmock crate to simulate API services in Rust. For demonstration, we will

The App

Let’s suppose we are building an app that will manage Github repositories on our behalf. Users should be able to create, read, update, and delete Github repositories using our app. To perform these operations, we will use the Github REST API.

Let’s start!

Let’s first create a new cargo package for our app and name it github_api_client:

cargo new github_api_client --bin

We’ll also need some libraries, so let’s add them to Cargo.toml. We’ll use

[dependencies]
isahc = { version = "0.9.8", features = ["json"] }
serde_json = "1.0"
custom_error = "1.7.1"

The Client

In this section, we will write some code that will allow us to access the Github REST API. We will follow the facade pattern and hide implementation details behind a simple and convenient interface.

Let’s create a structure named GithubAPIClient. It will contain all the logic required to make calls against the Github REST API. To keep things simple, we’ll only have functionality in place that will allow us to create new Git repositories.

main.rs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
impl GithubAPIClient {
    pub fn new(token: String, base_url: String) -> GithubAPIClient {
        GithubAPIClient {
            base_url,
            token,
        }
    }

    /// https://docs.github.com/en/rest/reference/repos#create-a-repository-for-the-authenticated-user
    pub fn create_repo(&self, name: &str, private: bool) -> Result<String, GithubError> {
        let mut response = Request::post(format!("{}/user/repos", self.base_url))
            .header("Authorization", format!("token {}", self.token))
            .header("Content-Type", "application/json")
            .body(json!({
                    "name": name,
                    "private": private
                }).to_string())?
            .send()?;

        if response.status() != 201 {
            return Err(UnexpectedResponseCodeError { code: response.status().as_u16() });
        }

        let json_body: Value = response.json()?;
        return match json_body["html_url"].as_str() {
            Some(url) => Ok(url.into()),
            None => Err(MissingValueError{field: "html_url"})
        }
    }
}

Let us discuss this client implementation quickly.

The only method our client provides is create_repo. It takes two arguments: A repository name and a flag to determine whether the Git repo will be private or public. It returns a Result holding the repository URL as a String value. In case of an error, it will hold a GithubError instead. We’ll discuss GithubError in the next section, so let’s ignore it for now.

The client creates a JSON message body based on the input parameters and adds some mandatory request headers. It then sends all this data in an HTTP POST request to the Github API. If Github does not respond with HTTP status code 201, the client returns an UnexpectedResponseCodeError. Otherwise, the html_url is extracted from the JSON response body, which is the URL of the newly created repository. If the response body does not contain an html_url for some reason, a MissingValueError will be returned.

Custom Error Type

To make it simpler to reason about error states, we are using a custom GithubError type in our client implementation. This error type holds all possible errors our client can return. Creating custom error types in Rust requires some inconvenient boilerplate at this time. For this reason, we will use the very handy custom_error crate to make this simpler and more concise.

main.rs:

1
2
3
4
5
6
7
custom_error! {pub GithubError
    HttpError{source: isahc::http::Error} = "HTTP error",
    HttpClientError{source: isahc::Error} = "HTTP client error",
    ParserError{source: serde_json::Error} = "JSON parser error",
    UnexpectedResponseCodeError{code: u16} = "Unexpected HTTP response code: {code}",
    MissingValueError{field: &'static str} = "Missing field in HTTP response: {field}",
}

Notice that GithubError does not only contain errors we use in our client implementation directly. By embedding other error types using the source field, we can use the question mark notation in our client to automatically map different errors to our GithubError type.

The Main Function

All the code presented so far does not compile on its own. To compile and run our app, we need to add some additional code.

main.rs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use custom_error::custom_error;
use isahc::prelude::Request;
use serde_json::{json, Value};
use isahc::{RequestExt, ResponseExt};
use crate::GithubError::{MissingValueError, UnexpectedResponseCodeError};

fn main() {
    let github = GithubAPIClient::new("<github-token>".into(), "https://api.github.com".into());
    let url = github
        .create_repo("apprepo", true)
        .expect("Cannot create repo");
    println!("Repo URL: {}", url);
}

The Problem

Now that we have a functional application, we need to write some tests to make sure it doesn’t have any obvious errors.

The tricky part is to find a good target for mocking in our API client so we can test client behavior in different scenarios. In our case, the HTTP client (such as the Request::post method in our API client implementation) looks like a good place to start.

Unfortunately, mocking HTTP clients is not feasible or cumbersome at best. This is because in a larger application we would need to reimplement a big chunk of the HTTP clients API to be able to simulate request/response scenarios.

So what to do?

The Solution

To test our Github API client conveniently, we can use an HTTP mocking library. Such libraries can help us verify that HTTP requests sent by our client are correct and allow us to simulate HTTP responses from the Github API.

At the time of writing there are 4 noteworthy Rust libraries that can help us with this:

The following comparison matrix shows how the libraries compare to each other:

Library Execution Pooling Request Matchers Custom Matchers Mock- able APIs Sync API Async API Stand-alone Mode
mockito serial no 8 no 1 yes no no
httpmock parallel yes 14 yes yes yes yes
httptest parallel yes 6 yes yes no no
wiremock parallel no 6 yes no yes no

According to the comparison matrix the most complete package is currently provided by httpmock. For this reason, we will use this one for the rest of this article (and also because I am the developer 😜).

Creating Mocks

In this section, we will write some tests to verify our Github API client implementation works as expected. Let’s first add the httpmock crate to our Cargo.toml:

[dev-dependencies]
httpmock = "0.4"

Now we’re all set. Let’s create a test that will make sure “the good path” in our client implementation works as expected:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#[cfg(test)]
mod tests {
    use crate::GithubAPIClient;
    use httpmock::{MockServer, Mock, Method::POST};
    use serde_json::{json};

    #[test]
    fn create_repo_success_test() {
        // Arrange
        let mock_server = MockServer::start();
        let mock = Mock::new()
            .expect_method(POST)
            .expect_path("/user/repos")
            .expect_header("Authorization", "token TOKEN")
            .expect_header("Content-Type", "application/json")
            .return_status(201)
            .return_body(&json!({ "html_url": "http://example.com" }).to_string())
            .create_on(&mock_server);

        let client = GithubAPIClient::new("TOKEN".into(), format!("http://{}", &mock_server.address()));

        // Act
        let result = client.create_repo("testrepo", true).expect("Request not successful");

        // Assert
        assert_eq!(mock.times_called(), 1);
        assert_eq!(result, "http://example.com");
    }
}

To easier grasp what this test does, we arranged it following the AAA (Arrange-Act-Assert) pattern (look at the comments).

Arrange

In the “Arrange” part, we first created a MockServer instance (line 10). Next, we created a Mock object on the MockServer with all our request and response requirements (lines 11-18). In this sense, a Mock object identifies a request/response interaction configuration stored on the mock server.

Notice how we used methods starting with expect in their name to define HTTP request requirements (lines 12-15). We used methods starting with return in their name to define what data the corresponding HTTP response will contain (lines 16-17).

The mock server will only respond as specified if it receives an HTTP request that meets all request requirements. Otherwise, it will respond with an error message and HTTP status code 404.

Important: Observe how we set the base URL in our client to point to the mock server instead of the real Github API (line 20).

Act

In the “Act” part we trigger the method that is under test (line 23). In this case, this is create_repo.

Assert

In the “Assert” part, we first use the spy method Mock::times_called to verify the mock was called at the mock server exactly one time (line 26). This also makes it possible for us to ensure the request that was sent by our client met all the mock requirements we specified before. Next, we make sure the GithubAPIClient returned the expected result.

Debugging

You will encounter situations where you will need to do some investigation to find out why an HTTP request does not match your mock definition. In this case, httpmock provides you a detailed application log to support debugging.

httpmock logs against the log crate API, so we will need to add a compatible logging facade implementation. In this example we will use the env_logger crate and therefore add it as a dependency to our Cargo.toml:

[dev-dependencies]
env_logger = "0.7.1"
...

We then need to add one line at the beginning of our test function to initialize the logger:

#[test]
fn create_repo_success_test() {
    let _ = env_logger::try_init();

    // Arrange
    ...

We can now enable log output by setting the environment variable RUST_LOG to httpmock=debug. Remember to add the --nocapture argument when executing your tests, otherwise you will not see any log output. Execution via command line could look like this:

RUST_LOG=httpmock=debug cargo test -- --nocapture

A test execution will now log output that looks similar to the following output:

1
2
3
4
[2020-08-18T20:03:04Z INFO  httpmock::server] Listening on 127.0.0.1:65014
[2020-08-18T20:03:04Z DEBUG httpmock::server::handlers] Deleted all mocks
[2020-08-18T20:03:04Z DEBUG httpmock::server::handlers] Adding new mock with ID=0: MockDefinition { request: RequestRequirements { path: Some("/user/repos"), path_contains: None, path_matches: None, method: Some("POST"), headers: Some({"Authorization": "token TOKEN", "Content-Type": "application/json"}), header_exists: None, body: None, json_body: None, json_body_includes: None, body_contains: None, body_matches: None, query_param_exists: None, query_param: None, matchers: None }, response: MockServerHttpResponse { status: 201, headers: None, body: Some("{\"html_url\":\"http://example.com\"}"), duration: None } }
[2020-08-18T20:03:04Z DEBUG httpmock::server::handlers] Matched mock with id=0 to the following request: MockServerHttpRequest { path: "/user/repos", method: "POST", headers: Some({"accept": "*/*", "accept-encoding": "deflate, gzip", "authorization": "token TOKEN", "content-length": "34", "content-type": "application/json", "host": "127.0.0.1:65014", "user-agent": "curl/7.71.1-DEV isahc/0.9.8"}), query_params: Some({}), body: Some("{\"name\":\"testrepo\",\"private\":true}") }

The most important lines are 3 and 4.

Line 3 gives us some information about the mock that has been created at the mock server (refer to the “arrange” part of our test).

Line 4 tells us there was a request that matched all mock requirements during test execution.

Pooling

The attentive reader has noticed that mocks are being deleted from the mock server even before we created our first mock (see line 2 in the log output above). This is because httpmock uses a pooling mechanism in the background. This allows us to execute multiple tests in parallel without overwhelming the executing system by creating too many HTTP servers. Therefore, mock servers are recycled before they are used. If the pool is empty, httpmock blocks the test function when it calls MockServer::start until a mock server becomes available. A MockServer instance is automatically put back into the pool when the corresponding variable goes out of scope. At the latest, this happens when the test function completes.

Conclusion

This article showed how httpmock can be used to test HTTP-based API clients in Rust. On one hand, it allowed us to verify that HTTP requests our app is sending contain all the required data. On the other hand, we could simulate HTTP responses to make sure our app behaves correctly.

Although we have only created a test for one request/response scenario, we could easily extend our test suite to cover many more scenarios.

A distinguishing feature of httpmock is a standalone mode that allows you to run a mock server in a Docker container. This way you can use httpmock not only for unit and integration tests but also for system and end-to-end tests. It makes httpmock a universal HTTP mocking tool that is usable in all stages of the software development lifecycle.

You can find the source code from this article on Github.

You can comment on this post on reddit.


  1. Do not confuse the Rust crate wiremock with Wiremock for Java. They are not related to each other in any way. ↩︎