Introduction

Grillon is a Rust library offering an elegant and natural way to approach API testing in Rust.

  • Elegant, intuitive and expressive API
  • Built-in testing functions
  • Extensible

Check out our Quickstart.

Usage

As the library is flexible, you can easily integrate it into your testing strategy in a Rust project. You can use it for synthetic monitoring, endpoint monitoring, functional testing, integration testing, BDD testing (e.g cucumber-rs), ... it's up to you. Grillon does not impose any test strategy or organization.

Depending on how you configure your logs, the execution will fail-fast or not and can be formatted in a human-readable or json output.

Next big steps

Here is an unordered and non-exhaustive list of what is planned for Grillon next:

  • Improve HTTP testing: HTTP/1.1 + HTTP/2, json path, xpath, form-data
  • Extend testing capabilities per-protocol/framework
    • WebSocket
    • gRPC
    • SSL
    • TCP, UDP, DNS, ICMP
  • Logs and metrics
  • Support for YAML-formatted (or other formats) tests to extend the library outside of Rust projects

Quickstart

Using Grillon is pretty straightforward, we will consider you are running it as part of your testing process. But you can also use it as a regular dependency.

Configuration

Before we begin, let's create a tests/ directory at the root of the project. Create a file there named create_posts.rs.

Add grillon to your development dependencies with tokio, as we need a runtime to run async functions in our test environement.

[dev-dependencies]
grillon = "0.6.0"
tokio = { version = "1", features = ["macros"] }

Our example will test the /posts endpoint of jsonplaceholder.typicode.com. We will send a json payload and we will assert that our resource is correctly created with an acceptable response time (< 500 ms). Depending on your location, feel free to tweak the response time value (in milliseconds).

Write the test

Create a new create_posts.rs file in tests and copy/paste the following example:

use grillon::{dsl::*, dsl::http::*, json, Grillon, StatusCode, Result};
use grillon::header::{HeaderValue, CONTENT_LENGTH, CONTENT_TYPE};
use grillon::Assert;

#[tokio::test]
async fn end_to_end_test() -> Result<()> {
    Grillon::new("https://jsonplaceholder.typicode.com")?
        .post("posts")
        .payload(json!({
            "title": "foo",
            "body": "bar",
            "userId": 1
        }))
        .assert()
        .await
        .status(is_success())
        .status(is(201))
        .response_time(is_less_than(700))
        .json_body(is(json!({
            "id": 101,
        })))
        .json_body(schema(json!({
            "properties": {
                "id": { "type": "number" }
            }
        })))
        .json_path("$.id", is(json!(101)))
        .header(CONTENT_TYPE, is("application/json; charset=utf-8"))
        .headers(contains(vec![
            (
                CONTENT_TYPE,
                HeaderValue::from_static("application/json; charset=utf-8"),
            ),
            (CONTENT_LENGTH, HeaderValue::from_static("15")),
        ]))
        .assert_fn(|assert| {
            let Assert {
                headers,
                status,
                json,
                ..
            } = assert.clone();

            assert!(!headers.unwrap().is_empty());
            assert!(status.unwrap() == StatusCode::CREATED);
            assert!(json.is_some());

            println!("Json response : {:#?}", assert.json);
        });

    Ok(())
}

Run the test

cargo test --test create_posts -- --nocapture

You should see similar output:

cargo test --test create_posts -- --nocapture
  Finished test [unoptimized + debuginfo] target(s) in 0.14s
    Running tests/create_posts.rs (target/debug/deps/create_posts-26c6ab07b039dabd)

running 1 test
Json response : Some(
    Object {
        "id": Number(101),
    },
)
test create_posts_monitoring ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.38s

Well done! You've written your first HTTP API test!

In this example, we performed assertions on:

  • the status code
  • the response time
  • the headers
  • the entire json body

We also added custom assertions and function calls with assert_fn. So if you have specific needs, you can manipulate assert and add your own logic! For more information, you can read more about assertions in this book.

Next steps

This book contains more in-depth content about Grillon such as reusing a request builder, how to organize your tests, available assertions, and how to configure your log output. You can also find technical information in our latest API documentation.

Writing tests

Client configuration

Grillon can be configured in different ways. We use Hyper as the default HTTP client and provide you with a default configuration. By using Hyper, we can leverage on the low-level API to inspect HTTP requests and responses and provide interesting features to Grillon.

Default client implementation

The default client implementation should provide you with the most common features. All you need to do is configure the base API URL when you create an instance of Grillon.

let grillon = Grillon::new("https://jsonplaceholder.typicode.com")?;

This way you don't have to rewrite the base URL each time you want to send a request and perform assertions on the response. You can reuse the existing client and create a new request. In the following example we send a POST request to https://jsonplaceholder.typicode.com/posts:

let request = grillon
    .post("posts")
    .payload(json!({
        "title": "foo",
        "body": "bar",
        "userId": 1
    }))
    .assert()
    .await;

