Common patterns

This page collects recipes for frequently encountered testing scenarios. Each pattern is self-contained and ready to adapt to your own tests.

Authenticated workflows

Use WithBasicAuth or WithHeaders to set authentication headers across a block of steps. The headers are automatically cleaned up after the block.

WithBasicAuth("admin", "secret") {
  When I get("/admin/users")
  Then assert status.is(200)
  Then assert body.asArray.isNotEmpty
}

// Or with a bearer token
WithHeaders(("Authorization", "Bearer <auth-token>")) {
  When I get("/api/protected-resource")
  Then assert status.is(200)
}

For a login flow that saves a token for later use:

Given I post("/auth/login").withBody(
  """
  {
    "username": "admin",
    "password": "secret"
  }
  """)
Then assert status.is(200)
And I save_body_path("token" -> "auth-token")

// Now use the token in subsequent requests
WithHeaders(("Authorization", "Bearer <auth-token>")) {
  When I get("/api/me")
  Then assert status.is(200)
}

Polling eventually-consistent endpoints

Use Eventually to retry assertions until they succeed or a timeout is reached. This is ideal for testing systems where changes propagate asynchronously.

// Create a resource
Given I post("/products").withBody("""{ "name": "Widget" }""")
Then assert status.is(201)
And I save_body_path("id" -> "product-id")

// Wait for it to appear in the search index
Eventually(maxDuration = 10.seconds, interval = 200.millis) {
  When I get("/products/search").withParams("q" -> "Widget")
  Then assert status.is(200)
  Then assert body.asArray.isNotEmpty
}

Choose interval carefully — too short floods the server, too long wastes time. 100-500ms is usually a good range.

Data-driven tests

Use WithDataInputs to run the same assertions across multiple input sets without duplicating steps.

WithDataInputs(
  """
    | endpoint             | expected_status |
    | "/health"            | "200"           |
    | "/api/version"       | "200"           |
    | "/does-not-exist"    | "404"           |
  """
) {
  When I get("<endpoint>")
  Then assert status.is("<expected_status>")
}

For JSON-formatted inputs, use WithJsonDataInputs:

WithJsonDataInputs(
  """
  [
    { "name": "Batman",   "city": "Gotham" },
    { "name": "Superman", "city": "Metropolis" }
  ]
  """
) {
  When I get("/superheroes/<name>")
  Then assert status.is(200)
  Then assert body.path("city").is("<city>")
}

Shared setup and teardown

Use beforeEachScenario and afterEachScenario to share common setup across all scenarios in a feature.

class ApiFeature extends CornichonFeature {

  override lazy val baseUrl = "http://localhost:8080"

  beforeEachScenario {
    Attach {
      Given I post("/auth/login").withBody("""{ "user": "test", "pass": "test" }""")
      And I save_body_path("token" -> "auth-token")
    }
  }

  afterEachScenario {
    Attach {
      Given I post("/auth/logout")
    }
  }

  def feature = Feature("API tests") {
    Scenario("list users") {
      // auth-token is already in session
      WithHeaders(("Authorization", "Bearer <auth-token>")) {
        When I get("/users")
        Then assert status.is(200)
      }
    }
  }
}

See Feature Options for more details on hooks.

CRUD workflow

A typical create-read-update-delete flow saving IDs between steps:

// Create
Given I post("/products").withBody("""{ "name": "Widget", "price": 42 }""")
Then assert status.is(201)
And I save_body_path("id" -> "product-id")

// Read
When I get("/products/<product-id>")
Then assert status.is(200)
Then assert body.path("name").is("Widget")

// Update
Given I put("/products/<product-id>").withBody("""{ "name": "Widget Pro", "price": 99 }""")
Then assert status.is(200)

// Verify update
When I get("/products/<product-id>")
Then assert body.path("name").is("Widget Pro")
Then assert body.path("price").is(99)

// Delete
Given I delete("/products/<product-id>")
Then assert status.is(200)

// Verify deletion
When I get("/products/<product-id>")
Then assert status.is(404)

Reusable step blocks

Extract common step sequences into functions using Attach for reuse across scenarios.

def create_superhero(name: String, city: String) =
  Attach {
    Given I post("/superheroes").withBody(
      s"""{ "name": "$name", "city": "$city" }""")
    Then assert status.is(201)
    And I save_body_path("id" -> "hero-id")
  }

def verify_superhero(name: String) =
  Attach {
    When I get("/superheroes/<hero-id>")
    Then assert status.is(200)
    Then assert body.path("name").is(name)
  }

// Usage in scenarios
Scenario("create and verify") {
  Given assert create_superhero("Batman", "Gotham")
  Then assert verify_superhero("Batman")
}

Use AttachAs("title") to give the block a descriptive name that shows up in the test output.

See DSL Composition for more on reusing steps.

Iterating over a collection

Use RepeatWith or RepeatFrom to run steps for each element in a collection, with the current element available as a placeholder.

RepeatWith("Batman", "Superman", "Spiderman")("hero") {
  When I get("/superheroes/<hero>")
  Then assert status.is(200)
}

To track the iteration index:

Repeat(5, "i") {
  When I get("/items/<i>")
  Then assert status.is(200)
}

Concurrent requests

Use RepeatConcurrently to load-test an endpoint or verify thread safety:

RepeatConcurrently(times = 50, parallelism = 10, maxTime = 30.seconds) {
  When I get("/api/health")
  Then assert status.is(200)
}

Use Concurrently when each step is different:

Concurrently(maxTime = 10.seconds) {
  When I get("/api/users")
  When I get("/api/products")
  When I get("/api/orders")
}