🌐 English | 日本語
A screen-transition-based load testing framework for Ruby. Define user flows as screen navigations, and Loadsmith measures per-API performance with real-time stats.
- Screen-based scenarios — model what users actually do (visit screens), not raw API calls
- Per-API metrics — latency percentiles (p50/p95/p99), RPS, error rates per endpoint
- Ractor parallelism — true parallel execution with Ractor workers on Ruby 4.0+, with a Thread-based fallback on Ruby 3.2+
- Access classes — reusable, class-based API definitions with before/after hooks and automatic session-cookie tracking
- Custom User classes — subclass
Loadsmith::Userto add domain-specific helpers - Minimal dependencies — only
loggerandwebrick; everything else is Ruby stdlib (Net::HTTP, JSON)
📖 For practical patterns and pitfalls, see the Writing Guide (日本語版).
gem install loadsmithOr in a Gemfile:
gem "loadsmith"Requires Ruby 3.2+ (Ruby 4.0+ for Ractor parallelism).
require "loadsmith"
Loadsmith.config do
self.base_url = "http://localhost:3000"
self.users = 100
self.spawn_rate = 10
self.workers = 4
end
class Home < Loadsmith::Access
get "/api/home"
end
Loadsmith.screen :home do |user|
Home.call(user)
end
Loadsmith.scenario :main do
visit :home
end
Loadsmith.run :mainRun it directly (ruby my_test.rb) or via the CLI (see CLI).
The gem installs a loadsmith executable that loads a test file and runs it:
loadsmith my_test.rb # headless run of scenario :main
loadsmith -s checkout my_test.rb # pick a scenario
loadsmith -w -p 8089 my_test.rb # start the web dashboard
loadsmith -o results/ my_test.rb # write result JSON files to results/
loadsmith --dry-run my_test.rb # one user, one iteration, verbose logging
loadsmith --step my_test.rb # dry run, pausing after each screenCLI options take precedence over the test file's config.
Each virtual user is a Loadsmith::User instance with:
user.id— unique identity from the user pooluser.store— hash persisted across iterations and runs (via User Memory); use for long-lived data like auth tokensuser.state— hash reset at the start of every iteration, never persisted; use for per-iteration working data (e.g. the card picked on the current run)user.headers— HTTP headers (default:Content-Type: application/json)user.abort!/user.aborted?— stop the current scenario early
Rule of thumb: default to user.state; reach for user.store only when the value must survive into the next iteration or the next run.
Screen blocks, lifecycle hooks, and Access classes all receive the user object.
Subclass Loadsmith::User to add domain-specific helpers:
class Player < Loadsmith::User
def has_cards? = store[:cards]&.any?
def token = headers["Authorization"]
end
Loadsmith.user_class PlayerA screen represents a page or view the user sees. Each screen makes API calls via Access classes:
Loadsmith.screen :card_list do |user|
CardList.call(user)
endTo parameterize a visit, declare a second parameter and pass options from the scenario.
Screens that only take |user| ignore any options, so this is opt-in:
Loadsmith.screen :card_detail do |user, opts|
CardDetail.call(user, id: opts[:id])
end
# In a scenario:
visit :card_detail, id: 42When a screen needs private helpers or complex logic, define it as a class with screen_class:
Loadsmith.screen_class :gacha do
def call(user)
enter_menu(user)
GachaDraw.call(user)
end
private
def enter_menu(user)
SpecialItemsList.call(user)
GachaList.call(user)
end
end
# Or register an existing Screen subclass:
Loadsmith.screen_class :gacha, GachaScreenScenarios define screen transition flows using a simple DSL:
Loadsmith.scenario :main do
visit :home
think 1..3 # random wait 1-3s (simulates user reading)
choose do # weighted random branching
percent 70 do
visit :card_list
think 1..2
visit :card_detail
end
percent 30, scenario: :gacha_flow # reference another scenario
end
endFor reusable API definitions, subclass Loadsmith::Access. Each class defines an endpoint, each instance is one request:
class Login < Loadsmith::Access
post "/api/auth/login"
def request_json
{ user_id: "user_#{user.id}" }
end
def after(res)
user.headers["Authorization"] = "Bearer #{res['token']}" if res.success?
end
end
class CardList < Loadsmith::Access
get "/api/cards"
def after(res)
user.store[:cards] = res["cards"] if res.success?
end
end
class CardProbe < Loadsmith::Access
get "/api/cards/:id"
allow_status 404 # a missing card is part of the normal flow
end
# Use in screens:
Loadsmith.screen :card_list do |user|
CardList.call(user)
end
# Or in lifecycle hooks:
Loadsmith.on_start do |user|
Login.call(user)
endClass-level DSL:
| Declaration | Purpose |
|---|---|
get / post / put / patch / delete "path" |
HTTP method and path (may contain :name placeholders) |
allow_status 404, 409 |
Non-2xx statuses that are part of the normal flow (Integers or Ranges) |
metric "..." |
Metrics key differing from the path |
headers "X-Key" => "v" |
Default headers for all requests from this class |
abstract! |
Mark as an abstract base holding shared config; calling it raises |
Instance override points:
| Method | Purpose |
|---|---|
before |
Pre-request setup |
skip? |
Return truthy to skip the request at runtime (no HTTP call, no metric; a null Response is returned) |
after(response) |
Post-request processing |
request_json |
JSON request body |
request_body |
Raw request body |
request_params |
Path/query params: keys matching :name fill the path, the rest become the query string (any verb) |
request_headers |
Per-request headers |
Per-call arguments: keyword arguments passed to .call are exposed via opts in every override point:
class CardDetail < Loadsmith::Access
get "/api/cards/:id/detail"
def request_params
{ id: opts[:id] }
end
end
CardDetail.call(user, id: 42) # GET /api/cards/42/detailShared config with abstract bases:
class AdminAccess < Loadsmith::Access
abstract!
headers "X-Admin-Token" => "secret"
end
class AdminUserList < AdminAccess
get "/admin/users"
endSession cookies: Loadsmith::Access automatically merges every Set-Cookie response header into user.headers["Cookie"], so later requests from any Access class carry the session. To opt out for a specific endpoint, subclass Loadsmith::BaseAccess instead — it is identical minus the cookie jar.
Error handling: an HTTP 4xx/5xx response or a transport failure (timeout, connection refused, DNS, TLS) records a scenario error and aborts the user's current iteration — later screens usually assume the call succeeded. To treat specific non-2xx statuses as expected, declare allow_status 404, 409 (Integers or Ranges) on the Access class. Allowed statuses still appear in metrics but don't record an error or abort.
Access.call returns the executed Access instance; the raw Loadsmith::Response is available via #response (#success? is delegated for convenience). The simplest way to expose data is a method reading lazily from the response:
class CardList < Loadsmith::Access
get "/api/cards"
def cards
response["cards"] if success?
end
end
access = CardList.call(user)
access.cards # parsed lazily (and memoized) on first use
access.success? # delegated to access.responseWhen you also need side effects (updating user.state, setting headers) or reshaping, extract in after and expose via attr_reader:
class CardList < Loadsmith::Access
get "/api/cards"
attr_reader :cards
def after(res)
return unless res.success?
@cards = res["cards"]
user.state[:cards] = @cards
end
endOverride hooks like after receive the Loadsmith::Response, which has convenience accessors:
res.ok? # true if network succeeded (no timeout/connection error)
res.success? # true if HTTP 2xx
res.status # HTTP status code (Integer)
res.json # auto-parsed JSON (memoized, {} on error)
res["cards"] # shortcut for res.json["cards"]
res.body # raw response body
res.error # error class name on network failureLoadsmith.on_start do |user|
# Runs once per user before the scenario (login, setup, etc.)
Login.call(user)
end
Loadsmith.on_stop do |user|
# Runs once per user after the scenario (logout, cleanup, etc.)
Logout.call(user)
endBy default, user IDs are sequential integers 1..users. Customize with user_pool:
Loadsmith.user_pool do |size|
size.times.map { |i| "player_#{i + 1}" }
endLoadsmith persists each user's store between scenario executions via Marshal files in tmp/loadsmith_users/. This allows multi-run state like login tokens to carry over. Clear with:
Loadsmith.clear_user_memory!Memory files are loaded with Marshal.load, which is only safe for trusted data. Never point Loadsmith at memory files from an untrusted source. user.state is never persisted.
Paths with :name placeholders group automatically under the template
(e.g. /api/cards/:id), so dynamic IDs don't scatter the metrics:
class CardDetail < Loadsmith::Access
get "/api/cards/:id/detail"
def request_params
{ id: user.store[:current_card]["id"] }
end
endUse metric "..." to set a metrics key that differs from the path.
Loadsmith.config do
self.base_url = "http://localhost:3000" # Target server
self.users = 100 # User pool size (default: 100)
self.spawn_rate = 10 # Workers spawned per second (default: 1)
self.workers = 4 # Concurrent worker threads/Ractors (default: 50)
self.duration = 60 # Test duration in seconds (default: nil = unlimited)
self.open_timeout = 5 # Connection timeout in seconds (default: 5)
self.read_timeout = 30 # Read timeout in seconds (default: 30)
self.runner = :auto # :auto (default), :ractor, or :thread
self.shutdown_grace = 3 # Seconds to wait for in-flight iterations at shutdown (default: 3)
self.results_dir = "." # Directory for result JSON files (default: ".")
self.debug = false # Verbose per-request debug logging (default: false)
endrunner selects the execution backend explicitly instead of letting Loadsmith
infer one from the Ruby version:
:auto(default) —RactorRunneron Ruby 4.0+, otherwiseThreadRunner:ractor— force Ractor parallelism (errors on Ruby < 4.0):thread— force the thread pool
| Ruby Version | :auto resolves to |
Parallelism |
|---|---|---|
| 4.0+ | RactorRunner |
True parallel Ractors |
| 3.2+ | ThreadRunner |
Thread pool (GVL-bound) |
Set runner before defining screens. Block-form screens are compiled for
the chosen runner at definition time: under the Ractor runner each screen's
block is made Ractor-shareable, so a screen that captures outer local variables
raises a ConfigurationError at definition (use screen_class or :thread).
Ractor is an experimental Ruby feature, so the first run with RactorRunner prints Ruby's "Ractor is experimental" warning to stderr. This is expected and harmless.
Loadsmith.dry_run (or loadsmith --dry-run) runs a single scenario iteration for one user, synchronously on the main thread, with verbose debug logging — full request/response traces, complete backtraces, and ordinary debugging tools (binding.irb, pp) work inside screens. No load is generated and user memory is not persisted.
Loadsmith.dry_run :main # trace all requests/responses
Loadsmith.dry_run :main, step: true # pause after each screen (Enter=continue, q=abort)Run with --web flag or call Loadsmith.serve to start the browser-based dashboard:
Loadsmith.serve(port: 8089)The Web UI provides real-time charts (RPS, latency, active users), endpoint tables, and downloadable HTML reports.
Note: The dashboard loads Chart.js, Alpine.js, and a few other assets from the jsDelivr CDN, so the browser viewing it needs internet access (the load test itself does not).
Offline / air-gapped networks: place the libraries in
lib/loadsmith/web/public/vendor/and they are served locally instead of from the CDN (reports embed them inline, so downloaded reports also work fully offline). Expected filenames:curl -Lo vendor/chart.umd.min.js https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js curl -Lo vendor/alpine.min.js https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js curl -Lo vendor/html2canvas.min.js https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js curl -Lo vendor/mermaid.min.js https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js
Loadsmith - 00:15 elapsed
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RPS: 42 | Users: 20 active, 30/100 done | Errors: 0
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Endpoint Count Avg(ms) P95(ms) P99(ms) Err
GET /api/cards 18 85 120 130 0
POST /api/gacha/draw 8 170 250 260 0
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Results are automatically saved to loadsmith_results_YYYYMMDD_HHMMSS.json (in results_dir, default: current directory) with per-endpoint summaries (count, error rate, and latency percentiles).
The example and test server ship with the repository (not the gem), so clone the repo first:
# Terminal 1: Start the test server
ruby bin/test_server
# Terminal 2: Run the load test
ruby example/sample_test.rb
# Or with Web UI:
ruby example/sample_test.rb --weblib/
loadsmith.rb # DSL entry point, configuration
loadsmith/
user.rb # User base class (public interface)
base_access.rb # Access base class without cookie handling
access.rb # Access with automatic session-cookie tracking
response.rb # Response wrapper
screen.rb # Screen base class
scenario.rb # Scenario builder & executor
iteration.rb # One user × one scenario iteration
context.rb # Internal HTTP context
runner.rb # RactorRunner / ThreadRunner
stats.rb # Metrics collection & reporting
web.rb # Web UI server (WEBrick + SSE)
MIT