The assert function consumes the grillon::Request and prevents further changes to the structure of the request when users want to run assertions on the response.

Refer to the Requests chapter for more information about how to configure your requests. Note that at the moment Grillon only supports HTTP(s), but later we will extend the use for different protocols and frameworks such as gRPC or SSL.

Session and authentication

Store cookies

You can update your client to enable or disable the cookie store with store_cookies:

// If an http response contains `Set-Cookie` headers, then cookies will be saved for
// subsequent http requests.
let grillon = Grillon::new("https://server.com/")?.store_cookies(true)?;

grillon.post("auth").assert().await.headers(contains(vec![(
    SET_COOKIE,
    HeaderValue::from_static("SESSIONID=123; HttpOnly"),
)]));

grillon
    .get("authenticated/endpoint") // An endpoint where the session cookie `SESSIONID=123` is required.
    .assert()
    .await
    .status(is_success());

The cookie store is disabled by default.

Basic Auth

You can easily configure your headers with the basic_auth function to set a per-request authentication:

Grillon::new("https://server.com/")?
    .get("auth/basic/endpoint")
    .basic_auth("isaac", Some("rayne"))
    .assert()
    .await
    .status(is_success());

Note that the header is considered as sensitive and will not be logged.

Bearer token

You can also use the bearer_auth function to set your Bearer header per-request:

Grillon::new("https://server.com/")?
    .get("auth/bearer/endpoint")
    .bearer_auth("token-123")
    .assert()
    .await
    .status(is_success());

This header is also considered as sensitive and will not be logged.

Use a different client

When you want to use a different client to send your requests and handle the responses, you should use the internal http response representation to assert. For that you need to use the Assert structure.

For example, suppose you want to use reqwest to perform an http GET request and you want to assert that the response time is less than 400ms. First you need to create your own structure that will handle a reqwest::Response.

struct MyResponse {
    pub response: reqwest::Response,
}

Next, you need to implement the grillon::Response trait to describe how you handle the various pieces of information that Grillon needs to perform assertions:

#[async_trait(?Send)]
impl Response for MyResponse {
    fn status(&self) -> StatusCode {
        self.response.status()
    }

    async fn json(self) -> Option<Value> {
        self.response.json::<Value>().await.ok()
    }

    fn headers(&self) -> HeaderMap {
        self.response.headers().clone()
    }
}

The next step is to create a new Assert instance which requires:

  • An implementation of a grillon::Response,
  • the response time in milliseconds,
  • and the LogSettings for the assertion results.

Let's first run the request and get the execution time:

let now = Instant::now();
let response = reqwest::get(mock_server.server.url("/users/1"))
    .await
    .expect("Failed to send the http request");
let response_time = now.elapsed().as_millis() as u64;

Now let's pass the response to your own response structure:

let my_response = MyResponse { response };

You are now ready to assert against a reqwest::Response wrapped by your own implementation of a grillon::Response:

Assert::new(my_response, response_time, LogSettings::default())
    .await
    .response_time(is_less_than(400));

Requests

HTTP

With Grillon, you can easily chain calls to configure and send HTTP requests and reuse the same client for a given base api URL.

#[tokio::test]
async fn test_get_jsonplaceholder() -> Result<()> {
    let grillon = Grillon::new("https://jsonplaceholder.typicode.com")?;

    grillon
        .get("posts?id=1")
        .assert()
        .await
        .json_path("$[0].id", is(json!(1)));

    grillon
        .get("posts?id=2")
        .assert()
        .await
        .json_path("$[0].id", is(json!(2)));

    Ok(())
}

Methods

Each method in this list has its corresponding lowercase function:

  • GET
  • POST
  • PUT
  • PATCH
  • DELETE
  • OPTIONS
  • CONNECT
  • HEAD

Headers

Grillon supports two different types to configuring http request headers:

  • HeaderMap
  • Vec<(HeaderName, HeaderValue)>
let grillon = Grillon::new("https://jsonplaceholder.typicode.com")?;

// Vec<(HeaderName, HeaderValue)>
let request = grillon
    .post("posts")
    .payload(json!({
        "title": "foo",
        "body": "bar",
        "userId": 1
    }))
    .headers(vec![(
        CONTENT_TYPE,
        HeaderValue::from_static("application/json"),
    )]);

// Override with HeaderMap
let mut header_map = HeaderMap::new();
header_map.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let request = request.headers(header_map);

Payload

At the moment, Grillon only supports the application/json content type. It will then be extended with different content types such as multipart/form-data, application/x-www-form-urlencoded, text/plain or text/html.

Json

Grillon re-exports serde_json::Value type to make it easier to add a json body. You can also use the json! macro.

Grillon::new("https://jsonplaceholder.typicode.com")?;
    .post("posts")
    .payload(json!({
        "title": "foo",
        "body": "bar",
        "userId": 1
    }))
    .assert()
    .await;

