Skip to content
carameldocs · 0.16.0
Browse documentation
Reference / Corretto

Test against the real thing.

Corretto runs request specs in-process against a real PostgreSQL database, rolls back every example, and fakes third parties at the wire.

ContractValue
Release0.15.0
NeedsA generated application and Latte’s PostgreSQL
CoversSpec helper, request client, matchers, wire stubs, job drains
Proved byfrappe corretto

Learning it for the first time? The guide Test the HTML your app returns teaches expectations in order. This page lists every call and limit.

Limits at a glance

LimitValue
.env.test size, at most64 KiB
--concurrency workers1–8, default 1
Body shown when a response matcher failsfirst 800 characters

Specs run only under frappe corretto

A generated application has a spec helper that requires caramel/corretto and calls Corretto.configure(App) once. The application is built on the spec database, with the origin and secret that frappe corretto supplies. Corretto.configure(App) { |config| … } takes a block for settings.

spec/spec_helper.cr
require "spec"
require "caramel/corretto"
require "../config/application"

# `frappe corretto` runs each worker against its own Latte clone of the
# migrated spec database; Corretto refuses any other database, wraps every
# example in a rolled-back savepoint and answers outbound HTTP from wire stubs.
Corretto.configure(App)

Spec workers start from a cleared environment. Put the test-only settings your application reads, such as a webhook secret, in the committed .env.test.

.env.test
WEBHOOK_SECRET=test-webhook-secret
ConditionOutcome
Plain crystal specstops with Run these specs with frappe corretto
A database that is not this worker’s own spec databasestops, so development data is never touched
Your development .envnever reaches a worker
.env.test that is not a regular file, or is over 64 KiBrefuses
.env.test that sets one of the ten keys APP_ORIGIN, APP_SECRET, DATABASE_URL, MIGRATION_DATABASE_URL, PATH, HOME, USER, LOGNAME, TMPDIR or LANG, or a key starting SPEC_, CARAMEL_, CORRETTO_, CRYSTAL_, LD_ or DYLD_refuses with .env.test cannot set KEY; Corretto and the toolchain supply these

Each worker gets its own database

A directory contributes its spec files. frappe corretto migrates the spec database once, and Latte then clones one database per worker from it.

Terminal
frappe corretto
frappe corretto spec/requests/books_spec.cr
frappe corretto spec/requests --concurrency=4

Expect: Prints Corretto: N spec files across M workers. Spec files are dealt round-robin to the workers. Each worker runs its own compiled spec binary and prefixes its output lines, such as [w1]. The spec database is migrated once, the worker databases are dropped afterwards, and the last line is Corretto: M of M workers passed.

Every example runs on one connection inside a transaction and a savepoint. Both are rolled back when the example ends, including the writes its requests and drained jobs made.

ConditionOutcome
No pathsruns every *_spec.cr file under spec
A path outside the projectrefuses
--concurrency outside 1 to 8refuses
--concurrency omitteduses 1 worker
An example that changes the schema outside its transactionwarns and recreates the worker’s database from the migrated template
An example tagged catalogruns unwrapped on a migration-role connection, and the database is always reset afterwards
File writesare not rolled back; point your application’s storage at Corretto.tmpdir, a private directory removed after each example

A request sends one body

Corretto.session yields a client and the example’s database connection. The client calls the application’s handler in-process, in the example’s fiber, so requests share the example’s transaction. It has get, post, put, patch and delete, and each returns a Caramel::Response with streamed bodies read to the end.

Corretto · request bodies
client.get("/books", params: {"page" => "2"})
client.post("/books", params: {"title" => "Dune"})
cover = Corretto.upload("spec/fixtures/cover.png", "image/png")
client.post("/books/1/cover",
  params: {"caption" => "First edition"},
  files: {"cover" => cover})
client.post("/api/books", json: {title: "Dune"})
client.post("/hooks/inbox",
  body: %({"event_id":"evt_1"}),
  headers: {"Content-Type" => "application/json"})

A request sends one body: URL-encoded params:, params: with files: as multipart, json: with any value, or raw body: text or bytes typed by your Content-Type header.

ConditionOutcome
Any other mix of params:, json:, body: and files:raises Send one body: params: (with files: for multipart), json: or body:
Any body besides params: on GETraises GET sends params: in the query and takes no body
params: on GETbecomes the query
An Array value in params:sends one key per item
A path that does not start with /, or starts with //, such as books or https://shop.test/booksraises Corretto requests take a local path such as /books

The client behaves like a browser

The client acts as a browser on your application’s origin. It sends Host on every request and keeps a cookie jar from Set-Cookie.

ConditionOutcome
post, put, patch, deleteadds the exact Origin and an X-CSRF-Token a page would submit
Headers you passoverride the automatic ones, which is how you test a forged request
A forged X-CSRF-Token or a foreign Originanswers 403
client.follow_redirect after a response with no HX-Location and no 3xx Locationraises
Corretto · forged requests
sample = {"title" => "Dune"}
forged = {"X-CSRF-Token" => "forged"}
foreign = {"Origin" => "https://attacker.example"}
client.post("/books", headers: forged, params: sample).should have_status(403)
client.post("/books", headers: foreign, params: sample).should have_status(403)

