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.