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:
- Record the existing application (if you are lazy)
- Write the json stub yourself 🥵
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 undertests/stubs
but elsewhere. Note that it can point to a directory.port
when you want an explicit port for your mock serververify
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:
- Record the existing application (if you are lazy)
- Write the json stub yourself 🥵
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 undertests/stubs
but elsewhere. Note that it can point to a directory.port
when you want an explicit port for your mock serververify
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
recommended
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 willverify
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 to 4
"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 withcaseInsensitive
.absent
specified query parameter key must be absent/present.contains
value must contain the supplied string in a case-insensitive waymatches
/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 ignoredalg.equalTo
by equality matcher. JWT algorithm has to be exactly thisalg.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 withexpression
, 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 matcherignoreArrayOrder
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 byexpression
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 valuesbinaryEqualTo
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 a404
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
}
}
Header
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 forbodyFileName
.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 castanyNonEmptyString
oranyNonBlankString
generates an arbitrary utf-8 stringanyAlphaNumeric
generates an arbitrary string with only alphanumeric charactersanyBoolean
generates eithertrue
orfalse
anyUuid
generates a random UUIDv4anyIpAddress
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 floatanyI32
oranyU32
etc.. generates a random integer. Possible values are:anyU64
,anyI64
,anyU32
,anyI32
,anyU16
,anyI16
,anyU8
,anyI8
anyFloat
generates a random floatanyDate
generates a date with formatyyyy-mm-dd
anyTime
generates a time with formathh:mm:ss
anyDatetime
generates a datetime with formatyyyy-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 tohttp://localhost/api/path?a=b
returnspath?a=b
request.path
given a request tohttp://localhost/api/path?a=b
returnsapi/path
request.pathSegments.[i]
allows picking a part of the url path (i
is zero indexed) e.g.http://localhost/a/b/c
withi
== 1 returnsb
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 indexedi
. For example withhttp://localhost?a=1&a=2&a=3&b=1
then{{query.b}}
returns1
and{{query.a.[1]}}
returns2
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 hereselector
is case-insensitive.request.body
takes the raw request body without altering itjsonPath 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 millisecondsunix
Unix timestamp in seconds
- a custom Java SimpleDateFormat (
for Wiremock compatibility) e.g.
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
orisEven
returns a boolean whether the numeric value is an even or odd integercapitalize
first letter to uppercase e.g.mister
becomesMister
. There's also adecapitalize
to do the opposite.upper
orlower
recapitalizes the whole wordreplace
for replacing a pattern with given input e.g.{{replace request.body 'a' 'b'}}
will replace all thea
in the request body withb
stripes
returns alternate values depending if the tested value is even or oddtrim
removes leading & trailing whitespacessize
returns the number of bytes for a string (⚠️ not the number of characters) or the size of an arraybase64
for standard (no base64 url encoding yet) Base64 encodingdecode
for decoding when truepadding
with/without padding
urlEncode
for url encoding the value. Usedecode=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 thisstubr::Stubr::start_with(stubr::Config { verify: true, ..Default::default() })
or#[stubr::mock(verify = true)]
with the attribute macrofixedDelayMilliseconds
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), usetype
to choose the onelognormal
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 millisecondssigma
: 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.
arg | about | examples |
---|---|---|
--port | Proxy port. Defaults to 3030. | stubr --port 3031 or stubr -p 3031 |
--output | File 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.
trait (recommended)
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
arg | about | examples |
---|---|---|
[dir] | Folder containing stubs or individual stub. | stubr ./my-app-stubs or stubr ./my-app-stubs/ping.json |
--root-dir | Directory containing a mappings folder with all stubs. Equivalent to Wiremock's one. Has precedence over [dir] | stubr --root-dir ./my-app-stubs |
--port | Server port. Defaults to random port. | stubr --port 8080 or stubr -p 8080 |
--delay | Global delay duration applied to all stubs (supersedes any locally defined delay). | stubr --delay 2s or stubr -d 1m or stubr -d 100ms |
--latency | Delay added to any locally defined delay. Simulates network latency. | stubr --latency 2s or stubr -l 1m or stubr -l 100ms |
completion | Generates & installs bash or zsh completion scripts | stubr completion bash or stubr completion zsh |
--help | Displays help. | stubr help or stubr -h for short help. stubr --help for long help |
--version | Displays 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
(andmappings/*.json
if you want to use it for original wiremock stubs)
- name:
- 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"}]