Stubr

Stubr is a stubbing and contract testing tool. It supports the same API for writing stubs as Wiremock. You can also see it as an adaptation of wiremock-rs with the ability to write stubs as json files instead of code.

You might ask why would I want to do such a thing ? After all, code is a great way to write stubs. That's true ! But it also comes with some limitations: it is hard to share. And it especially starts tickling your attention when you try to do contract testing. You have to share a contract between a producer and a consumer. Sometimes, both are written in different languages, or with different frameworks ; and even when that's not the case, they might both be sharing a different version of that wicked dependency which clash together. Having your contract written in json has the benefits of being portable, lightweight and polyglot (if you stick to a standard API such as Wiremock's one). So you could for example test a producer service written in Java and vice versa !

Stubr aims at bridging multiple languages and framework and enable developers to test their integration with remote application without having to mock their own code. It also enables them to shorten their feedback loop: no need for a complex CI to make sure 2 application share the same API definition, everything can be done offline.

Then, beyond contract testing, it tries to cover others areas:

  • stubbing in your Rust project for simulating remote services your app depends on
  • recording for capturing http traffic and turning it into a stub file
  • standalone for running stubr stub server from anywhere and benefiting from Rust performances. Available as a cli or a Docker image

Getting started

You will of course want to use stubr in your Rust project. We will cover here the case where you want to mock an external http application you do not own (if you own it, you might be interested in contract testing).

First you need a stub. A stub is a json file which represents the endpoint you want to mock. You have 2 options from now on:

We are going to be even lazier and simply create the json stub with a command.

You should have a project layout like this:

├── src
│   ├── lib.rs
└── tests
    ├── stubs
├── Cargo.toml
├── README.md

We are going to create the stub under tests/stubs/, the default location. You can place them wherever you want of course, but you'll see it's more convenient to place them here.

echo "{\"request\": {\"method\": \"GET\", \"urlPath\": \"/hello\"}, \"response\": { \"body\": \"Hello stubr\" }}" > tests/stubs/hello.json

And with a few lines of code we can spawn a mock server and call (here with reqwest for example).

use asserhttp::*;

#[tokio::test]
async fn getting_started() {
    // run a mock server with the stub 👇
    let stubr = stubr::Stubr::start("tests/stubs/hello.json").await;
    // or use 'start_blocking' for a non-async version

    // the mock server started on a random port e.g. '127.0.0.1:43125'
    // so we use the stub instance 'path' (or 'uri') method to get the address back
    let uri = stubr.path("/hello");
    reqwest::get(uri).await
        // (optional) use asserhttp for assertions
        .expect_status_ok()
        .expect_content_type_text()
        .expect_body_text_eq("Hello stubr");
}

But we can further shorten this with a attribute macro: #[stubr::mock]

use asserhttp::*;

#[tokio::test]
#[stubr::mock("hello.json")] // 👈 this starts the mock server
async fn getting_started() {
    // a local binding 'stubr' has been created, equivalent to the one before
    let uri = stubr.path("/hello");
    reqwest::get(uri)
        .await
        .expect_status_ok()
        .expect_content_type_text()
        .expect_body_text_eq("Hello stubr");
}

You can also use the macro in non-async test methods of course, the macro will adapt by itself.
Note that here you can omit the tests/stubs/ path prefix. If you placed your files in the default location, they are going to be searched from there.
As well, you can mount many stubs with the macro e.g. #[stubr::mock("hello.json", "goodbye.json")]. By default, if you take care to place only stubs with different request matching under tests/stubs, you can simply place #[stubr::mock]. It will recursively mount all the stubs under tests/stubs, searching also in subdirectories.

Here are all the options you can use with the attribute macro

#[tokio::test]
#[stubr::mock(full_path = "tests/book/hello.json", port = 1234, verify = true)]
async fn getting_started() {}
  • full_path: use this if your stubs are not under tests/stubs but elsewhere. Note that it can point to a directory.
  • port when you want an explicit port for your mock server
  • verify to turn on verification of the number of times a stub gets called (expect field in your stubs). See simulating fault for reference

Getting started

You will of course want to use stubr in your Rust project. We will cover here the case where you want to mock an external http application you do not own (if you own it, you might be interested in contract testing).

First you need a stub. A stub is a json file which represents the endpoint you want to mock. You have 2 options from now on:

We are going to be even lazier and simply create the json stub with a command.

You should have a project layout like this:

├── src
│   ├── lib.rs
└── tests
    ├── stubs
├── Cargo.toml
├── README.md

We are going to create the stub under tests/stubs/, the default location. You can place them wherever you want of course, but you'll see it's more convenient to place them here.

echo "{\"request\": {\"method\": \"GET\", \"urlPath\": \"/hello\"}, \"response\": { \"body\": \"Hello stubr\" }}" > tests/stubs/hello.json

And with a few lines of code we can spawn a mock server and call (here with reqwest for example).

use asserhttp::*;

#[tokio::test]
async fn getting_started() {
    // run a mock server with the stub 👇
    let stubr = stubr::Stubr::start("tests/stubs/hello.json").await;
    // or use 'start_blocking' for a non-async version

    // the mock server started on a random port e.g. '127.0.0.1:43125'
    // so we use the stub instance 'path' (or 'uri') method to get the address back
    let uri = stubr.path("/hello");
    reqwest::get(uri).await
        // (optional) use asserhttp for assertions
        .expect_status_ok()
        .expect_content_type_text()
        .expect_body_text_eq("Hello stubr");
}

But we can further shorten this with a attribute macro: #[stubr::mock]

use asserhttp::*;

#[tokio::test]
#[stubr::mock("hello.json")] // 👈 this starts the mock server
async fn getting_started() {
    // a local binding 'stubr' has been created, equivalent to the one before
    let uri = stubr.path("/hello");
    reqwest::get(uri)
        .await
        .expect_status_ok()
        .expect_content_type_text()
        .expect_body_text_eq("Hello stubr");
}

You can also use the macro in non-async test methods of course, the macro will adapt by itself.
Note that here you can omit the tests/stubs/ path prefix. If you placed your files in the default location, they are going to be searched from there.
As well, you can mount many stubs with the macro e.g. #[stubr::mock("hello.json", "goodbye.json")]. By default, if you take care to place only stubs with different request matching under tests/stubs, you can simply place #[stubr::mock]. It will recursively mount all the stubs under tests/stubs, searching also in subdirectories.

Here are all the options you can use with the attribute macro

#[tokio::test]
#[stubr::mock(full_path = "tests/book/hello.json", port = 1234, verify = true)]
async fn getting_started() {}
  • full_path: use this if your stubs are not under tests/stubs but elsewhere. Note that it can point to a directory.
  • port when you want an explicit port for your mock server
  • verify to turn on verification of the number of times a stub gets called (expect field in your stubs). See simulating fault for reference

Getting started

You can use stubr as a standalone mock server i.e. an executable. To learn more read the pages about how to use the cli or as a Docker image.

For this short demo we are going to use the cli. We will create a http stub, mount it on a stub server and then call it to verify it works.

installation

If you don't have it, install rustup from here.

cargo install stubr-cli

or from precompiled binaries

Those binaries are stripped with upx and then compressed. They are likely to be smaller than the ones built by rustc which might be preferable in certain conditions

macos
curl -L https://github.com/beltram/stubr/releases/latest/download/stubr-macos.tar.gz | tar xz - -C /usr/local/bin
linux
curl -L https://github.com/beltram/stubr/releases/latest/download/stubr-linux.tar.gz | tar xz - -C /usr/local/bin
windows

Install binary from here.

Hello world !

We are going to create the simplest stub possible. It will accept any http method on any path and will respond 200 OK.

cat > stub.json <<- EOF
{
  "request": {
    "method": "ANY"
  },
  "response": {
    "status": 200,
    "body": "Hello world !",
  }
}
EOF

A few things about a stub:

  • It is a json file. First because it is the format supported by Wiremock and we want to be compatible with it. Also, most of the time, your http APIs will consume/produce json data in their bodies. So you can inline the request/response body in this file without externalizing it.
  • request { .. } is where we define request matching i.e. "conditions the incoming http request has to satisfy in order for the response part to be served"
  • response { .. } is the part where you define what the stub server will respond if all request matchings pass.

mount it

The cli can spawn a http server with a path to the file or folder containing json stubs. By default, it will try to bind to a random port, here we force it to attach to port 8080.

stubr stub.json -p 8080 &

call it

Now let's verify our stub server is up and running and that it serves our stubs the right way.

curl -i http://localhost:8080

Which should output:

HTTP/1.1 200 OK
server: stubr(0.4.14)
content-length: 0
date: Fri, 22 Jul 2022 19:31:48 GMT

Hello world !%

or with httpie

http :8080

Hello {name} !

Now let's spice things a bit and make our stub a bit more dynamic by capturing a part of the request path and template it in the response (this is called response templating).

But first let's kill the previous server

lsof -ti tcp:8080 | xargs kill
cat > hello.json <<- EOF
{
  "request": {
    "method": "GET",
    "urlPathPattern": "/hello/(.+)"
  },
  "response": {
    "status": 200,
    "body": "Hello {{request.pathSegments.[1]}} !",
    "transformers": ["response-template"]
  }
}
EOF

Here:

  • "urlPathPattern": "/hello/(.+)" is one way to express URL matching. It contains a regular hardcoded path /hello/ and a regular expression (.+) which has to match in order for the stub response to be served.
  • "transformers": ["response-template"] will activate response templating. This allows you to inject a part of the request in the response. Prefer using it over hardcoded values when your real life application actually does that. The more you use it the better your test accuracy will be.
  • {{request.pathSegments.[1]}} now that response templating is enabled, you can inject parts of your request in the response. With Wiremock as with stubr, we use handlebars templates to do such a thing. Many response templates are available in stubr in order to pick whatever part of the request you want.

Mount it

stubr hello.json -p 8080 &

call it

curl -i http://localhost:8080/hello/stubr

Which should output:

HTTP/1.1 200 OK
server: stubr(0.4.14)
content-type: text/plain
content-length: 13
date: Sat, 23 Jul 2022 09:25:42 GMT

Hello stubr !%

Contract testing

In order to introduce contract testing in the simplest terms, we'll go through a mixed practice interleaved with as much documentation as possible. The main idea behind this testing practice is to be able to quickly spot breaking API changes between a producer and a consumer. All of this process should be offline, deterministic and should not involve spawning any real http application locally !!! If you want to learn more about the concept, I warmly invite you to read what Martin Fowler has to say about it.

It must not be confused with end-to-end tests as it does not aim at all to replace them ; it's the opposite actually, they are complementary. Their goal is to have the shortest feedback loop possible are detect early on mistakes before running those onerous e2e CI pipelines where you'll get the confidence that your code is ready to be shipped in production.

What is it ?

Contract test are not about testing functional things ! To be more explicit, imagine you have a User endpoint with the following json response:

{
  "name": "alice",
  "age": 42
}

Given this endpoint, your contract test will just verify that "name" is a json string and "age" a json number, that's it ! On the other hand, your e2e test will probably create a user Alice with age 42 and then verify that your endpoint returns the exact same data with the same values.

Producer & consumer

graph LR
  C[Consumer] --> |http| P[Producer]

Most of the time you will use this kind of testing when you have many application communicating over http. In this situation we will call:

  • producer: the application exposing a http API (could be REST or SOAP for what it's worth). We will write one (or many) stubs for each http endpoint. We could as well write stubs once and then let them rot. The risk would be that while the API evolves, our stub stops representing it accurately. To prevent that, we will verify the stubs we write i.e. we will generate real http requests from the stubs and run them against a real instance of the application to assert that it does what it claims in the stub. And now we can confidently share our stubs with the outside world and our consumers.
  • consumer: application with a http client calling a producer http application. The consumer first has to fetch stubs exposed by the producer. Then it can mount them with stubr to mock the producer in its unit tests. It brings the consumer the benefits of being able to lean on verified stubs and to carefully pick a specific version of the producer. Along its lifetime, whenever the producer releases a new version, the consumer will be able, even in a semi-automated way (dependabot), to verify it is still compatible with the producer API.

Producer/consumer driven

There are 2 schools when it comes to contract testing: producer-driven & consumer-driven.

In consumer-driven (for example Pact), multiple consumers define the contract which then has to be respected by the producer (summarizing). This has the advantage of taking into consideration every single consumer but has some drawbacks. First, every consumer has to manually write/copy the same producer mock ; imagine a microservice fleet with a producer consumed 100 times, hence its mocks re-defined in all 100 consumers. Then, anytime a consumer produces a new version, a complex CI workflow has to be triggered to verify that the contract is respected by the producer. In conclusion, it is not offline.

Producer-driven (for example Spring Cloud Contract) on the other hand has the advantage of being straightforward. Producer defines the contract once, publishes it and every consumer has to adapt. Compared to consumer-driven, this model is better suited for organizations where a team has ownership of all the applications (when contract definition is easy). It has the major advantage of being offline and not requiring more than 1 definition of the contract. Stubr is producer-driven

As a producer

We'll use actix in those examples because it is the only web framework currently supported for verifying the producer, others will come ! And of course it does not impact the consumer where you can use any technology you want since stubr can be used standalone (you can even use a language other than Rust !).

endpoint

We'll begin in a very common situation where your producer exposes a http endpoint. We will make the endpoint as simple and stupid as possible with a flat Beer resource and an in-memory database. We'll just expose an endpoint for creating and fetching a resource which should cover most of the use cases.

use actix_web::http::header::ContentType;
use actix_web::web;

// simple in-memory database
pub type Database = std::sync::RwLock<std::collections::HashMap<u32, Beer>>;

#[actix_web::post("/beers")]
pub async fn create(mut beer: web::Json<Beer>, db: web::Data<Database>) -> impl actix_web::Responder {
    let exists = db.read().unwrap().iter().any(|(_, b)| &beer.0 == b);
    if !exists {
        let next_id = db.read().unwrap().len() as u32;
        beer.id = Some(next_id);
        db.write().unwrap().insert(next_id, beer.clone());
        actix_web::HttpResponse::Created()
            .content_type(ContentType::json())
            .body(serde_json::to_string(&beer).unwrap())
    } else {
        actix_web::HttpResponse::Conflict()
            .content_type(ContentType::json())
            .body(serde_json::json!({"message": "Beer already exists"}).to_string())
    }
}

#[actix_web::get("/beers/{id}")]
pub async fn find_by_id(path: web::Path<u32>, db: web::Data<Database>) -> impl actix_web::Responder {
    let id = path.into_inner();
    if let Some(beer) = db.read().unwrap().get(&id) {
        actix_web::HttpResponse::Ok()
            .content_type(ContentType::json())
            .body(serde_json::to_string(beer).unwrap())
    } else {
        actix_web::HttpResponse::NotFound()
            .content_type(ContentType::json())
            .body(serde_json::json!({"message": "Beer not found"}).to_string())
    }
}

#[derive(Debug, Clone, Eq, serde::Serialize, serde::Deserialize)]
pub struct Beer {
    pub id: Option<u32>,
    pub name: String,
    pub price: u32,
}

impl PartialEq for Beer {
    fn eq(&self, other: &Self) -> bool {
        self.name == other.name
    }
}

tests

Then as we do things seriously, we will write some tests. For the create endpoint we'll have a nominal case where the beer we create succeeds and another negative one where it fails because we have a uniqueness constraint on the Beer's name and price fields. For the find_by_id one, we'll have a nominal case and a 404 Not Found one.

use actix_web::{
    test::{call_service, init_service, TestRequest},
    web, App,
};
use asserhttp::*;

use actix_producer::api::beer::*;

mod find_by_id {
    use super::*;

    #[actix_web::test]
    async fn find_by_id_should_find_one() {
        let app = App::new()
            .app_data(sample_db())
            .service(find_by_id)
            .wrap(stubr::ActixRecord::default()); // 👈 record
        let beers = sample();
        let (id, to_find) = beers.get(0).unwrap();
        let req = TestRequest::get().uri(&format!("/beers/{id}")).to_request();
        call_service(&init_service(app).await, req)
            .await
            .expect_status_ok()
            .expect_content_type_json()
            .expect_body_json(|b: Beer| assert_eq!(&b, to_find));
    }

    #[actix_web::test]
    async fn find_by_id_should_not_find_any() {
        let app = App::new()
            .app_data(sample_db())
            .service(find_by_id)
            .wrap(stubr::ActixRecord::default()); // 👈 record
        let req = TestRequest::get().uri("/beers/999").to_request();
        call_service(&init_service(app).await, req).await.expect_status_not_found();
    }
}

mod create {
    use super::*;

    #[actix_web::test]
    async fn create_should_create_one() {
        let beer = Beer {
            id: None,
            name: "Heineken".to_string(),
            price: 4,
        };
        let app = App::new()
            .app_data(empty_db())
            .service(create)
            .wrap(stubr::ActixRecord::default()); // 👈 record
        let req = TestRequest::post().uri("/beers").set_json(beer.clone()).to_request();
        call_service(&init_service(app).await, req)
            .await
            .expect_status_created()
            .expect_content_type_json()
            .expect_body_json(|b: Beer| {
                assert!(b.id.is_some());
                assert_eq!(b.name, beer.name);
                assert_eq!(b.price, beer.price);
            });
    }

    #[actix_web::test]
    async fn create_should_conflict_when_already_exists_by_name() {
        let (_id, beer) = sample().get(0).unwrap().clone();
        let app = App::new()
            .app_data(sample_db())
            .service(create)
            .wrap(stubr::ActixRecord::default()); // 👈 record
        let req = TestRequest::post().uri("/beers").set_json(beer.clone()).to_request();
        call_service(&init_service(app).await, req).await.expect_status_conflict();
    }
}

pub fn sample_db() -> web::Data<Database> {
    web::Data::new(std::sync::RwLock::new(sample().into()))
}

pub fn empty_db() -> web::Data<Database> {
    web::Data::new(std::sync::RwLock::new([].into()))
}

pub fn sample() -> [(u32, Beer); 2] {
    [
        (
            0,
            Beer {
                id: Some(0),
                name: "Leffe".to_string(),
                price: 5,
            },
        ),
        (
            1,
            Beer {
                id: Some(1),
                name: "1664".to_string(),
                price: 3,
            },
        ),
    ]
}

recording

Although it is optional, we'll use recording here to easily create one stub for each test. Recording is triggered by the .wrap(stubr::ActixRecord::default()) line. Those recorded stubs will be in target/stubs/localhost.

import stubs

In order for a producer to expose stubs, they have to live in a stubs folder in the root of your project. So copy/paste the 4 recorded stubs into this folder and arrange them a bit (remove recording noise) to match the following.

create
{
  "request": {
    "method": "POST",
    "urlPath": "/beers",
    "headers": {
      "content-type": {
        "equalTo": "application/json"
      }
    },
    "bodyPatterns": [
      {
        "equalToJson": {
          "name": "Heineken",
          "price": 4
        }
      }
    ]
  },
  "response": {
    "status": 201,
    "jsonBody": {
      "id": 2,
      "name": "Heineken",
      "price": 4
    },
    "headers": {
      "content-type": "application/json"
    }
  }
}
create with name conflict
{
  "request": {
    "method": "POST",
    "urlPath": "/beers",
    "headers": {
      "content-type": {
        "equalTo": "application/json"
      }
    },
    "bodyPatterns": [
      {
        "equalToJson": {
          "name": "Leffe",
          "price": 5
        }
      }
    ]
  },
  "response": {
    "status": 409,
    "jsonBody": {
      "message": "Beer already exists"
    },
    "headers": {
      "content-type": "application/json"
    }
  }
}
find by id
{
  "request": {
    "method": "GET",
    "urlPath": "/beers/0"
  },
  "response": {
    "status": 200,
    "jsonBody": {
      "id": 0,
      "name": "Leffe",
      "price": 5
    },
    "headers": {
      "content-type": "application/json"
    }
  }
}
find by id not found
{
  "request": {
    "method": "GET",
    "urlPath": "/beers/404"
  },
  "response": {
    "status": 404,
    "jsonBody": {
      "message": "Beer not found"
    },
    "headers": {
      "content-type": "application/json"
    }
  }
}

verify

And finally, we have to verify that the stubs exposed by our producer match the actual implementation. To do so, stubr exports the StubrVerify trait with the .verify() method you have to invoke. There is no automatic verification of stubs possible, it has to be explicit in a test. It is advised to declare it in a file with just the verification test.

Such a test will start by declaring your actix app with all the endpoints. In order to verify it, stubr will create a test for every stub in ./stubs by converting, for each, the request part in an actual actix integration test.

But you might need some state ! For example, think of the find by id endpoint. It cannot be verified if your database is empty. Likewise, stubs are verified in no particular order (since anyway your endpoint are most likely stateless, right ?). Executing some tests (for example a "delete" endpoint) might affect others. So before each test, we have to reset our application state. You can do that with the stubr::ActixVerifyLifecycle middleware, for example here to wipe our database then populate it with our sample data (it is recommended to reuse the same as in your tests).

Finally, call .verify() (a bit different in our example) to launch the verification test. If it passes, you have the guarantee your stubs accurately represent your application API.

use crate::api::beer::*;
use actix_producer::api::beer::*;

#[actix_web::test]
async fn should_verify() {
    use stubr::StubrVerify as _;

    actix_web::App::new()
        .app_data(sample_db())
        .service(create)
        .service(find_by_id)
        // reset application state
        .wrap(stubr::ActixVerifyLifecycle::<Database>(|db| {
            let mut db = db.write().unwrap();
            db.clear();
            for (i, beer) in sample() {
                db.insert(i, beer);
            }
        }))
        // required because this sample lives in a project with other stubs.
        // Otherwise just use '.verify()'
        .verify_except(|stub_name: &str| stub_name.starts_with("pet-"))
        .await;
}

Now let's use those stubs in a consumer.

As a consumer

We will now consume the stubs we have verified on the producer side. It actually does not change from when you mount regular stubs, you will just use different helpers here.

importing

So we have some stubs defined in a Rust project, let's call it actix-producer. On naive way to deal with this would be to simply copy/paste stubs from producer to consumer. That'd work, obviously ; but we would be too cumbersome to maintain and way too error-prone. We need a more automated way to import those stubs.

Here comes stubr-build. It's a simple build dependency which will scan your build dependencies and look for producers (projects with a root stubs folder with json stubs underneath). For each, it will copy/paste those stubs under target/stubr/{consumer-name}/{producer-name}. This default location will be used later on to mount the stubs.

To begin with, add stubr-build to your build dependencies. Then also add the producers (here we will use actix-producer).

[build-dependencies]
stubr-build = "0.6.2"
actix-producer = "0.1.0"

Then, in a build script, invoke stubr-build and do cargo build

fn main() {
    stubr_build::stubr_consumer()
}

Verify your stubs have been imported (given your consumer project is called actix-consumer), you should have something like this under target/stubr:

├── actix-consumer
│ └── actix-producer
│   ├── beer-create-conflict-name.json
│   ├── beer-create.json
│   ├── beer-find-by-id-not-found.json
│   └── beer-find-by-id.json

consuming

At this point, your consumer app could be anything: either another actix application, or a web application using a different framework, or a simple cli or batch relying on a http client to call your producer. It does not matter. Here, we'll assume the simplest use case (a simple blocking http client using reqwest) but it does not make any difference.

We will use the apps attribute macro to mount the stubs we just imported in our stub server. And for our tests. we will just import the stubs of the actix-producer app we created previously. To do so, add #[stubr::apps("actix-producer")] on your test method (note that you can use it to mount many apps e.g. #[stubr::apps("svc-a", "svc-b")]). This will create a local binding with the name of your app.

#[test]
#[stubr::apps("actix-producer")]
fn sample_binding() {
    let actix_producer: stubr::Stubr = actix_producer;
    let _uri: String = actix_producer.uri();
}

You can then use this binding to get the uri(s) of the mock server(s) and execute your tests against it.

use asserhttp::*;
use serde_json::json;

#[test]
#[stubr::apps("actix-producer")]
fn find_by_id_should_find_one() {
    let uri: String = actix_producer.uri();
    let beer_id = 0;
    reqwest::blocking::get(format!("{uri}/beers/{beer_id}"))
        .expect_status_ok()
        .expect_content_type_json()
        .expect_body_json_eq(json!({
            "id": 0,
            "name": "Leffe",
            "price": 5
        }));
}

#[test]
#[stubr::apps("actix-producer")]
fn find_by_id_should_not_find_any() {
    let uri: String = actix_producer.uri();
    reqwest::blocking::get(format!("{uri}/beers/404"))
        .expect_status_not_found()
        .expect_content_type_json()
        .expect_body_json_eq(json!({
            "message": "Beer not found"
        }));
}

#[test]
#[stubr::apps("actix-producer")]
fn create_should_create_one() {
    let uri: String = actix_producer.uri();
    reqwest::blocking::Client::new()
        .post(format!("{uri}/beers"))
        .json(&json!({
            "name": "Heineken",
            "price": 4
        }))
        .send()
        .expect_status_created()
        .expect_content_type_json()
        .expect_body_json(|beer: serde_json::Value| {
            assert!(beer.get("name").unwrap().is_string());
            assert!(beer.get("price").unwrap().is_u64());
        });
}

#[test]
#[stubr::apps("actix-producer")]
fn create_should_should_conflict_on_name() {
    let uri: String = actix_producer.uri();
    reqwest::blocking::Client::new()
        .post(format!("{uri}/beers"))
        .json(&json!({
            "name": "Leffe",
            "price": 5
        }))
        .send()
        .expect_status_conflict()
        .expect_content_type_json()
        .expect_body_json_eq(json!({
            "message": "Beer already exists"
        }));
}

And that's all folks ! We have consumed our producer stubs. But those stubs only have hardcoded values and will be hard to change and maintain. We'll now see how to relax them.

Relaxing your stubs

Whenever you are maintaining a large cohort of interconnected services, you more often than not will opt for Test Data e.g. a user with firstname john and lastname doe. And then use this same sample user in all your services tests. That works actually quite well. That's actually what we did previously, for example

in this stub
{
  "request": {
    "method": "POST",
    "urlPath": "/beers",
    "headers": {
      "content-type": {
        "equalTo": "application/json"
      }
    },
    "bodyPatterns": [
      {
        "equalToJson": {
          "name": "Heineken",
          "price": 4
        }
      }
    ]
  },
  "response": {
    "status": 201,
    "jsonBody": {
      "id": 2,
      "name": "Heineken",
      "price": 4
    },
    "headers": {
      "content-type": "application/json"
    }
  }
}

With such a stub, we can imagine we would end up with a test in our consumer like that:

#[test]
#[stubr::apps("actix-producer")]
fn create_should_create_one() {
    let uri: String = actix_producer.uri();
    reqwest::blocking::Client::new()
        .post(format!("{uri}/beers"))
        .json(&json!({
            "name": "Heineken",
            "price": 4
        }))
        .send()
        .expect_status_created()
        .expect_content_type_json()
        .expect_body_json(|beer: serde_json::Value| {
            assert!(beer.get("name").unwrap().is_string());
            assert!(beer.get("price").unwrap().is_u64());
        });
}

Notice the request data in this test. Here, this sample is really dummy but in a real application, name is likely to come from the consumer's own Test Data. But it cannot be any value ; remember, the producer API requires name to be unique. So both producer and consumer Test Data, are now tightly coupled. We are also having specific expectations regarding the response, for example we are expecting the beer name to be Heineken and its price 4 (in this situation you could also relax your test, but sometimes you can't). And in a big system, your consumer is probably also a producer, so all its stubs are also going to contain Heineken and 4.

When things change

Things always change. Sometimes, their format does, for example our beer price can change from 4 to "4.00" ; that's fine, contract testing is made to catch those changes. But imagine if, for whatever functional reason, now prices in your system can no longer contain 0 cent by needs to be 99 cent: all your Test Data have to change. Now imagine your system is made of hundreds of microservices, all requiring this beer service. That's a shame, and all because some Test Data in a single service has changed.

This could have been avoided if we had taken some time to make our stubs relax its request expectation and also return randomized response data.

Relaxing fields

Here we will get rid of all the hardcoded data in the response (currently there are not enough helpers to also relax the request data with stubr, but it will be possible one day).

We will first randomize the id with anyU32. On the consumer side, this will generate a random u32. On the producer side in the verifier tests, it simply asserts that the field is a u32.

Now, for the other fields, those are directly taken from the request, so we'll use response templating helpers to forward them from the request.

We end up with a stub like this:

{
  "request": {
    "method": "POST",
    "urlPath": "/beers",
    "headers": {
      "content-type": {
        "equalTo": "application/json"
      }
    },
    "bodyPatterns": [
      {
        "equalToJson": {
          "name": "Heineken",
          "price": 4
        }
      }
    ]
  },
  "response": {
    "status": 201,
    "jsonBody": {
      "id": "{{anyI32}}",
      "name": "{{jsonPath request.body '$.name'}}",
      "price": "{{jsonPath request.body '$.price'}}"
    },
    "headers": {
      "content-type": "application/json"
    },
    "transformers": ["response-template"]
  }
}

Stubs

Writing stubs can be challenging and time-consuming. Stubr tries to assist by providing IDE completion or by recording live traffic into stubs. But you still have to know how to write a stub and what helpers you have in order to relax your stubs as much as possible.

You will find here in a single snippet ALL the fields/helpers available to you:

{
  "id": "82d86e05-9ee0-44ca-9a8d-1fc6f719437e", // (optional) unique stub identifier. Returned in 'Matched-Stub-Id' header
  "priority": 1, // (optional) helps solving interlaced conditions (many stubs match the request). 1 is the highest priority, 255 the lowest
  "request": {
    "method": "GET", // (optional) http method. Can be "ANY" to match any method. Defaults to "ANY"
    "urlPath": "/api/exact-uri", // exact URI match
    "urlPathPattern": "/api/regex-uri/([a-z]{4})", // URI must match regex
    "urlPattern": "/api/regex-uri/([a-z]{4})\\?and=([a-z]{4})", // URI & query must match regex
    "url": "/api/uri?age=young", // raw URI + query parameters by equality matching
    "queryParameters": {
      "firstname": { "equalTo": "beltram" }, // by equality matching (can also be an int, or a boolean)
      "lastname": { "equalTo": "maldant", "caseInsensitive": true }, // case insensitve equality
      "age": { "absent": true }, // must be absent
      "city": { "contains": "a" }, // must contain the letter 'a'
      "title": { "matches": "([A-Za-z]+)" }, // must match regex
      "job": { "doesNotMatch": "([A-Za-z]+)" }, // or must not match regex
    },
    "headers": {
      "content-type": { "equalTo": "application/json" } // by equality matching
      // .. then all matchers described above for query parameters are also applicable here
    },
    "basicAuth" : { // exact Basic authentication matching
      "username": "user",
      "password": "pass"
    },
    "jwtAuth": {
      "equalTo": "eyJhbGciOiJSUzI1NiJ9.e30.MBkQ...", // plain JWT token
      "alg": {
        "equalTo": "RS256", // JWT algorithm by equality matcher
        "oneOf": ["RS256", "HS256"] // JWT must contain one of these algorithms
      },
      "payloadPatterns": [
        // all matchers available in 'bodyPatterns' ⬇️
      ]
    },
    "bodyPatterns": [
      { "equalToJson": {"name": "bob"} }, // strict json request body equality
      { "equalToJson": {"name": "bob"}, "ignoreExtraElements": true }, // ignore extra json fields supplied in request body. Default to false.
      { "equalToJson": {"name": "bob"}, "ignoreArrayOrder": true }, // ignore array items order. Default to false.
      { "matchesJsonPath": "$.name" }, // must just match json path
      { "matchesJsonPath": "$.consoles[?(@.name == 'xbox')]" }, // must match json path + equality
      { "matchesJsonPath": "$.consoles[?(@.price > 200)]" }, // must match json path + bound
      { "expression": "$.name", "contains": "at" }, // must match json path + contain the string 'at'
      { "expression": "$.user", "equalToJson": { "name": "bob" } }, // must match json path + be equal
      { "binaryEqualTo": "AQID" /* Base 64 */ } // byte array equality
    ]
  },
  "response": {
    "status": 200, // (required) response status
    "fixedDelayMilliseconds": 2000, // delays response by 2 seconds
    "delayDistribution": { // a random delay..
      "type": "lognormal", // ..with logarithmic distribution
      "median": 100, // The 50th percentile of latencies in milliseconds
      "sigma": 0.1 // Standard deviation. The larger the value, the longer the tail
    },
    "jsonBody": { // json response body (automatically adds 'content-type:application/json' header)
      "name": "john",
      "surnames": [ "jdoe", "johnny" ]
    },
    "body": "Hello World !", // text response (automatically adds 'Content-Type:text/plain' header)
    "base64Body": "AQID", // binary Base 64 body
    "bodyFileName": "tests/stubs/response.json", // path to a .json or .txt file containing the response
    "bodyFileName": "tests/stubs/{{request.pathSegments.[1]}}.json", // supports templating
    "headers": {
      "content-type": "application/pdf" // returns this response header
    },
    // ..now response templating
    // it uses handlebars and allows you to define dynamic response based upon the content of the request
    // it can be used in "jsonBody", "body", "bodyFileName" or "headers"
    "transformers": ["response-template"], // required to activate response templating
    "jsonBody": {
      "url-path-and-query": "{{request.url}}",
      "url-path": "{{request.path}}",
      "url-path-segments": "{{request.pathSegments.[1]}}", // returns 'two' given '/one/two/three' path
      "query": "{{request.query.kind}}", // returns 'comics' given '/api/books?kind=comics'
      "multi-query": "{{request.query.kind.[1]}}", // returns 'novel' given '/api/books?kind=comics&kind=novel'
      "method": "{{request.method}}", // http request method e.g. "POST"
      "header": "{{request.headers.Content-Type}}", // returns request header with given key
      "multi-header": "{{request.headers.cache-control.[0]}}", // returns first value of "cache-control" values
      "body": "{{request.body}}", // returns raw request body
      "from-request": "{{jsonPath request.body '$.name'}}", // takes field 'name' from json request body
      "now": "{{now}}", // current datetime (UTC)
      "now-fmt": "{{now format='yyyy/MM/dd'}}", // with custom Java SimpleDateFormat
      "now-fmt-epoch": "{{now format='epoch'}}", // epoch in milliseconds
      "now-fmt-unix": "{{now format='unix'}}", // epoch in seconds
      "now-positive-offset": "{{now offset='3 days'}}", // human time positive offset
      "now-negative-offset": "{{now offset='-3 days'}}", // human time negative offset
      "now-with-timezone": "{{now timezone='Europe/Rome'}}",
      "number-is-odd": "{{isOdd 3}}", // or 'isEven'
      "string-capitalized": "{{capitalize mister}}", // or 'decapitalize'
      "string-uppercase": "{{upper mister}}", // or 'lower'
      "string-replace": "{{replace request.body 'a' 'b'}}", // e.g. given "Handlebars" in request body returns "Hbndlebbrs"
      "number-stripes": "{{stripes request.body 'if-even' 'if-odd'}}",
      "string-trim": "{{trim request.body}}", // removes leading & trailing whitespaces
      "size": "{{size request.body}}", // string length or array length
      "base64-encode": "{{base64 request.body padding=false}}", // padding is optional and defaults to true
      "base64-decode": "{{base64 request.body decode=true}}",
      "url-encode": "{{urlEncode request.header.x-raw}}",
      "url-decode": "{{urlEncode request.header.x-encoded decode=true}}",
      // you can also use 'any*' helpers. They will produce a random value
      "regex": "{{anyRegex '[a-z]{4}'}}", // generate a random string matching regex
      "string": "{{anyNonEmptyString}}", // or '{{anyNonEmptyString}}'
      "alphanum": "{{anyAlphaNumeric}}",
      "boolean": "{{anyBoolean}}",
      "uuid": "{{anyUuid}}",
      "ip": "{{anyIpAddress}}", // e.g. '127.0.0.1'
      "host": "{{anyHostname}}", // e.g. 'https://github.com'
      "email": "{{anyEmail}}", // e.g. 'john.doe@gmail.com'
      "enum": "{{anyOf 'alpha' 'beta' 'gamma'}}", // returns randomly one of those 3 values
      "number": "{{anyNumber}}", // integer or float 
      "integer": "{{anyI32}}", // also all Rust int types (u32, u8, i64 etc..)
      "float": "{{anyFloat}}",
      "anyDate": "{{anyDate}}" // or 'anyTime', 'anyDatetime', 'anyIso8601'
    }
  }
}

Request

With request matching you have to describe all the conditions the incoming http requests have to match in order for your stub response to be served. Most of the time, you will opt in for a conservative approach where you will have exhaustive and strict conditions. That's when you want to assess the http caller behaves the right way. Other times you do not care about request matching at all e.g. you use stubr to benchmark a reverse proxy: in that case request { "method": "ANY" } is enough. Just write the request matching you need.

Method

Expects the request method. Use ANY when you do not care which method it will be.
Available verbs are GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE, PATCH

Note: method is optional and defaults to ANY

{
  "request": {
    "method": "GET"
  }
}

URI

To match request's URI (and maybe its query parameters). Only one of the following matcher is allowed. If more than one are present, it does not fail but chooses one matcher according to the descending priority url > urlPath > urlPattern > urlPathPattern

{
  "request": {
    "url": "/api/uri?age=young",
    "urlPath": "/api/exact-uri",
    "urlPattern": "/api/regex-uri/([a-z]{4})\\?and=([a-z]{4})",
    "urlPathPattern": "/api/regex-uri/([a-z]{4})"
  }
}
  • url: Matches by equality the URI and query parameters.
  • urlPath: Matches by equality just the URI without query parameters.
  • urlPattern: Matches URI and query parameters. Path segments and query parameters value can contain regexes.
  • urlPathPattern: Matches just the URI without query parameters. Path segments can contain regexes.

Query parameters

Allows matching query parameters. Prefer this instead of URI matching just because it is clearer. Multivalued query parameters are not supported yet.

{
  "request": {
    "queryParameters": {
      "firstname": { "equalTo": "beltram" },
      "lastname": { "equalTo": "maldant", "caseInsensitive": true },
      "age": { "absent": true },
      "birthdate": { "absent": false },
      "city": { "contains": "at" },
      "title": { "matches": "([A-Za-z]+)" },
      "job": { "doesNotMatch": "([A-Za-z]+)" }
    }
  }
}
  • equalTo by equality matcher. Can be a string, a boolean, a number, null etc... Can be turned case-insensitive with caseInsensitive.
  • absent specified query parameter key must be absent/present.
  • contains value must contain the supplied string in a case-insensitive way
  • matches/doesNotMatch value must match the supplied regex (or not)

Headers

Header matcher are exactly the same as query parameter matcher above.

{
  "request": {
    "headers": {
      "content-type": { "equalTo": "application/json" }
    }
  }
}

Authorization

Those matcher are exclusive to stubr and not available in Wiremock. They allow crafting more relaxed request matchers when it comes to authorization. You could for example have stubs specialized for a specific user (we sometimes persona).

You can have matchers for Basic authentication (RFC 7617). For example, for matching Authorization: Basic am9obi5kb2U6Y2hhbmdlbWU= you would have:

{
  "request": {
    "basicAuth": {
      "username": "john.doe",
      "password": "changeme"
    }
  }
}

You can also match a JWT token in the Authorization header as per RFC 7519

{
  "request": {
    "jwtAuth": {
      "equalTo": "eyJhbGciOiJSUzI1NiJ9.e30.MBkQ...",
      "alg": {
        "equalTo": "RS256",
        "oneOf": [ "RS256", "HS256" ]
      },
      "payloadPatterns": [
        { "equalToJson": { "iss": "john.doe" } },
        { "equalToJson": { "exp": 1300819380 } },
        {
          "expression": "$.address",
          "equalToJson": { "street": "rue de Rivoli", "city": "Paris" }
        }
      ]
    }
  }
}
  • equalTo by equality matcher. Equivalent to "headers":{"authorization":{"equalTo": "..."}}. If you have this matcher, all the other ones will be ignored
  • alg.equalTo by equality matcher. JWT algorithm has to be exactly this
  • alg.oneOf JWT algorithm has to be one of the supplied values. Here are all the supported JWT algorithms: HS256, HS384, HS512, ES256, ES384, RS256, RS384, RS512, PS256, PS384, PS512, EdDSA
  • payloadPatterns for matching the JWT body. Exactly the same matcher as body ones.

Body

{
  "request": {
    "bodyPatterns": [
      { "equalToJson": { "name": "bob" } },
      {
        "equalToJson": {"names": ["alice", "bob"]},
        "ignoreExtraElements": true,
        "ignoreArrayOrder": true
      },
      { "matchesJsonPath": "$.name" },
      { "matchesJsonPath": "$.consoles[?(@.name == 'xbox')]" },
      { "matchesJsonPath": "$.consoles[?(@.price > 200)]" },
      { "expression": "$.name", "contains": "at" },
      { "expression": "$.user", "equalToJson": { "name": "bob" } },
      { "expression": "$.age", "equalToJson": 42 },
      { "binaryEqualTo": "AQID" }
    ]
  }
}
  • equalToJson strict equality matcher. Request body has to be exactly equal to this. If it is not used with expression, all other matchers will be ignored. However, it can be relaxed with:
    • ignoreExtraElements to ignore json fields in the http request not present in the matcher
    • ignoreArrayOrder to match json arrays regardless the order of their items
  • expression a JSONPath matcher used to narrow the matching. The matched expression has then to be verified by either:
    • equalToJson for strict equality (can be another json object, a string, number etc..)
    • contains ; if json matched by expression is a string it must contain the supplied string
  • matchesJsonPath json request body has to contain the supplied key identified by a JSONPath. You can also use JSONPath expression to also filter and match the json values
  • binaryEqualTo byte equality matcher. Has to be base 64 encoded

Priority

Sometimes, you can have 2 different stubs that could both match a given http request. This happens most of the time when you start writing stubs for your application errors. You basically should have:

  • one relaxed stub for your nominal case matching for example "urlPathPattern": "/users/([0-9]{4})"
  • one stub for each error with hardcoded value e.g. "urlPath": "/users/1234" for a 404 response

The issue here is that if your stub server receives a GET /users/1234 request, both stubs will match. You want your error stub to have a higher than the nominal e.g. error stub will have a priority of 1 whereas the nominal one will have a priority of 2.

{
  "priority": 1
}
  • priority a u8. 1 is the highest priority, 255 the lowest, 5 the default value when absent. It is optional.

Response

In the response part, you have to define the actual http response the stub server will serve when the stub matches the incoming http request as we defined it with request matching

Status

The http response status. In the range [100..599].

{
  "response": {
    "status": 200
  }
}

Http response headers. Note that keys are case-insensitive. Multivalued headers are not supported yet. You can use response templating here as well if you add "transformers": ["response-template"].

{
  "response": {
    "transformers": [
      "response-template"
    ],
    "headers": {
      "content-type": "application/json",
      "ETag": "33a64df551425fcc55e4d42a148795d9f25f89d4",
      "location": "{{request.url}}/1234"
    }
  }
}

Body

There are different ways to define a http response. We'll just focus here on supplying hardcoded values in the response, but you can relax all those fields with templates. We'll see that immediately in the next chapter.

{
  "response": {
    "body": "Hello World !",
    "base64Body": "AQID",
    "bodyFileName": "tests/stubs/response.json",
    "jsonBody": {
      "name": "john",
      "surnames": [
        "jdoe",
        "johnny"
      ]
    }
  }
}
  • body use this one if you have a text body or anything simple. If the body is large you'd better opt for bodyFileName.
  • base64Body if the body is not utf-8 encoded use it to supply a body as byte. Those have to be base 64 encoded.
  • bodyFileName when the response gets large or to factorize some very common bodies, it is sometimes preferable to extract it in a file. When using it in a Rust project, the file path is relative to the workspace root. You can also use templating to dynamically select a file.
  • jsonBody when the body is json. Even though such a body can be defined with all the previous fields, it is more convenient to define a json response body here.

Relaxed field

Using only hardcoded values is a good way to start mocking things. But as time goes on, your project might start to get bloated with a lot of stubs. You will also see the limit of hardcoded values when doing contract testing.

In order to "relax" your stub, you will have to use Handlebars helpers. They will allow you to have random values generated for you, because, most of the time, that's what the actual application does. And, as a consumer, you also don't care about the actual value of this field in your test i.e. "age": "{{anyU8}}" will work in all your unit tests because none of your unit tests expects a particular value for this field.

In order to use a Handlebars helper, you need to add "transformers": ["response-template"].

Keep in mind that such helper will also be used to generate assertions when you will be using this stub for contract testing while verifying your producer.

NB: those templates are not available in Wiremock, you can only use them in stubr.

{
  "response": {
    "transformers": [
      "response-template"
    ],
    "jsonBody": {
      "regex": "{{anyRegex '[a-z]{4}'}}",
      "string": "{{anyNonEmptyString}}",
      "alphanum": "{{anyAlphaNumeric}}",
      "boolean": "{{anyBoolean}}",
      "uuid": "{{anyUuid}}",
      "ip": "{{anyIpAddress}}",
      "host": "{{anyHostname}}",
      "email": "{{anyEmail}}",
      "enum": "{{anyOf 'alpha' 'beta' 'gamma'}}",
      "number": "{{anyNumber}}",
      "integer": "{{anyI32}}",
      "float": "{{anyFloat}}",
      "anyDate": "{{anyDate}}"
    }
  }
}
  • anyRegex generates a value matching this regex. Tip: most of the time will be used for strings but if this regex defines an integer, a float or a boolean and is used in "jsonBody"" the generated value will be cast
  • anyNonEmptyString or anyNonBlankString generates an arbitrary utf-8 string
  • anyAlphaNumeric generates an arbitrary string with only alphanumeric characters
  • anyBoolean generates either true or false
  • anyUuid generates a random UUIDv4
  • anyIpAddress generates a random IP address e.g. 127.0.0.1
  • anyHostname generates an arbitrary hostname e.g. https://github.com
  • anyEmail generates a random valid email address e.g. john.doe@gmail.com
  • anyOf given the supplied values, will pick one randomly. Only works for strings.
  • anyNumber when one does not care about the number size, generates either an integer or a float
  • anyI32 or anyU32 etc.. generates a random integer. Possible values are: anyU64, anyI64, anyU32, anyI32, anyU16, anyI16, anyU8, anyI8
  • anyFloat generates a random float
  • anyDate generates a date with format yyyy-mm-dd
  • anyTime generates a time with format hh:mm:ss
  • anyDatetime generates a datetime with format yyyy-mm-ddThh:mm:ss
  • anyIso8601 generates an iso-8601 compliant datetime

Response templating

Another kind of relaxing you can do is by being able to represent as best as possible the actual http response of your app. Very often, a field in the response is the exact same as the one in the request e.g. in a POST request to create a REST resource. You can use in your response parts of the request to do so.

{
  "response": {
    "transformers": [
      "response-template"
    ],
    "jsonBody": {
      "url-path-and-query": "{{request.url}}",
      "url-path": "{{request.path}}",
      "url-path-segments": "{{request.pathSegments.[1]}}",
      "query": "{{request.query.kind}}",
      "multi-query": "{{request.query.kind.[1]}}",
      "method": "{{request.method}}",
      "header": "{{request.headers.Content-Type}}",
      "multi-header": "{{request.headers.cache-control.[0]}}",
      "body": "{{request.body}}",
      "from-request": "{{jsonPath request.body '$.name'}}"
    }
  }
}
  • request.url given a request to http://localhost/api/path?a=b returns path?a=b
  • request.path given a request to http://localhost/api/path?a=b returns api/path
  • request.pathSegments.[i] allows picking a part of the url path (i is zero indexed) e.g. http://localhost/a/b/c with i == 1 returns b
  • query.<selector>.[i] allows picking a named query parameter. Replace <selector> by the name of the query parameter. If the query parameter is multivalued, you can select only one with the zero indexed i. For example with http://localhost?a=1&a=2&a=3&b=1 then {{query.b}} returns 1 and {{query.a.[1]}} returns 2
  • request.method returns the (uppercase) http request method. If you want the lowercase method just {{lower request.method}}
  • request.headers.<selector>.[i] about the same as picking query parameters. Note that here selector is case-insensitive.
  • request.body takes the raw request body without altering it
  • jsonPath request.body '<json-path>' for templating only a field from request's json body. json-path is the JSONPath for selecting the right field. Use an online JSONPath evaluator to try out your paths.

You also sometimes have to generate dynamic data or to transform existing one:

{
  "response": {
    "transformers": [
      "response-template"
    ],
    "jsonBody": {
      "now": "{{now}}",
      "now-fmt": "{{now format='yyyy/MM/dd'}}",
      "now-fmt-epoch": "{{now format='epoch'}}",
      "now-fmt-unix": "{{now format='unix'}}",
      "now-positive-offset": "{{now offset='3 days'}}",
      "now-negative-offset": "{{now offset='-3 days'}}",
      "now-with-timezone": "{{now timezone='Europe/Rome'}}",
      "number-is-odd": "{{isOdd request.body}}",
      "number-stripes": "{{stripes request.body 'if-even' 'if-odd'}}",
      "string-capitalized": "{{capitalize request.body}}",
      "string-uppercase": "{{upper request.body}}",
      "string-replace": "{{replace request.body 'a' 'b'}}",
      "string-trim": "{{trim request.body}}",
      "size": "{{size request.body}}",
      "base64-encode": "{{base64 request.body padding=false}}",
      "base64-decode": "{{base64 request.body decode=true}}",
      "url-encode": "{{urlEncode request.header.x-raw}}",
      "url-decode": "{{urlEncode request.header.x-encoded decode=true}}"
    }
  }
}
  • now by default return the current datetime in RFC 3339 format (this is only for backward compatibility with Wiremock)
    • format could be either:
      • a custom Java SimpleDateFormat ( for Wiremock compatibility) e.g. format='yyyy/MM/dd'
      • epoch Unix timestamp in milliseconds
      • unix Unix timestamp in seconds
    • offset now with the given offset expressed in human-readable format. Refer to humantime documentation for further examples.
    • timezone for using a string timezone ( see list)
  • isOdd or isEven returns a boolean whether the numeric value is an even or odd integer
  • capitalize first letter to uppercase e.g. mister becomes Mister. There's also a decapitalize to do the opposite.
  • upper or lower recapitalizes the whole word
  • replace for replacing a pattern with given input e.g. {{replace request.body 'a' 'b'}} will replace all the a in the request body with b
  • stripes returns alternate values depending if the tested value is even or odd
  • trim removes leading & trailing whitespaces
  • size returns the number of bytes for a string (⚠️ not the number of characters) or the size of an array
  • base64 for standard (no base64 url encoding yet) Base64 encoding
    • decode for decoding when true
    • padding with/without padding
  • urlEncode for url encoding the value. Use decode=true to decode

Simulate fault

You can also use stubr to simulate http server runtime behaviour. And most of the time you'll want to introduce latencies to check how your consuming application reacts to such delays. Currently, the options are quite sparse but should grow !

{
  "expect": 2,
  "response": {
    "fixedDelayMilliseconds": 2000
  },
  "delayDistribution": {
    // a random delay with logarithmic distribution
    "type": "lognormal",
    "median": 100,
    // The 50th percentile of latencies in milliseconds
    "sigma": 0.1
    // Standard deviation. The larger the value, the longer the tail
  }
}
  • expect will allow to verify that your unit test has not called the given stub more than N times. Turn it on like this stubr::Stubr::start_with(stubr::Config { verify: true, ..Default::default() }) or #[stubr::mock(verify = true)] with the attribute macro
  • fixedDelayMilliseconds a delay (in milliseconds) added everytime this stub is matched. If you are using stubr standalone through the cli, this value can be either superseded by --delay or complemented by --latency
  • delayDistribution for random delays (always in milliseconds), use type to choose the one
    • lognormal is a pretty good approximation of long tailed latencies centered on the 50th percentile. Try different values to find a good approximation.
      • median: the 50th percentile of latencies in milliseconds
      • sigma: standard deviation. The larger the value, the longer the tail.

Recording

Writing stubs by hand can be... well, painful. More often than not, you already have an existing app ready and you just want to mock it for consumers. Or you are from those who prefer writing code before tests. Or you are lazy 😅.

All those are valid reasons and in order to help you with that, you can record real http traffic and turn it into json stub files. If you are not at all working on Rust projects, you can record using the cli to capture traffic from for example a real application in production written in Java. If you are in a Rust project and use a http client library, you can record its traffic in tests for actix, isahc or reqwest. And if your favorite http client is not is this list you can still record using a standalone recorder. Recording in a Rust consumer is mostly about getting faster, especially with "fat" endpoints with hundreds of fields.

Recording your actix app

You can plug recording in your existing actix integration tests by just adding a single line: .wrap(stubr::ActixRecord::default()). This will register a middleware which will capture the http request and response, then dump them under ./target/stubs/localhost.

This requires the record-actix feature.

use actix_web::test::{call_service, init_service, TestRequest};
use asserhttp::*;

#[actix_web::test]
async fn record_actix() {
    let app = actix_web::App::new()
        .route("/", actix_web::web::get().to(|| async { actix_web::HttpResponse::Ok().await }))
        // just add this 👇
        .wrap(stubr::ActixRecord::default()); // or `ActixRecord(RecordConfig)` for configuring it
    let req = TestRequest::get().uri("/").to_request();
    let svc = init_service(app).await;
    call_service(&svc, req).await.expect_status_ok();
}

Recording with the cli

In order to record http traffic, stubr can act as a proxy to dump this traffic into json stubs on your local filesystem. Recording can be started with the stubr record command. Stubs will be grouped by hosts. You can then play them back using stubr.

argaboutexamples
--portProxy port. Defaults to 3030.stubr --port 3031 or stubr -p 3031
--outputFile path where recorded stubs are stored. Default to current directory.stubr --port record-1 or stubr -o record-1

example

First, start stubr recorder on port 3030. It will act as a proxy.

stubr record -p 3030

We are going to consume a publicly available endpoint returning a list of sample users. We'll use curl to make this http call, and we will configure it to use our recorder as a proxy.

curl jsonplaceholder.typicode.com/users --proxy http://localhost:3030

You should have a stub under jsonplaceholder.typicode.com/users-*.json following the pattern {domain}/{path}-{md5-hash}.json.

NB: That way of recording is less intrusive than if you had to do it with wiremock, and you can configure. Most of the tools e.g. curl, JMeter, k6 or simply your web browser support configuring a http proxy (and more often than not, just by setting an environment variable, leaving your tests/scripts untouched).

Recording your isahc calls

The only way currently to record isahc is to spawn a proxy and configure isahc to use this proxy. This is exactly what the following snippet does.

This requires the record-isahc feature.

use asserhttp::*;

#[tokio::test(flavor = "multi_thread")] // 👈 required by recording proxy
#[stubr::mock("ping.json")] // 👈 spawn a mock server
async fn record_isahc() {
    // 👇 spawn the recording proxy
    stubr::Stubr::record() // or `record_with()` for configuring it
        // 👇 builds an isahc client with proxy configured
        .isahc_client()
        .get(stubr.uri())
        .expect_status_ok();
}

You can find your recorded stubs under ./target/stubs/localhost

Recording your reqwest calls

You have 2 ways to record reqwest http calls ; either with the stubr::Record trait (highly recommended) or the "original" way, still supported, with a standalone recording proxy.

This requires the record-reqwest feature.

use asserhttp::*;
use stubr::Record as _;

#[test]
#[stubr::mock("ping.json")] // 👈 spawn a mock server
fn record_reqwest_trait() {
    // recording unfortunately requires using reqwest's builder hence the syntax is a bit verbose
    let req = reqwest::blocking::ClientBuilder::new().build().unwrap()
        .get(stubr.uri())
        // 👇 this will intercept and dump all http traffic going through this client
        .record() // or `record_with()` for configuring it
        .build().unwrap();
    reqwest::blocking::Client::default().execute(req).unwrap().expect_status_ok();
}

You can find your recorded stubs under ./target/stubs/localhost

NB: async is not supported yet

standalone

use asserhttp::*;

#[tokio::test(flavor = "multi_thread")] // 👈 required by recording proxy
#[stubr::mock("ping.json")] // 👈 spawn a mock server
async fn record_reqwest() {
    // 👇 spawn the recording proxy
    stubr::Stubr::record() // or `record_with()` for configuring it
        // 👇 builds a reqwest client with proxy configured
        .reqwest_client()
        .get(stubr.uri())
        .send()
        .await
        .expect_status_ok();
}

You can find your recorded stubs under ./target/stubs/localhost

Recording standalone

If you don't fall into one of the previous boxes (for example if you use another http client), you can still record the http traffic by using the standalone recording proxy, the exact same one used in the cli.

To do so, you just have to spawn the proxy and then configure your http client to use this proxy.

This requires the record feature.

#[tokio::test(flavor = "multi_thread")]
async fn record_standalone() {
    // 👇 spawn the recording proxy
    let proxy = stubr::Stubr::record();
    // or use `record_with()` for configuring it
    let _proxy_uri = proxy.uri();
    // ☝️ then use this uri to configure your http client
}

Or, in order to keep the syntax short, you can use the provided attribute macro.

// works for async as well
#[stubr::record] // 👈 this spawns the proxy and creates a 'recorder' binding in the function
// #[stubr::record(port = 1234)] for setting a port
#[test]
fn record_standalone() {
    let _proxy_uri = recorder.uri();
    // ☝️ then use this uri to configure your http client
}

gRPC

Stubr also supports mocking a gRPC service ! To do so, it leverages Protobuf to json mapping in order to ; first to reuse all the request matchers and response templates already available and implemented ; then, to let you instantiate (give a value to the fields) your messages using json (you can't assign a value to a field in Protobuf).

The API looks like this:

{
  "protoFile": "path/to/grpc.proto", // protobuf file where gRPC service & protobuf messages are defined
  "grpcRequest": {
    "message": "Pet", // name of the body's message in 'protoFile' 
    "service": "PetStore", // (optional) name of the gRPC service to mock, supports Regex
    "method": "createDog", // (optional) name of the gRPC method to mock, supports Regex
    "bodyPatterns": [
      {
        "equalToJson": { // literally the same matchers as in http
          "name": "Rex",
          "race": "dog"
        }
      }
    ]
  },
  "grpcResponse": {
    "status": "OK", // or "CANCELLED", "NOT_FOUND" etc..
    "message": "Pet", // name of the body's message in 'protoFile'
    "body": { // literally the same as in http, supports templating too
      "id": 1234,
      "name": "{{jsonPath request.body '$.name'}}",
      "race": "{{jsonPath request.body '$.race'}}",
      "action": "{{request.method}}", // only 2 differences with standard templates
      "service": "{{request.service}}"
    },
    "transformers": [ // required for response templating
      "response-template"
    ]
  }
}

Cli

You can use stubr as a cli for serving Wiremock stubs on a local server or as proxy for recording http traffic into json stubs.

To get a list of all available options run stubr --help

argaboutexamples
[dir]Folder containing stubs or individual stub.stubr ./my-app-stubs or stubr ./my-app-stubs/ping.json
--root-dirDirectory containing a mappings folder with all stubs. Equivalent to Wiremock's one. Has precedence over [dir]stubr --root-dir ./my-app-stubs
--portServer port. Defaults to random port.stubr --port 8080 or stubr -p 8080
--delayGlobal delay duration applied to all stubs (supersedes any locally defined delay).stubr --delay 2s or stubr -d 1m or stubr -d 100ms
--latencyDelay added to any locally defined delay. Simulates network latency.stubr --latency 2s or stubr -l 1m or stubr -l 100ms
completionGenerates & installs bash or zsh completion scriptsstubr completion bash or stubr completion zsh
--helpDisplays help.stubr help or stubr -h for short help. stubr --help for long help
--versionDisplays stubr version.stubr -V or stubr --version

install it

precompiled binaries

If you don't want to install Rust toolchain, you can always download precompiled binaries. They have the advantage of being optimized with upx hence they are just smaller than the ones you'd get from sources.

linux:

curl -L https://github.com/beltram/stubr/releases/latest/download/stubr-linux.tar.gz | tar xz - -C /usr/local/bin

macos:

curl -L https://github.com/beltram/stubr/releases/latest/download/stubr-macos.tar.gz | tar xz - -C /usr/local/bin

from source

cargo install stubr-cli

once installed, generate completion

Completion files generation is currently supported for bash and zsh. Stubr cli provides a completion command to generate and install them in a standard location.

stubr completion zsh
# or
stubr completion bash

getting started

The simplest usage is for serving Wiremock stubs under a directory (or just a single file).
For example let's generate a simple stub file.

echo "{\"request\": {\"method\": \"GET\"}, \"response\": { \"status\": 200 }}" > hello.json

Then simply run it with the following command.

stubr hello.json

Which will generate something like that.

 > + mounted "hello.json"
 > Started stubr in 50ms on http://127.0.0.1:49604

Docker

A docker image is published here with each release.

You can play with it with the following commands:

echo "{\"request\": {\"method\": \"GET\"}, \"response\": { \"body\": \"Hello stubr\" }}" > hello.json &&
docker run -v $(pwd):/stubs -d --rm -p 8080:8080 ghcr.io/beltram/stubr:latest /stubs -p 8080 &&
http :8080

Which should output

HTTP/1.1 200 OK
content-length: 11
content-type: text/plain
date: Tue, 23 Mar 2021 13:37:41 GMT
server: stubr(0.6.2)

Hello stubr

Benchmark

vs wiremock

A very simple benchmark comparing stubr to wiremock is available here

standalone

A benchmark of stubr itself, powered by criterion is available for each release. The latest is available here. It aims at tracking down progresses/regressions made.

I'm still looking for a way to turn this into something more ergonomic, especially I'd like to provide a way to compare 2 benchmarks. Meanwhile, you can download the latest benchmark with these commands.

mkdir stubr-bench &&
curl -L https://github.com/beltram/stubr/releases/latest/download/bench.tar.gz | tar xz - -C stubr-bench

Then open ./stubr-bench/report/index.html in your browser.

IDE completion

A json schema is also maintained here to provide completion in IDE. It just contains completion for features implemented in stubr and should alleviate you from a bit of pain when writing json from scratch.

IntelliJ
  • Go to Settings > Languages & Frameworks > Schemas & DTDs > JSON Schema Mappings
  • Add a mapping (click on the upper +)
  • Then supply the following
    • name: stubr
    • Schema file or URL: https://raw.githubusercontent.com/beltram/stubr/main/schemas/stubr.schema.json
    • Schema version: JSON Schema version 7
    • File path pattern: stubs/*.json (and mappings/*.json if you want to use it for original wiremock stubs)
  • Then Apply
VsCode
  • Open workspace settings (File > Preferences > Settings)
  • Add the following under the property json.schemas
"json.schemas": [{"fileMatch": ["stubs/*.json", "mappings/*.json"], "url": "https://raw.githubusercontent.com/beltram/stubr/main/schemas/stubr.schema.json"}]