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.