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
part | predicates | types |
---|---|---|
headers | is, is_not, contains, does_not_contain | Vec<(HeaderName, HeaderValue)>, Vec<(&str, &str)>, HeaderMap |
header | is, is_not | String, &str, HeaderValue |
status | is, is_not, is_between | u16, StatusCode |
json_body | is, is_not, schema | String, &str, Value, json! , PathBuf |
json_path | is, is_not, schema, contains, does_not_contain, matches, does_not_match | String, &str, Value, json! , PathBuf |
response_time | is_less_than | u64 |
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.