DSL
The content of a feature
is described using a domain-specific language (DSL) providing a clear structure for statement definitions.
The structure of a step statement is the following:
1 - starts with either Given
- When
- And
- Then
The prefixes do not change the behavior of the steps but are present to improve the readability.
2 - followed by any single word (could be several words wrapped in back-ticks)
This structure was chosen to increase the freedom of customization while still benefiting from Scala’s infix notation.
3 - ending with a step
definition
The usage pattern is often to first run a step
with a side effect then assert an expected state in a second step
.
For example :
Given I step_definition
When a step_definition
And \`another really important\` step_definition
Then assert step_definition
step_definition
stands here for any object of type Step
, those can be manually defined or simply built-in in Cornichon.
Built-in steps
Cornichon has a set of built-in steps for various HTTP calls and assertions on the response.
HTTP effects
- GET, DELETE, HEAD, OPTIONS, POST, PUT and PATCH use the same request builder for request’s body, URL parameters and headers.
head("http://superhero.io/daredevil")
get("http://superhero.io/daredevil").withParams(
"firstParam" -> "value1",
"secondParam" -> "value2")
delete("http://superhero.io/daredevil").withHeaders(("Authorization", "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="))
post("http://superhero.io/batman").withBody("JSON description of Batman goes here")
put("http://superhero.io/batman").withBody("JSON description of Batman goes here").withParams(
"firstParam" -> "value1",
"secondParam" -> "value2")
patch("http://superhero.io/batman").withBody("JSON description of Batman goes here")
There is a built-in support for HTTP body defined as String, if you wish to use other types please check out the section Custom HTTP body type.
HTTP assertions
- assert response status
status.is(200)
- assert response headers
headers.name("cache-control").isPresent
headers.contain("cache-control" -> "no-cache")
headers.name("cache_control").isAbsent
save_header_value("cache_control" -> "my-cache-control-value")
- assert response body comes with different flavors (ignoring, whitelisting)
body.is(
"""
{
"name": "Batman",
"realName": "Bruce Wayne",
"city": "Gotham city",
"hasSuperpowers": false,
"publisher":{
"name":"DC",
"foundationYear":1934,
"location":"Burbank, California"
}
}
""")
body.ignoring("city", "hasSuperpowers", "publisher.foundationYear", "publisher.location").is(
"""
{
"name": "Batman",
"realName": "Bruce Wayne",
"publisher":{
"name":"DC"
}
}
""")
body.whitelisting.is(
"""
{
"name": "Batman",
"realName": "Bruce Wayne",
"publisher":{
"name":"DC"
}
}
""")
Ignored keys and extractors are JsonPaths following the format “a.b.c[index].d”.
The index
value is either:
- an Integer addressing the array position.
- a
*
to target all values, the result will be an array of the projected values.
JsonPath can also be used to only assert part of the response
body.path("city").is("Gotham city")
body.path("hasSuperpowers").is(false)
body.path("publisher.name").is("DC")
body.path("city").containsString("Gotham")
body.path("superheroes[*].name").is("""[ "Spiderman", "IronMan", "Superman", "GreenLantern", "Batman" ]""")
body.path("publisher.foundationYear").is(1934)
body.path("publisher.foundationYear").isPresent
body.path("publisher.foundationMonth").isAbsent
It is possible to handle null values, given the following response body { “data” : null }
body.path("data").isAbsent //incorrect
body.path("data").isPresent //correct
body.path("data").isNull //correct
body.path("data").isNotNull //incorrect
If one key of the path contains a “.” it has to be wrapped with “`” to notify the parser.
body.path("`message.en`").isPresent
body.path("`message.fr`").isAbsent
To address a root array use $
followed by the index the access.
body.path("$[2].name")
If the endpoint returns a collection assert response body has several options (ordered, ignoring and using data table)
body.asArray.inOrder.ignoringEach("city", "hasSuperpowers", "publisher").is(
"""
[{
"name": "Batman",
"realName": "Bruce Wayne"
},
{
"name": "Superman",
"realName": "Clark Kent"
}]
""")
body.asArray.inOrder.ignoringEach("publisher").is(
"""
| name | realName | city | hasSuperpowers |
| "Batman" | "Bruce Wayne" | "Gotham city" | false |
| "Superman" | "Clark Kent" | "Metropolis" | true |
""")
body.asArray.hasSize(2)
body.asArray.size.is(2) //equivalent to above
body.asArray.size.isLesserThan(3)
body.asArray.size.isGreaterThan(1)
body.asArray.size.isBetween(1, 3)
body.asArray.isNotEmpty
body.asArray.contains(
"""
{
"name": "Batman",
"realName": "Bruce Wayne",
"city": "Gotham city",
"hasSuperpowers": false,
"publisher":{
"name":"DC",
"foundationYear":1934,
"location":"Burbank, California"
}
}
""")
It is important to mention that body
expects a JSON content!
When receiving non JSON payloads, use body_raw
which offers String
like assertions.
body_raw.containsString("xml")
HTTP streams
- Server-Sent-Event.
When I open_sse(s"http://superhero.io/stream", takeWithin = 1.seconds).withParams("justName" -> "true")
Then assert body.asArray.hasSize(2)
Then assert body.is("""
| eventType | data | id | retry | comment |
| "superhero name" | "Batman" | null | null | null |
| "superhero name" | "Superman" | null | null | null |
""")
SSE streams are aggregated over a period of time in an array, therefore the previous array predicates can be re-used.
GraphQL support
Cornichon offers an integration with the library Sangria to propose convenient features to test GraphQL API.
- GraphQL query
import sangria.macros._
When I query_gql("/<project-key>/graphql").withQuery(
graphql"""
query MyQuery {
superheroes {
results {
name
realName
publisher {
name
}
}
}
}
"""
)
query_gql
can also be used for mutation query.
- GraphQL JSON
all built-in steps accepting String input/output can also accept an alternative lightweight JSON format using the gqljson
StringContext.
import com.github.agourlay.cornichon.json.CornichonJson._
And assert body.ignoring("city", "publisher").is(
gqljson"""
{
name: "Batman",
realName: "Bruce Wayne",
hasSuperpowers: false
}
""")
Session steps
- setting a value in
session
save("favorite-superhero" -> "Batman")
- saving value to
session
save_body_path("city" -> "batman-city")
- asserting value in
session
session_value("favorite-superhero").is("Batman")
- asserting JSON value in
session
session_value("my-json-response").asJson.path("a.b.c").ignoring("d").is("...")
- asserting existence of value in
session
session_value("favorite-superhero").isPresent
session_value("favorite-superhero").isAbsent
- transforming a value in
session
transform_session("my-key")(_.toUpperCase)
Wrapper steps
Wrapper steps allow to control the execution of a series of steps to build more powerful tests.
- repeating a series of
steps
Repeat(3) {
When I get("http://superhero.io/batman")
Then assert status.is(200)
}
- repeating a series of
steps
during a period of time
RepeatDuring(300.millis) {
When I get("http://superhero.io/batman")
Then assert status.is(200)
}
- repeat a series of
steps
for each input element
RepeatWith("Superman", "GreenLantern", "Spiderman")("superhero-name") {
When I get("/superheroes/<superhero-name>").withParams("sessionId" -> "<session-id>")
Then assert status.is(200)
Then assert body.path("hasSuperpowers").is(true)
}
- retry a series of
steps
until it succeeds or reaches the limit
RetryMax(3) {
When I get("http://superhero.io/batman")
Then assert status.is(200)
}
- repeating a series of
steps
until it succeeds over a period of time at a specified interval (handy for eventually consistent endpoints)
Eventually(maxDuration = 15.seconds, interval = 200.milliseconds) {
When I get("http://superhero.io/random")
Then assert body.ignoring("hasSuperpowers", "publisher").is(
"""
{
"name": "Batman",
"realName": "Bruce Wayne",
"city": "Gotham city"
}
"""
)
}
- execute a series of steps ‘n’ times by batch of
p
in parallel and wait ‘maxTime’ for completion.
RepeatConcurrently(times = 10, parallel = 3, maxTime = 10 seconds) {
When I get("http://superhero.io/batman")
Then assert status.is(200)
}
- execute each step in parallel and wait ‘maxTime’ for completion.
Concurrently(maxTime = 10 seconds) {
When I get("http://superhero.io/batman")
When I get("http://superhero.io/superman")
}
- execute a series of steps and fails if the execution does not complete within ‘maxDuration’.
Within(maxDuration = 10 seconds) {
When I get("http://superhero.io/batman")
Then assert status.is(200)
}
- repeat a series of steps with different inputs specified via a data-table
WithDataInputs(
"""
| a | b | c |
| 1 | 3 | 4 |
| 7 | 4 | 11 |
| 1 | -1 | 0 |
"""
) {
Then assert a_plus_b_equals_c
}
def a_plus_b_equals_c =
AssertStep("sum of 'a' + 'b' = 'c'", s ⇒ GenericEqualityAssertion(s.getUnsafe("a").toInt + s.getUnsafe("b").toInt, s.getUnsafe("c").toInt))
- WithHeaders automatically sets headers for several steps useful for an authenticated scenario.
WithHeaders(("Authorization", "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==")){
When I get("http://superhero.io/secured")
Then assert status.is(200)
}
- WithBasicAuth automatically sets basic auth headers for several steps.
WithBasicAuth("admin", "root"){
When I get("http://superhero.io/secured")
Then assert status.is(200)
}
- HttpListenTo creates an HTTP server that will be running during the length of the enclosed steps.
This feature is defined the module cornichon-http-mock
and requires to extend the trait HttpMockDsl
.
By default, this server responds with 201 to any POST request and 200 for all the rest.
Additionally, it provides three administrations features:
- fetching recorded received requests
- resetting recorded received requests
- toggling on/off the error mode to return HTTP 500 to incoming requests
The server records all requests received as a JSON array of HTTP request for later assertions.
There are two ways to perform assertions on the server statistics, either by querying the session at the end of the block or by contacting directly the server while it runs.
Refer to those examples for more information.
This feature is experimental and may change in the future.
- Log duration
By default, all Step
execution time can be found in the logs, but sometimes one needs to time a series of steps.
This is where LogDuration
comes in handy, it requires a label that will be printed as well to identify results.
LogDuration(label = "my experiment") {
When I get("http://superhero.io/batman")
Then assert status.is(200)
}
Debug steps
- showing session content for debugging purpose
And I show_session
And I show_last_response
And I show_last_response_json (pretty print the json body)
And I show_last_status
And I show_last_body
And I show_last_body_json (pretty print the json body)
And I show_last_headers
Those descriptions might be already outdated, in case of doubt always refer to those examples as they are executed as part of Cornichon’s test suite.
DSL composition
Series of steps defined with Cornichon’s DSL can be reused within different Scenarios
.
Using the keyword Attach
if the series starts with a Step
and without if it starts with a wrapping bloc.
import com.github.agourlay.cornichon.CornichonFeature
import scala.concurrent.duration._
class CompositionFeature extends CornichonFeature {
def feature =
Feature("Cornichon feature example") {
Scenario("demonstrate DSL composition") {
Then assert superhero_exists("batman")
Then assert random_superheroes_until("Batman")
}
}
def superhero_exists(name: String) =
Attach {
When I get(s"/superheroes/$name").withParams("sessionId" -> "<session-id>")
Then assert status.is(200)
}
def random_superheroes_until(name: String) =
Eventually(maxDuration = 3.seconds, interval = 10.milliseconds) {
When I get("/superheroes/random").withParams("sessionId" -> "<session-id>")
Then assert body.path("name").is(name)
Then I print_step("bingo!")
}
}
It is possible to give a title to an attached bloc using AttachAs(title)
.