spec-kemal
Version, currently 1.0.011 versions
- 1.3.0latestMay 4, 2026
- 1.2.0not indexedJun 10, 2026
- 1.1.1not indexedJun 10, 2026
- 1.1.0not indexedJun 10, 2026
- 1.0.0not indexedJun 10, 2026
- 0.5.0not indexedJun 10, 2026
- 0.4.0not indexedJun 10, 2026
- 0.3.1not indexedJun 10, 2026
- 0.3.0not indexedJun 10, 2026
- 0.2.0not indexedJun 10, 2026
- 0.1.0not indexedJun 10, 2026
github.com/kemalcr/spec-kemal
Easy testing for Kemal applications
60 stars
8 dependents
License: MIT
Nothing has been indexed for 1.0.0 yet. The tag is recorded, its shard.yml has not been read, so the manifest and dependency list below are empty because they are unknown rather than because they are absent.
Installation
# Add this to your shard.yml
dependencies:
spec-kemal:
github: kemalcr/spec-kemal
version: ~> 1.0.0Then run:
shards installshard.yml
No shard.yml has been indexed for 1.0.0. You can read it on the repository.
Dependencies
Unknown: the shard.yml for this version has not been read yet.
README
This README is the one indexed from the repository at its latest ref, not from the tag for this version.
# spec-kemal
[](https://github.com/kemalcr/spec-kemal/actions/workflows/ci.yml)
[](https://github.com/kemalcr/spec-kemal/releases)
[](https://opensource.org/licenses/MIT)
Testing helpers for the [Kemal](https://kemalcr.com) web framework. Write expressive and readable tests for your Kemal applications using Crystal's built-in `spec` library.
## Table of Contents
- [Installation](#installation)
- [Quick Start](#quick-start)
- [API Reference](#api-reference)
- [HTTP Methods](#http-methods)
- [WebSocket testing](#websocket-testing)
- [Response Object](#response-object)
- [Headers](#headers)
- [Request Body](#request-body)
- [Testing Patterns](#testing-patterns)
- [JSON APIs](#json-apis)
- [Form Data](#form-data)
- [Authentication](#authentication)
- [Sessions](#sessions)
- [Configuration](#configuration)
- [Troubleshooting](#troubleshooting)
- [Contributing](#contributing)
- [License](#license)
## Installation
Add spec-kemal to your `shard.yml` as a **development dependency**:
```yaml
name: your-kemal-app
version: 0.1.0
dependencies:
kemal:
github: kemalcr/kemal
development_dependencies:
spec-kemal:
github: kemalcr/spec-kemal
```
Then run:
```bash
shards install
```
## Quick Start
### 1. Set up your spec helper
Create or update `spec/spec_helper.cr`:
```crystal
require "spec"
require "spec-kemal"
require "../src/your-kemal-app"
Spec.before_each do
Kemal.config.env = "test"
Kemal.config.setup
end
Spec.after_each do
Kemal.config.clear
end
```
### 2. Write your tests
```crystal
# spec/your-kemal-app_spec.cr
require "./spec_helper"
describe "My Kemal App" do
it "renders the homepage" do
get "/"
response.status_code.should eq 200
response.body.should contain "Welcome"
end
it "creates a new user" do
post "/users", body: {name: "Crystal"}.to_json,
headers: HTTP::Headers{"Content-Type" => "application/json"}
response.status_code.should eq 201
end
end
```
### 3. Run your tests
```bash
KEMAL_ENV=test crystal spec
```
## API Reference
### HTTP Methods
spec-kemal provides helper methods for all standard HTTP verbs:
| Method | Description |
|--------|-------------|
| `get(path, headers?, body?)` | Sends a GET request |
| `post(path, headers?, body?)` | Sends a POST request |
| `put(path, headers?, body?)` | Sends a PUT request |
| `patch(path, headers?, body?)` | Sends a PATCH request |
| `delete(path, headers?, body?)` | Sends a DELETE request |
| `head(path, headers?, body?)` | Sends a HEAD request |
| `options(path, headers?, body?)` | Sends an OPTIONS request |
**Parameters:**
- `path : String` - The request path (e.g., `"/users"`, `"/api/v1/posts?page=2"`)
- `headers : HTTP::Headers?` - Optional HTTP headers
- `body : String?` - Optional request body
An additional overload is available for GET with a WebSocket handshake:
- `get(path, headers?, body?, *, websocket : Bool)` — when `websocket` is `true`, builds a valid `Upgrade: websocket` request (see [WebSocket testing](#websocket-testing)).
### WebSocket testing
Kemal exposes WebSockets with `ws "/path"`. spec-kemal splits testing into **handshake-only** (fast, in-memory) and **full duplex** (real upgrade, your `on_message` runs).
| Approach | API | `ws` handler runs? | Typical assertions |
|----------|-----|-------------------|--------------------|
| Handshake only | `get path, websocket: true` or `SpecKemal.process_request` + `websocket_request_headers` | No | **101**, `Sec-WebSocket-Accept`, **400** / **426** on bad requests |
| Messages | `connect_websocket` | Yes | Echo, broadcast, auth via `Origin` / cookies |
Handshake-only requests go through `SpecKemal.process_request`, which does **not** call [HTTP::Server::Response#upgrade_handler](https://crystal-lang.org/api/latest/HTTP/Server/Response.html). That matches normal `get`/`post` helpers: the route is matched and the upgrade response is built, but no WebSocket I/O runs. Use `connect_websocket` whenever you need `socket.on_message`, pings, or closing behavior.
Assume an echo route (adjust the path to your app):
```crystal
ws "/chat" do |socket, _env|
socket.on_message { |message| socket.send message }
end
```
#### Handshake-only (`get …, websocket: true`)
```crystal
get "/chat", websocket: true
response.status_code.should eq 101
response.headers["Upgrade"].should eq "websocket"
```
To assert `Sec-WebSocket-Accept` for a **known** key (RFC 6455 test vector), pass headers from `websocket_request_headers(sec_key:)` into `get` (`connect_websocket` also accepts `sec_key:` for the same purpose):
```crystal
key = "dGhlIHNhbXBsZSBub25jZQ=="
headers = websocket_request_headers(sec_key: key)
get "/chat", headers: headers, websocket: true
response.status_code.should eq 101
response.headers["Sec-WebSocket-Accept"].should eq HTTP::WebSocket::Protocol.key_challenge(key)
```
Invalid handshakes are ordinary HTTP responses — still in-memory (reuse the same `/chat` route):
```crystal
bad = websocket_request_headers
bad.delete "Sec-WebSocket-Key"
SpecKemal.process_request HTTP::Request.new("GET", "/chat", bad)
response.status_code.should eq 400
bad = websocket_request_headers
bad["Sec-WebSocket-Version"] = "7"
SpecKemal.process_request HTTP::Request.new("GET", "/chat", bad)
response.status_code.should eq 426
```
#### Full session (`connect_websocket`)
Runs the real upgrade on a loopback socket pair (UNIX socket pair on Unix-like systems, TCP on Windows), then yields a client [HTTP::WebSocket](https://crystal-lang.org/api/latest/HTTP/WebSocket.html). After the block returns, the client and server I/O are closed. The last `response` is the client’s **101 Switching Protocols** handshake.
Pattern: register `on_message`, **spawn** `client.run` so frames are processed, **yield** once so the fiber starts, then `send` / assert on a `Channel`:
```crystal
connect_websocket "/chat" do |client|
replies = Channel(String).new(1)
client.on_message { |message| replies.send message }
spawn { client.run }
Fiber.yield
client.send "hello"
replies.receive.should eq "hello"
end
response.status_code.should eq 101
```
Optional keyword arguments on `connect_websocket`:
- `headers` — merged after the default WebSocket upgrade headers (e.g. `Authorization`, extra cookies).
- `sec_key` — fixed `Sec-WebSocket-Key` for deterministic `Sec-WebSocket-Accept` checks.
- `origin` — sets `Origin` when your handler rejects unknown origins.
```crystal
connect_websocket "/chat", origin: "https://myapp.test" do |client|
# ...
end
```
For the same handshake object **inside** the block (not only via `response` after), use `inner_connect_websocket` (advanced; see `src/spec-kemal/websocket.cr`).
When you `require "spec-kemal/session"`, cookies from `with_session` apply to both `get(..., websocket: true)` and `connect_websocket`, the same as for normal HTTP helpers.
### Response Object
After making a request, access the response using the `response` method:
```crystal
get "/users"
# Status
response.status_code # => 200
response.status # => HTTP::Status::OK
response.success? # => true
# Body
response.body # => "{\"users\": []}"
# Headers
response.headers # => HTTP::Headers
response.headers["Content-Type"] # => "application/json"
response.content_type # => "application/json"
# Cookies
response.cookies # => HTTP::Cookies
response.cookies["session"] # => HTTP::Cookie
```
### Headers
Pass custom headers to your requests:
```crystal
headers = HTTP::Headers{
"Content-Type" => "application/json",
"Authorization" => "Bearer token123",
"Accept" => "application/json"
}
get "/protected", headers: headers
```
### Request Body
Send data in the request body:
```crystal
# JSON body
post "/api/users",
headers: HTTP::Headers{"Content-Type" => "application/json"},
body: {name: "John", email: "john@example.com"}.to_json
# Form-encoded body
post "/login",
headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"},
body: "username=john&password=secret"
```
## Testing Patterns
### JSON APIs
```crystal
describe "Users API" do
it "returns users as JSON" do
get "/api/users",
headers: HTTP::Headers{"Accept" => "application/json"}
response.status_code.should eq 200
response.content_type.should eq "application/json"
users = JSON.parse(response.body)
users.as_a.size.should eq 3
end
it "creates a user" do
payload = {
name: "Alice",
email: "alice@example.com"
}
post "/api/users",
headers: HTTP::Headers{"Content-Type" => "application/json"},
body: payload.to_json
response.status_code.should eq 201
user = JSON.parse(response.body)
user["name"].should eq "Alice"
end
it "handles validation errors" do
post "/api/users",
headers: HTTP::Headers{"Content-Type" => "application/json"},
body: {name: ""}.to_json
response.status_code.should eq 422
end
end
```
### Form Data
```crystal
describe "Login" do
it "authenticates with valid credentials" do
post "/login",
headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"},
body: "email=user@example.com&password=secret123"
response.status_code.should eq 302
response.headers["Location"].should eq "/dashboard"
end
end
```
### Authentication
```crystal
describe "Protected Routes" do
it "requires authentication" do
get "/admin/dashboard"
response.status_code.should eq 401
end
it "allows access with valid token" do
headers = HTTP::Headers{
"Authorization" => "Bearer valid-jwt-token"
}
get "/admin/dashboard", headers: headers
response.status_code.should eq 200
end
end
```
### Sessions
For testing session-based features, require the session module:
```crystal
require "spec-kemal/session"
```
**Important:** Configure your session secret before tests:
```crystal
Spec.before_each do
Kemal::Session.config.secret = "your-test-secret"
end
```
Use `with_session` to create an authenticated session:
```crystal
describe "Dashboard" do
it "shows user data from session" do
with_session do |session|
session.int("user_id", 42)
session.string("username", "alice")
get "/dashboard"
response.body.should contain "Welcome, alice"
end
end
it "handles session expiry" do
with_session do |session|
session.int("user_id", 42)
# Session is automatically destroyed after the block
end
get "/dashboard"
response.status_code.should eq 401
end
end
```
**Available session methods:**
```crystal
session.string("key", "value") # String
session.int("key", 42) # Int32
session.bigint("key", 12345_i64) # Int64
session.float("key", 3.14) # Float64
session.bool("key", true) # Bool
session.object("key", my_object) # Any serializable object
```
## Configuration
### Disable Logging
Logging is disabled by default in spec-kemal. To enable it:
```crystal
Kemal.config.logging = true
```
### Error Handling
By default, Kemal rescues errors and renders an error page. For testing, you may want exceptions to propagate:
```crystal
Spec.before_each do
Kemal.config.always_rescue = false
end
```
This is useful when testing error handling:
```crystal
it "raises on invalid input" do
expect_raises(JSON::ParseException) do
post "/api/data",
headers: HTTP::Headers{"Content-Type" => "application/json"},
body: "invalid json"
end
end
```
### Test Environment
Always run tests with `KEMAL_ENV=test`:
```bash
KEMAL_ENV=test crystal spec
```
Or set it in your spec helper:
```crystal
ENV["KEMAL_ENV"] = "test"
```
## Troubleshooting
### "response is nil" Error
Make sure you've made a request before accessing `response`:
```crystal
# Wrong
response.body # Error: response is nil
# Correct
get "/"
response.body # Works!
```
### Tests Interfering with Each Other
Clear Kemal's configuration between tests:
```crystal
Spec.after_each do
Kemal.config.clear
end
```
### Session Not Working
1. Ensure you've required the session module:
```crystal
require "spec-kemal/session"
```
2. Set the session secret:
```crystal
Kemal::Session.config.secret = "test-secret"
```
## Contributing
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
1. Fork it (<https://github.com/kemalcr/spec-kemal/fork>)
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Write tests for your changes
4. Ensure all tests pass (`crystal spec`)
5. Ensure code is formatted (`crystal tool format`)
6. Commit your changes (`git commit -am 'Add some feature'`)
7. Push to the branch (`git push origin my-new-feature`)
8. Create a new Pull Request
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Contributors
- [sdogruyol](https://github.com/sdogruyol) - Creator and maintainer
Documentation
Built from the current release. The first visit to a release nobody has asked for starts its build.
Links
This release
- Version
1.0.0- Tagged
- Jun 10, 2026
- Commit
d0cf79de3e7f- Indexed
- not yet
Dependents
Repository
github.com/kemalcr/spec-kemal
Metadata
- Created
- Aug 12, 2026
- Updated
- Aug 12, 2026
- Synced
- Aug 12, 2026
- Versions
- 11