Compare commits

..

No commits in common. "20495b68746d6a8df731c858a71df03eaf0960e1" and "804220262843fcea737cd2689f77901d10523bab" have entirely different histories.

10 changed files with 67 additions and 1564 deletions

View file

@ -1,88 +0,0 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
schedule:
# Run weekly on Sunday at midnight to catch Crystal updates
- cron: "0 0 * * 0"
jobs:
test:
name: Crystal ${{ matrix.crystal }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
crystal: ["1.10", "1.11", "1.12", "1.13", "1.14", "latest"]
include:
- os: macos-latest
crystal: latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Crystal
uses: crystal-lang/install-crystal@v1
with:
crystal: ${{ matrix.crystal }}
- name: Cache shards
uses: actions/cache@v4
with:
path: |
~/.cache/shards
lib
key: ${{ runner.os }}-shards-${{ hashFiles('shard.yml') }}
restore-keys: |
${{ runner.os }}-shards-
- name: Install dependencies
run: shards install
- name: Run tests
run: crystal spec --order=random
env:
KEMAL_ENV: test
- name: Check formatting
run: crystal tool format --check
if: matrix.crystal == 'latest' && matrix.os == 'ubuntu-latest'
lint:
name: Ameba Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Crystal
uses: crystal-lang/install-crystal@v1
with:
crystal: latest
- name: Cache shards
uses: actions/cache@v4
with:
path: |
~/.cache/shards
lib
key: ${{ runner.os }}-shards-${{ hashFiles('shard.yml') }}
restore-keys: |
${{ runner.os }}-shards-
- name: Install dependencies
run: shards install
- name: Install Ameba
run: |
git clone https://github.com/crystal-ameba/ameba.git /tmp/ameba
cd /tmp/ameba && make install
- name: Run Ameba
run: ameba
continue-on-error: true

1
.travis.yml Normal file
View file

@ -0,0 +1 @@
language: crystal

View file

@ -1,18 +0,0 @@
# 1.1.1 (23-02-2026)
- Fix `undefined method 'session?' for Global.class` when using spec-kemal without the session extension. Thanks @sdogruyol :pray:
# 1.1.0 (02-02-2026)
- Session testing support via `with_session` helper [#23](https://github.com/kemalcr/spec-kemal/pull/23) Thanks @hugopl :pray:
- Migrated from Travis CI to GitHub Actions
- Improved documentation with comprehensive examples
- Added inline documentation to source code
# (1.0.0) - 25-04-2021
- Support for all HTTP methods: GET, POST, PUT, PATCH, DELETE, HEAD
- Custom headers support for requests
- Request body support for POST/PUT/PATCH requests
- Updated for Kemal 1.x compatibility
- Improved handler chain building

View file

@ -1,273 +0,0 @@
# Contributing to spec-kemal
First off, thank you for considering contributing to spec-kemal! It's people like you that make spec-kemal such a great tool.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Development Setup](#development-setup)
- [Making Changes](#making-changes)
- [Testing](#testing)
- [Submitting Changes](#submitting-changes)
- [Style Guidelines](#style-guidelines)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Features](#suggesting-features)
## Code of Conduct
This project and everyone participating in it is governed by our commitment to providing a welcoming and inclusive environment. Please be respectful and constructive in all interactions.
## Getting Started
1. **Fork the repository** on GitHub
2. **Clone your fork** locally:
```bash
git clone https://github.com/YOUR-USERNAME/spec-kemal.git
cd spec-kemal
```
3. **Add the upstream remote**:
```bash
git remote add upstream https://github.com/kemalcr/spec-kemal.git
```
## Development Setup
### Prerequisites
- [Crystal](https://crystal-lang.org/install/) (1.10 or later recommended)
- Git
### Installing Dependencies
```bash
shards install
```
### Running Tests
```bash
crystal spec
```
Or with verbose output:
```bash
crystal spec --verbose
```
## Making Changes
1. **Create a feature branch** from `master`:
```bash
git checkout -b my-feature-branch
```
2. **Make your changes** with clear, descriptive commits
3. **Write or update tests** for your changes
4. **Ensure all tests pass**:
```bash
crystal spec
```
5. **Format your code**:
```bash
crystal tool format
```
6. **Check for issues** (optional but recommended):
```bash
# If you have ameba installed
ameba
```
## Testing
### Running the Test Suite
```bash
# Run all tests
crystal spec
# Run specific test file
crystal spec spec/spec-kemal_spec.cr
# Run with random order
crystal spec --order=random
```
### Writing Tests
- Place tests in the `spec/` directory
- Name test files with `_spec.cr` suffix
- Use descriptive test names that explain what is being tested
Example:
```crystal
describe "HTTP Methods" do
describe "#get" do
it "sends a GET request to the specified path" do
get "/" do
"Hello"
end
get "/"
response.body.should eq "Hello"
end
it "includes custom headers in the request" do
# test implementation
end
end
end
```
## Submitting Changes
1. **Push your branch** to your fork:
```bash
git push origin my-feature-branch
```
2. **Create a Pull Request** from your branch to `kemalcr/spec-kemal:master`
3. **Fill out the PR template** with:
- A clear description of the changes
- Any related issues (use "Fixes #123" to auto-close)
- Screenshots if applicable (for documentation changes)
4. **Wait for review** - maintainers will review your PR and may request changes
### PR Guidelines
- Keep PRs focused on a single feature or fix
- Include tests for new functionality
- Update documentation if needed
- Ensure CI passes before requesting review
## Style Guidelines
### Code Style
- Follow Crystal's standard formatting (use `crystal tool format`)
- Use meaningful variable and method names
- Keep methods small and focused
- Add documentation comments for public methods
### Documentation Style
```crystal
# Brief description of what the method does.
#
# More detailed explanation if needed, including any
# important notes or caveats.
#
# ## Parameters
#
# - `param1` : Description of param1
# - `param2` : Description of param2
#
# ## Example
#
# ```crystal
# result = my_method("value")
# result.should eq expected
# ```
#
# Returns description of return value.
def my_method(param1 : String, param2 : Int32? = nil) : ReturnType
# implementation
end
```
### Commit Messages
- Use present tense ("Add feature" not "Added feature")
- Use imperative mood ("Move cursor to..." not "Moves cursor to...")
- Keep the first line under 72 characters
- Reference issues when relevant
Good examples:
```
Add support for custom request timeout
Fix session cookie not being set correctly
Update README with session testing examples
Fixes #42
```
## Reporting Bugs
### Before Submitting
1. **Search existing issues** to avoid duplicates
2. **Try the latest version** to see if the bug has been fixed
3. **Gather information** about your environment
### Bug Report Template
```markdown
## Description
A clear description of the bug.
## Steps to Reproduce
1. Step one
2. Step two
3. ...
## Expected Behavior
What you expected to happen.
## Actual Behavior
What actually happened.
## Environment
- Crystal version: [e.g., 1.10.0]
- Kemal version: [e.g., 1.1.0]
- spec-kemal version: [e.g., 1.0.0]
- OS: [e.g., Ubuntu 22.04]
## Additional Context
Any other relevant information.
```
## Suggesting Features
We welcome feature suggestions! Please:
1. **Check existing issues** for similar suggestions
2. **Create a new issue** with the "feature request" label
3. **Describe the feature** and its use case
4. **Provide examples** of how it would be used
### Feature Request Template
```markdown
## Feature Description
A clear description of the feature.
## Use Case
Why this feature would be useful.
## Proposed API
```crystal
# How you envision using this feature
```
## Alternatives Considered
Other approaches you've thought about.
```
## Questions?
If you have questions about contributing, feel free to:
- Open an issue with the "question" label
- Reach out to maintainers
Thank you for contributing to spec-kemal! 🎉

415
README.md
View file

@ -1,409 +1,82 @@
# spec-kemal
[![CI](https://github.com/kemalcr/spec-kemal/actions/workflows/ci.yml/badge.svg)](https://github.com/kemalcr/spec-kemal/actions/workflows/ci.yml)
[![GitHub release](https://img.shields.io/github/release/kemalcr/spec-kemal.svg)](https://github.com/kemalcr/spec-kemal/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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)
- [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)
Kemal helpers to Crystal's `spec` for easy testing.
## Installation
Add spec-kemal to your `shard.yml` as a **development dependency**:
Add it to your `shard.yml`.
```yaml
name: your-kemal-app
version: 0.1.0
dependencies:
kemal:
github: kemalcr/kemal
development_dependencies:
spec-kemal:
github: kemalcr/spec-kemal
branch: master
kemal:
github: kemalcr/kemal
branch: master
```
Then run:
## Usage
```bash
shards install
```
## Quick Start
### 1. Set up your spec helper
Create or update `spec/spec_helper.cr`:
Just require it before your files in your `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
Your Kemal application
```crystal
# spec/your-kemal-app_spec.cr
require "./spec_helper"
# src/your-kemal-app.cr
describe "My Kemal App" do
it "renders the homepage" do
require "kemal"
get "/" do
"Hello World!"
end
Kemal.run
```
Now you can easily test your `Kemal` application in your `spec`s.
```
KEMAL_ENV=test crystal spec
```
```crystal
# spec/your-kemal-app-spec.cr
describe "Your::Kemal::App" do
# You can use get,post,put,patch,delete to call the corresponding route.
it "renders /" do
get "/"
response.status_code.should eq 200
response.body.should contain "Welcome"
response.body.should eq "Hello World!"
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
#### Rescue errors
Errors gets rescued by default which results in the Kemal's exception page is rendered.
This may not always be the desired behaviour, e.g. when a JSON parsing error occurs one might expect `"[]"`
and not Kemal's exception page.
```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 |
**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
### 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"
```
Set `Kemal.config.always_rescue = false` to prevent this behaviour and raise errors instead.
## 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.
1. Fork it ( https://github.com/kemalcr/spec-kemal/fork )
2. Create your feature branch (git checkout -b my-new-feature)
3. Commit your changes (git commit -am 'Add some feature')
4. Push to the branch (git push origin my-new-feature)
5. Create a new Pull Request
## Contributors
- [sdogruyol](https://github.com/sdogruyol) - Creator and maintainer
- [sdogruyol](https://github.com/sdogruyol) Sdogruyol - creator, maintainer

View file

@ -1,12 +1,10 @@
name: spec-kemal
version: 1.1.1
version: 1.0.0
crystal: "< 2.0.0"
development_dependencies:
kemal:
git: https://gitdab.com/luna/kemal.git
kemal-session:
github: kemalcr/kemal-session
authors:
- Luna <luna@l4.pm>

View file

@ -1,503 +1,20 @@
require "./spec_helper"
describe "spec-kemal" do
describe "HTTP Methods" do
describe "GET" do
it "handles basic get request" do
get "/" do
"Hello world"
end
get "/"
response.body.should eq "Hello world"
response.status_code.should eq 200
end
it "handles get with query parameters" do
get "/search" do |env|
query = env.params.query["q"]? || "empty"
"Search: #{query}"
end
get "/search?q=crystal"
response.body.should eq "Search: crystal"
end
it "handles get with multiple query parameters" do
get "/filter" do |env|
page = env.params.query["page"]? || "1"
limit = env.params.query["limit"]? || "10"
"Page: #{page}, Limit: #{limit}"
end
get "/filter?page=2&limit=25"
response.body.should eq "Page: 2, Limit: 25"
end
it "handles get with custom headers" do
get "/auth" do |env|
auth = env.request.headers["Authorization"]? || "none"
"Auth: #{auth}"
end
get "/auth", headers: HTTP::Headers{"Authorization" => "Bearer token123"}
response.body.should eq "Auth: Bearer token123"
end
it "handles get with multiple custom headers" do
get "/headers" do |env|
accept = env.request.headers["Accept"]? || "none"
lang = env.request.headers["Accept-Language"]? || "none"
"Accept: #{accept}, Lang: #{lang}"
end
headers = HTTP::Headers{
"Accept" => "application/json",
"Accept-Language" => "tr-TR",
}
get "/headers", headers: headers
response.body.should eq "Accept: application/json, Lang: tr-TR"
end
it "handles get with URL parameters" do
get "/users/:id" do |env|
id = env.params.url["id"]
"User ID: #{id}"
end
get "/users/42"
response.body.should eq "User ID: 42"
end
it "handles get with multiple URL parameters" do
get "/posts/:post_id/comments/:comment_id" do |env|
post_id = env.params.url["post_id"]
comment_id = env.params.url["comment_id"]
"Post: #{post_id}, Comment: #{comment_id}"
end
get "/posts/10/comments/5"
response.body.should eq "Post: 10, Comment: 5"
end
end
describe "POST" do
it "handles post with JSON body" do
post "/user" do |env|
env.params.json.to_json
end
json_body = {"name": "Serdar", "age": 27, "skills": ["crystal, kemal"]}
post("/user", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: json_body.to_json)
response.body.should eq(json_body.to_json)
end
it "handles post with form data" do
post "/login" do |env|
username = env.params.body["username"]? || "none"
password = env.params.body["password"]? || "none"
"Login: #{username} password: #{password}"
end
post "/login",
headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"},
body: "username=admin&password=secret"
response.body.should eq "Login: admin password: secret"
end
it "handles post with empty body" do
post "/ping" do
"pong"
end
post "/ping"
response.body.should eq "pong"
end
it "handles post and returns status code" do
post "/create" do |env|
env.response.status_code = 201
"Created"
end
post "/create"
response.status_code.should eq 201
response.body.should eq "Created"
end
end
describe "PUT" do
it "handles put request" do
put "/put-users/:id" do |env|
id = env.params.url["id"]
"Updated user #{id}"
end
put "/put-users/1"
response.body.should eq "Updated user 1"
end
it "handles put with JSON body" do
put "/put-users-json/:id" do |env|
id = env.params.url["id"]
name = env.params.json["name"]?.to_s
"Updated user #{id} to #{name}"
end
put "/put-users-json/5",
headers: HTTP::Headers{"Content-Type" => "application/json"},
body: {name: "Alice"}.to_json
response.body.should eq "Updated user 5 to Alice"
end
end
describe "PATCH" do
it "handles patch request" do
patch "/users/:id" do |env|
id = env.params.url["id"]
"Patched user #{id}"
end
patch "/users/3"
response.body.should eq "Patched user 3"
end
it "handles patch with partial JSON update" do
patch "/settings" do |env|
theme = env.params.json["theme"]?.to_s
"Theme changed to #{theme}"
end
patch "/settings",
headers: HTTP::Headers{"Content-Type" => "application/json"},
body: {theme: "dark"}.to_json
response.body.should eq "Theme changed to dark"
end
end
describe "DELETE" do
it "handles delete request" do
delete "/users/:id" do |env|
env.response.status_code = 204
""
end
delete "/users/99"
response.status_code.should eq 204
end
it "handles delete with confirmation body" do
delete "/account" do |env|
confirm = env.params.json["confirm"]?
if confirm == true
"Account deleted"
else
env.response.status_code = 400
"Confirmation required"
end
end
delete "/account",
headers: HTTP::Headers{"Content-Type" => "application/json"},
body: {confirm: true}.to_json
response.body.should eq "Account deleted"
end
end
describe "HEAD" do
it "handles head request" do
get "/status" do |env|
env.response.headers["X-Status"] = "OK"
"This body should not appear in HEAD"
end
head "/status"
response.status_code.should eq 200
# HEAD requests don't return body
end
describe "SpecKemalApp" do
it "handles get" do
get "/" do
"Hello world"
end
get "/"
response.body.should eq "Hello world"
end
describe "Response" do
it "returns correct status codes" do
get "/not-found" do |env|
env.response.status_code = 404
"Not Found"
end
get "/not-found"
response.status_code.should eq 404
end
it "returns custom headers" do
get "/custom-header" do |env|
env.response.headers["X-Custom"] = "test-value"
env.response.headers["X-Request-Id"] = "12345"
"OK"
end
get "/custom-header"
response.headers["X-Custom"].should eq "test-value"
response.headers["X-Request-Id"].should eq "12345"
end
it "returns content type header" do
get "/json" do |env|
env.response.content_type = "application/json"
%({"status": "ok"})
end
get "/json"
response.headers["Content-Type"].should eq "application/json"
end
it "handles redirect responses" do
get "/old-page" do |env|
env.response.status_code = 302
env.response.headers["Location"] = "/new-page"
""
end
get "/old-page"
response.status_code.should eq 302
response.headers["Location"].should eq "/new-page"
end
it "handles server error responses" do
get "/error" do |env|
env.response.status_code = 500
"Internal Server Error"
end
get "/error"
response.status_code.should eq 500
end
end
describe "Request Context" do
it "accesses request method" do
post "/echo-method" do |env|
env.request.method
end
post "/echo-method"
response.body.should eq "POST"
end
it "accesses request path" do
get "/echo-path" do |env|
env.request.path
end
get "/echo-path"
response.body.should eq "/echo-path"
end
it "accesses full request URL with query" do
get "/echo-resource" do |env|
env.request.resource
end
get "/echo-resource?foo=bar"
response.body.should eq "/echo-resource?foo=bar"
end
end
describe "Sessions" do
it "handles string session values" do
get "/session_var" do |env|
env.session.string?(env.params.query["key"]) || "not found"
end
with_session do |session|
session.string("hey ho!", "let's go! 🎸")
get "/session_var?key=hey+ho!"
response.body.should eq("let's go! 🎸")
end
end
it "handles integer session values" do
get "/session_int" do |env|
value = env.session.int?("counter")
value ? value.to_s : "not set"
end
with_session do |session|
session.int("counter", 42)
get "/session_int"
response.body.should eq "42"
end
end
it "handles bigint session values" do
get "/session_bigint" do |env|
value = env.session.bigint?("big_number")
value ? value.to_s : "not set"
end
with_session do |session|
session.bigint("big_number", 9999999999_i64)
get "/session_bigint"
response.body.should eq "9999999999"
end
end
it "handles float session values" do
get "/session_float" do |env|
value = env.session.float?("price")
value ? value.to_s : "not set"
end
with_session do |session|
session.float("price", 19.99)
get "/session_float"
response.body.should eq "19.99"
end
end
it "handles boolean session values" do
get "/session_bool" do |env|
value = env.session.bool?("admin")
value.nil? ? "not set" : value.to_s
end
with_session do |session|
session.bool("admin", true)
get "/session_bool"
response.body.should eq "true"
end
end
it "session is destroyed after with_session block" do
get "/check_session" do |env|
env.session.string?("test_key") || "no session"
end
with_session do |session|
session.string("test_key", "test_value")
get "/check_session"
response.body.should eq "test_value"
end
# Session should be cleared after the block
# New request without session should not have the value
end
it "handles multiple session values" do
get "/multi_session" do |env|
name = env.session.string?("name") || "guest"
role = env.session.string?("role") || "user"
"#{name} (#{role})"
end
with_session do |session|
session.string("name", "Alice")
session.string("role", "admin")
get "/multi_session"
response.body.should eq "Alice (admin)"
end
end
it "persists session across multiple requests" do
get "/set_session" do |env|
env.session.string("visit", "first")
"Set"
end
get "/get_session" do |env|
env.session.string?("visit") || "none"
end
with_session do |session|
session.string("visit", "remembered")
get "/get_session"
response.body.should eq "remembered"
# Make another request - session should persist
get "/get_session"
response.body.should eq "remembered"
end
end
end
describe "Multiple Sequential Requests" do
it "handles multiple different routes" do
get "/first" do
"First"
end
get "/second" do
"Second"
end
get "/first"
response.body.should eq "First"
get "/second"
response.body.should eq "Second"
end
it "response is updated after each request" do
get "/counter/:n" do |env|
"Count: #{env.params.url["n"]}"
end
get "/counter/1"
response.body.should eq "Count: 1"
get "/counter/2"
response.body.should eq "Count: 2"
get "/counter/3"
response.body.should eq "Count: 3"
end
end
describe "Edge Cases" do
it "handles empty response body" do
get "/empty" do |env|
env.response.status_code = 204
""
end
get "/empty"
response.status_code.should eq 204
response.body.should eq ""
end
it "handles unicode in response" do
get "/unicode" do
"Hello 世界! 🌍 Merhaba Dünya!"
end
get "/unicode"
response.body.should eq "Hello 世界! 🌍 Merhaba Dünya!"
end
it "handles unicode in request body" do
post "/echo" do |env|
env.request.body.try(&.gets_to_end) || ""
end
post "/echo", body: "Türkçe karakterler: şçğüöı"
response.body.should eq "Türkçe karakterler: şçğüöı"
end
it "handles special characters in query params" do
get "/special" do |env|
env.params.query["msg"]? || "none"
end
get "/special?msg=hello%20world%21"
response.body.should eq "hello world!"
end
it "handles routes with trailing slash" do
get "/api/users/" do
"Users list"
end
get "/api/users/"
response.body.should eq "Users list"
end
it "handles deeply nested routes" do
get "/api/v1/users/:user_id/posts/:post_id/comments" do |env|
user_id = env.params.url["user_id"]
post_id = env.params.url["post_id"]
"Comments for post #{post_id} by user #{user_id}"
end
get "/api/v1/users/10/posts/20/comments"
response.body.should eq "Comments for post 20 by user 10"
end
it "handles JSON array in request body" do
post "/batch" do |env|
body = env.request.body.try(&.gets_to_end) || "[]"
items = JSON.parse(body).as_a
"Received #{items.size} items"
end
post "/batch",
headers: HTTP::Headers{"Content-Type" => "application/json"},
body: [1, 2, 3, 4, 5].to_json
response.body.should eq "Received 5 items"
end
it "handles large response body" do
get "/large" do
"x" * 10000
end
get "/large"
response.body.size.should eq 10000
it "handles post" do
post "/user" do |env|
env.params.json.to_json
end
json_body = {"name": "Serdar", "age": 27, "skills": ["crystal, kemal"]}
post("/user", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: json_body.to_json)
response.body.should eq(json_body.to_json)
end
end

View file

@ -1,14 +1,10 @@
require "spec"
require "kemal"
require "kemal-session"
require "../src/spec-kemal"
require "../src/spec-kemal/session"
Spec.before_each do
config = Kemal.config
config.env = "test"
Kemal::Session.config.secret = "🤫"
config.setup
end

View file

@ -1,192 +1,48 @@
# spec-kemal - Testing helpers for the Kemal web framework
#
# This module provides convenient methods for testing Kemal applications
# using Crystal's built-in spec library.
#
# ## Basic Usage
#
# ```
# require "spec-kemal"
#
# describe "My App" do
# it "renders homepage" do
# get "/"
# response.status_code.should eq 200
# end
# end
# ```
#
# ## Available HTTP Methods
#
# - `get(path, headers?, body?)` - Send GET request
# - `post(path, headers?, body?)` - Send POST request
# - `put(path, headers?, body?)` - Send PUT request
# - `patch(path, headers?, body?)` - Send PATCH request
# - `delete(path, headers?, body?)` - Send DELETE request
# - `head(path, headers?, body?)` - Send HEAD request
require "spec"
require "kemal"
# Disable logging by default for cleaner test output
Kemal.config.logging = false
# Internal hook for session cookie injection. When spec-kemal/session is required,
# it registers a callback here. This avoids referencing Global.session? in the
# base library, which would fail to compile when the session extension is not used.
class SessionInjector
@@callback : (HTTP::Request -> Nil)? = nil
def self.register(&block : HTTP::Request -> Nil)
@@callback = block
end
def self.run(request : HTTP::Request) : Nil
@@callback.try(&.call(request))
end
end
# Internal class for storing the response between requests.
# This allows the `response` helper method to access the last response.
#
# NOTE: This uses class variables which are not thread-safe.
# Tests should be run sequentially, not in parallel.
class Global
# The last HTTP response received from a test request
@@response : HTTP::Client::Response?
# Sets the response from the last request
def self.response=(@@response)
end
# Returns the response from the last request
def self.response
@@response
end
end
# Generate HTTP helper methods for each HTTP verb.
# Each method creates an HTTP request and processes it through Kemal's handlers.
#
# ## Parameters
#
# - `path` - The request path (e.g., "/users", "/api/posts?page=2")
# - `headers` - Optional HTTP headers to include in the request
# - `body` - Optional request body as a string
#
# ## Examples
#
# ```
# # Simple GET request
# get "/"
#
# # GET with headers
# get "/api/users", headers: HTTP::Headers{"Authorization" => "Bearer token"}
#
# # POST with JSON body
# post "/api/users",
# headers: HTTP::Headers{"Content-Type" => "application/json"},
# body: {name: "John"}.to_json
#
# # DELETE request
# delete "/api/users/1"
# ```
{% for method in %w[get post put head delete patch] %}
# Sends a {{ method.id.upcase }} request to the specified path.
#
# ## Parameters
#
# - `path` : The URL path to request
# - `headers` : Optional HTTP headers
# - `body` : Optional request body
#
# ## Example
#
# ```crystal
# {{ method.id }} "/example"
# response.status_code.should eq 200
# ```
def {{ method.id }}(path : String, headers : HTTP::Headers? = nil, body : String? = nil) : HTTP::Client::Response
request = HTTP::Request.new("{{ method.id }}".upcase, path, headers, body)
Global.response = process_request(request)
{% for method in %w(get post put head delete patch) %}
def {{method.id}}(path, headers : HTTP::Headers? = nil, body : String? = nil)
request = HTTP::Request.new("{{method.id}}".upcase, path, headers, body )
Global.response = process_request request
end
{% end %}
# Processes an HTTP request through Kemal's handler chain.
#
# This method simulates a full HTTP request/response cycle by:
# 1. Creating an in-memory IO for the response
# 2. Injecting session cookies if session support is enabled
# 3. Building and executing the Kemal handler chain
# 4. Parsing and returning the response
#
# NOTE: This is a private method used internally by the HTTP helper methods.
private def process_request(request : HTTP::Request) : HTTP::Client::Response
def process_request(request)
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
# Inject session cookie if session support is loaded (callback registered by spec-kemal/session).
SessionInjector.run(request)
# Create the server context and process through handlers
context = HTTP::Server::Context.new(request, response)
main_handler = build_main_handler
main_handler.call(context)
# Close the response and parse it as a client response
main_handler.call context
response.close
io.rewind
client_response = HTTP::Client::Response.from_io(io, decompress: false)
Global.response = client_response
end
# Builds the Kemal handler chain by linking all configured handlers together.
#
# Kemal uses a chain of handlers (middleware) to process requests.
# This method links them together so each handler can call the next.
#
# NOTE: This is a private method used internally.
private def build_main_handler : HTTP::Handler
def build_main_handler
main_handler = Kemal.config.handlers.first
current_handler = main_handler
Kemal.config.handlers.each do |handler|
Kemal.config.handlers.each_with_index do |handler, index|
current_handler.next = handler
current_handler = handler
end
main_handler
end
# Returns the response from the last HTTP request.
#
# This method provides access to the `HTTP::Client::Response` object
# from the most recent test request. Use it to make assertions about
# the response status, body, headers, and cookies.
#
# ## Example
#
# ```
# get "/users"
#
# response.status_code.should eq 200
# response.body.should contain "John"
# response.headers["Content-Type"].should eq "application/json"
# ```
#
# ## Available Response Properties
#
# - `status_code : Int32` - HTTP status code (200, 404, etc.)
# - `status : HTTP::Status` - Status as enum (HTTP::Status::OK, etc.)
# - `body : String` - Response body content
# - `headers : HTTP::Headers` - Response headers
# - `cookies : HTTP::Cookies` - Response cookies
# - `success? : Bool` - True if status is 2xx
# - `content_type : String?` - Content-Type header value
#
# Raises `NilAssertionError` if called before making a request.
def response : HTTP::Client::Response
# ameba:disable Lint/NotNil
def response
Global.response.not_nil!
# ameba:enable Lint/NotNil
end

View file

@ -1,159 +0,0 @@
# spec-kemal/session - Session testing support for Kemal applications
#
# This module extends spec-kemal with session testing capabilities.
# It integrates with kemal-session to allow setting and testing
# session values in your specs.
#
# ## Installation
#
# Add kemal-session to your development dependencies:
#
# ```yaml
# development_dependencies:
# kemal-session:
# github: kemalcr/kemal-session
# ```
#
# ## Usage
#
# Require this module instead of the base spec-kemal:
#
# ```
# require "spec-kemal/session"
# ```
#
# Configure the session secret in your spec helper:
#
# ```
# Spec.before_each do
# Kemal::Session.config.secret = "test-secret"
# end
# ```
#
# Use `with_session` to test session-based features:
#
# ```
# it "shows user dashboard" do
# with_session do |session|
# session.int("user_id", 42)
#
# get "/dashboard"
# response.body.should contain "Welcome"
# end
# end
# ```
require "./version"
require "../spec-kemal"
require "kemal-session"
# Extend Global class with session storage.
# This allows the session to be shared between `with_session` and
# the HTTP helper methods (get, post, etc.)
class Global
# The current test session, if any.
# When set, all HTTP requests will include this session's cookie.
class_property? session : Kemal::Session?
end
# Register session cookie injection with the base library.
# This runs on every request when a session is set (e.g. inside with_session).
SessionInjector.register do |request|
if session = Global.session?
session_cookie = HTTP::Cookie.new(
Kemal::Session.config.cookie_name,
Kemal::Session.encode(session.id)
)
request.cookies << session_cookie
end
end
# Creates a new Kemal session for testing.
#
# This method:
# 1. Validates that the session secret is configured
# 2. Destroys any existing session
# 3. Creates a new session with a secure random ID
#
# Raises if `Kemal::Session.config.secret` is not set.
#
# NOTE: This is a private method. Use `with_session` instead.
private def create_session : Kemal::Session
if Kemal::Session.config.secret.empty?
raise "Kemal session secret not set. " \
"Set Kemal::Session.config.secret in your spec helper."
end
destroy_session
Global.session = Kemal::Session.new(Random::Secure.hex)
end
# Creates a new session, yields it to the block, and ensures cleanup.
#
# All spec-kemal HTTP requests (get, post, etc.) made within the block
# will automatically include this session's cookie, simulating an
# authenticated user.
#
# The session is automatically destroyed when the block exits,
# even if an exception is raised.
#
# ## Parameters
#
# Yields a `Kemal::Session` instance for setting session values.
#
# ## Example
#
# ```
# it "requires login" do
# get "/dashboard"
# response.status_code.should eq 401
# end
#
# it "shows dashboard for logged-in user" do
# with_session do |session|
# session.int("user_id", 123)
# session.string("username", "alice")
# session.bool("admin", false)
#
# get "/dashboard"
# response.status_code.should eq 200
# response.body.should contain "Welcome, alice"
# end
# end
# ```
#
# ## Available Session Methods
#
# ```
# session.string("key", "value") # Store a String
# session.int("key", 42) # Store an Int32
# session.bigint("key", 123_i64) # Store an Int64
# session.float("key", 3.14) # Store a Float64
# session.bool("key", true) # Store a Bool
# session.object("key", user) # Store any JSON-serializable object
# ```
#
# ## Notes
#
# - The session is destroyed after the block, simulating logout
# - Each `with_session` call creates a fresh session
# - Nested `with_session` calls will destroy the outer session
def with_session(&) : Nil
session = create_session
yield session
ensure
destroy_session
end
# Destroys the current test session.
#
# This method:
# 1. Calls `destroy` on the session (clears stored data)
# 2. Sets `Global.session` to nil (stops cookie injection)
#
# NOTE: This is a private method. Sessions are automatically
# destroyed when exiting a `with_session` block.
private def destroy_session : Nil
Global.session?.try(&.destroy)
Global.session = nil
end