Testing
Altair ships testing helpers so application specs read as intent instead of socket plumbing. The helpers live in Altair::Test and mirror the patterns the framework's own suite uses.
Booting an application#
Altair::Test.boot binds the application on an ephemeral port, waits until it accepts, yields the port, and restores the shared application instance afterwards:
require "spec_helper"
class MyApp < Altair::Application
routes do
get "/ping", to: PingController.ping
end
end
class PingController < Altair::Controller
def ping : Nil
render text: "pong"
end
end
describe "ping" do
it "answers pong" do
Altair::Test.boot(MyApp) do |port|
response = Altair::Test.get(port, "/ping")
response.status_code.should eq(200)
response.body.should eq("pong")
end
end
endThe block form guarantees cleanup — the server closes and Altair.application_instance is restored even when the example raises.
Request helpers#
Small wrappers over HTTP::Client with the base URL already filled in:
Altair::Test.get(port, "/posts")
Altair::Test.post(port, "/posts", form: "title=Hello")
Altair::Test.post_json(port, "/api/token", body: %({"email":"a@b.com"}))
Altair::Test.put(port, "/posts/1", form: "title=Edited")
Altair::Test.patch(port, "/posts/1", form: "title=Patched")
Altair::Test.delete(port, "/posts/1")All helpers accept an optional headers argument (HTTP::Headers) and return HTTP::Client::Response.
The cookie-jar client#
Altair::Test::Client keeps the server's cookies between requests, so a sign-in carries into every later request without manual header plumbing, and follows redirects the way a browser does when asked:
client = Altair::Test::Client.new(port) # or: follow_redirects: true
client.post("/login", form: "email=a@b.com&password=secret")
client.get("/me").body.should contain("a@b.com") # session carried
destroyed = client.get("/logout") # expired cookies leave the jar
client.get("/me").status_code.should eq(302)Redirects follow only when follow_redirects: true; 301/302/303 downgrade to GET (so POST-then-redirect lands on the page), 307/308 keep the method, and the jar rides along every hop.
Configuring the booted app#
The configure: proc runs on the fresh instance before the server is built — where per-spec settings belong:
Altair::Test.boot(SessionApp, configure: ->(app : SessionApp) {
app.config.secret_key_base = "test-secret"
}) do |port|
# sessions work here
endDatabase helpers#
Altair::Test.migrate! applies pending migrations against the application's configured database — the same engine db:migrate runs:
Altair::Test.migrate!(MyApp)Altair::Test.transactional { } wraps a block in a transaction that is always rolled back, so every example starts from the same data; nested calls join the outer transaction through savepoints:
Altair::Test.transactional do
Post.create(title: "only this example sees me")
endBackground jobs have their own test seam: set Altair::Jobs::Queue.test_mode = true and enqueues collect in memory (Queue.enqueued, Queue.clear_enqueued!) instead of hitting the table; drain them synchronously with a Worker#execute loop over sorted calls.
Isolation#
Each boot saves the current Altair.application_instance, sets it to nil so the application builds a fresh instance, and restores the original in an ensure block. Nested boots are not supported — finish one before starting the next.
For specs that need to reset state between examples without a server, use the same pattern the framework uses internally:
before_each do
# clear database, reset singletons, etc.
endTips#
- Prefer
Altair::Test.bootover hand-rolledHTTP::Server.new+spawn+ polling — it handles the readiness probe and theensurecleanup. - Keep specs fast by booting once per
describewhen the application does not mutate global state; otherwise boot perit. - Pair with
Altair::Recordfixtures: create rows viaModel.createinside thebootblock, then exercise the controller over real HTTP.