Build a custom request

If for some reasons you need a more programmatic way to create your http requests, you can use the http_request function:

Grillon::new("https://jsonplaceholder.typicode.com")?
    .http_request(Method::POST, "posts")
    .assert()
    .await
    .status(is_success());

Assertions

Grillon provides domain-specific language per protocol and framework to make it natural to write assertions.

An assertion is made up of:

  • A part under test like status,
  • a predicate such as is, is_not,
  • and an expected value, for example 200.

The predicates of a specific part can handle different type parameters. For example if you want to assert that a status code is 200, you can pass a u16 or a StatusCode. This information is described below in the types column.

Execution order

Your assertions are executed sequentially and in a blocking fashion. Asynchronous executions are not supported yet. With sequential runs, order matters, so if you want to fail early under specific conditions, it's possible. Each assertion produces logs.

HTTP assertion table

partpredicatestypes
headersis, is_not, contains, does_not_containVec<(HeaderName, HeaderValue)>, Vec<(&str, &str)>, HeaderMap
headeris, is_notString, &str, HeaderValue
statusis, is_not, is_betweenu16, StatusCode
json_bodyis, is_not, schemaString, &str, Value, json!, PathBuf
json_pathis, is_not, schema, contains, does_not_contain, matches, does_not_matchString, &str, Value, json!, PathBuf
response_timeis_less_thanu64

Note about json_path

Json path requires one more argument than other predicates because you have to provide a path. The expected value should always be a valid json representation. To enforce this the provided value is always converted to a Value.

Here is an example of a json path assertion, where we are testing the value under the path $[0].id.

#[tokio::test]
async fn test_json_path() -> Result<()> {
    Grillon::new("https://jsonplaceholder.typicode.com")?
        .get("posts?id=1")
        .assert()
        .await
        .json_path("$[0].id", is(json!(1)))
        .json_path("$[0].id", is("1"));

    Ok(())
}

Custom assertions

You may need to create more complex assertions or have more control on what is executed as part of an assertion. If so, the library provides a specific function, assert_fn, allowing you to write your own logic.

Grillon::new("https://jsonplaceholder.typicode.com")?
    .post("posts")
    .payload(json!({
        "title": "foo",
        "body": "bar",
        "userId": 1
    }))
    .assert()
    .await
    .status(is_success())
    .assert_fn(|assert| {
        assert!(!assert.headers.is_empty());
        assert!(assert.status == StatusCode::CREATED);
        assert!(assert.json.is_some());

        println!("Json response : {:#?}", assert.json);
    });

With this function you can access the Assert structure which is the internal representation of an http response under test. You should have access to all parts that Grillon supports (headers, status, json, etc.). It's also possible to add your own stdout logs if you want more control over the results or need to debug what you receive.

Logs

Grillon provides a LogSettings structure so you can easily configure how the assertion results should be output. The default log settings are set to StdAssert. Only failures will be printed to standard output in a human-readable format.

Each assertion results in a log to standard output that you can connect with your infrastructure to react to specific events. We could imagine for example an integration with CloudWatch and create an alert as soon as the json log contains the key/value "result": "failure".

Human readable

Failures only

This is the default, fail-fast, mode. As soon as you get a failure, the execution halts.

Grillon::new("https://jsonplaceholder.typicode.com")?
    .log_settings(LogSettings::StdAssert)
    .get("posts?id=1")
    .assert()
    .await
    .status(is_client_error());

As the status isn't a client error but a successful code, the assertion fails. The following logs will be printed on the standard output:

part: status code
should be between: "400 and 499"
was: "200"

If you replace is_client_error() by is_success() you should now see a successful test without any logs.

Failures and successes

Now, if you want to log everything, even passing test cases (when debugging for example), then you just need to change your log settings to StdOut:

Grillon::new("https://jsonplaceholder.typicode.com")?
    .log_settings(LogSettings::StdAssert)
    .get("posts?id=1")
    .assert()
    .await
    .status(is_success());

Which should produce similar output:

running 1 test

part: status code
should be between: "200 and 299"
test http::basic_http::test ... ok

Json

The json format is to be used when you want to integrate external tools: CI/CD, logging services such as Elasticsearch or Cloudwatch, reporting tools, etc.

Grillon::new("https://jsonplaceholder.typicode.com")?
    .log_settings(LogSettings::Json)
    .get("posts?id=1")
    .assert()
    .await
    .status(is_client_error());

With the previous code block, we get an assertion failure since the status code isn't a client error. Here is the resulting json output (stdout) of the run:

{
   "left":200,
   "part":"status code",
   "predicate":"should be between",
   "result":"failed",
   "right":[
      400,
      499
   ]
}

Grillon doesn't provide any connectors yet, so you will need to redirect stdout logs to a driver if you want to ingest json logs with other services.