client.follow_redirect sends a GET to the last response’s HX-Location, or to its Location when the status is 3xx. client.sign_in(user) writes user.id as user_id into the signed session cookie, for an application whose sign-in stores user_id; an application with revocable sessions signs in through its login route instead.

Revocable sessions →

Matchers assert on responses and rows

Corretto’s matchers work with should and should_not.

MatcherPasses when
have_status(status)The response status equals status.
have_header(name, value = nil)The header is present; with a value, one of its values equals it.
redirect_to(path)A 3xx Location, or an HX-Location, equal to path.
render_page(title)A full document with a doctype whose title contains title.
render_partial(target, swap = nil)An hx-partial with that hx-target, and that hx-swap when given. A block also checks its content.
have_html(within:, count:, strict:) { … }Blueprint-shaped requirements on the returned HTML.
have_row(Schema, **conditions)On the db handle: a row matching typed where conditions.
ConditionOutcome
A response matcher failsshows the status, the headers except Content-Security-Policy and Set-Cookie, and the first 800 characters of the body, cut with …
have_row with an unknown or mistyped fieldfails compilation
have_row without conditionschecks for any row
Corretto · matchers
created.should have_status(303)
created.should redirect_to("/books/1")
updated.should have_header("HX-Location", "/books/1")
shown.should render_page("Book")
db.should have_row(App::Book, title: "Dune", author: nil)
db.should_not have_row(App::Book, title: "Solaris")

The guide Test the HTML your app returns covers have_html and the block form of render_partial in depth.

Test the HTML your app returns →

Third parties are faked at the wire

Corretto starts a local proxy for the suite and points Caramel::Outbound, the framework’s outbound HTTP client, at it. The proxy reads the real request bytes, records them, and answers from the latest matching stub. A stub matches the absolute URL, and the method when you give one.

Corretto · wire stubs
Corretto.stub_wire("https://api.stripe.com/v1/customers", method: "POST")
  .to_return(status: 200, fixture: "stripe/customer.json")
Corretto.stub_wire("https://api.stripe.com/v1/charges")
  .to_return(status: 402, body: %({"error":"card_declined"}),
    headers: {"Content-Type" => "application/json"})

to_return takes a status, headers, and either body: or fixture:. Corretto.wire_requests lists the requests the proxy received in order.

ConditionOutcome
A request matching no stubanswers 502 Unstubbed outbound request: METHOD URL, so specs never reach the network
Several matching stubsthe latest answers
stub_wire with a URL that is not absolute http or httpsraises ArgumentError
to_return with both body: and fixture:raises to_return takes fixture: or body:, not both
A fixture pathis relative to spec/fixtures/wire/, and its extension sets the default Content-Type
A fixture path that starts with / or has an empty, . or .. segmentraises ArgumentError
A fixture file that does not existraises Corretto::Error naming the path
A request not sent through Caramel::Outbounddoes not pass through the proxy
Stubs and recorded requestsreset after each example

Mocking libraries fail compilation

Corretto forbids mocks. Assert on requests, rows and rendered HTML instead.

ConditionOutcome
A mocking library that defines Mocks, Mock or Double, or Spectator’s mocksfails compilation
A .cr file under spec/ with a line, outside a # comment, that calls allow(, receive(, double(, instance_double(, mock( or .stub(, the first five not preceded by a word character, ., :, @ or $refuses to run before building: frappe corretto prints PATH:LINE: `allow(` is a mocking API; Corretto forbids mocks, then Specs refused: N mocking call(s) under spec/

Specs start no workers

Specs start no Cold Brew workers. A job enqueued during an example waits in the example’s transaction until you drain its queue on the example’s connection. The Cold Brew reference lists the drain rules and the 1,000-job limit.

Corretto · after a request enqueues a job
Caramel::ColdBrew.drain_queue!(db, "default").should eq(1)
db.should have_row(App::Book, title: "Drained probe", author: "Cold Brew")
ConditionOutcome
A job enqueued by a request in an examplewaits, unrun, until drain_queue! runs it on the example’s db
work under CARAMEL_ENV=teststops with Specs run no workers; drain queues with Caramel::ColdBrew.drain_queue!
Drain queues in specs →

Prove it

Run a generated request spec

frappe make resource Book title:string writes a request spec that exercises the whole resource. This excerpt keeps its first steps.

spec/requests/books_spec.cr · excerpt
require "../spec_helper"

describe "Books" do
  it "creates, reads, updates and deletes through CSRF-protected browser forms" do
    Corretto.session do |client, db|
      client.get("/books/new").should render_page("New book")
      created = client.post("/books", params: {"title" => "Example <title>"})
      record = App::Book.query.order_by(:id, :desc).first!(db)
      path = "/books/#{record.id}"
      created.should have_status(303)
      created.should redirect_to(path)
      db.should have_row(App::Book, id: record.id, title: "Example <title>")
      shown = client.follow_redirect
      shown.should render_page("Book")
      shown.should have_html { dl { dd { "Example <title>" } } }
    end
  end
end
Terminal · inside the application
frappe corretto spec/requests/books_spec.cr

Passing: Every example passes and the last line is Corretto: 1 of 1 workers passed. The spec proves render_page, have_row, have_html and the 403 for forged requests; the sample title contains <title>, so it also proves that the page escapes user text. The full spec goes on to check JSON, updates, unknown fields and deletion. Caramel’s own scripts/check frappe-project proves the catalog reset, wire isolation, drains and the mock refusal.