Skip to content

Repository files navigation

Loadsmith

🌐 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::User to add domain-specific helpers
  • Minimal dependencies — only logger and webrick; everything else is Ruby stdlib (Net::HTTP, JSON)

📖 For practical patterns and pitfalls, see the Writing Guide (日本語版).

Installation

gem install loadsmith

Or in a Gemfile:

gem "loadsmith"

Requires Ruby 3.2+ (Ruby 4.0+ for Ractor parallelism).

Quick Start

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 :main

Run it directly (ruby my_test.rb) or via the CLI (see CLI).

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 screen

CLI options take precedence over the test file's config.

Concepts

Users

Each virtual user is a Loadsmith::User instance with:

  • user.id — unique identity from the user pool
  • user.store — hash persisted across iterations and runs (via User Memory); use for long-lived data like auth tokens
  • user.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.

Custom User Classes

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 Player

Screens

A 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)
end

To 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: 42

When 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, GachaScreen

Scenarios

Scenarios 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
end

Access Classes

For 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)
end

Class-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/detail

Shared config with abstract bases:

class AdminAccess < Loadsmith::Access
  abstract!
  headers "X-Admin-Token" => "secret"
end

class AdminUserList < AdminAccess
  get "/admin/users"
end

Session 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.

Response

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.response

When 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
end

Override 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 failure

Lifecycle Hooks

Loadsmith.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)
end

User Pool

By default, user IDs are sequential integers 1..users. Customize with user_pool:

Loadsmith.user_pool do |size|
  size.times.map { |i| "player_#{i + 1}" }
end

User Memory

Loadsmith 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.

Metrics Grouping

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
end

Use metric "..." to set a metrics key that differs from the path.

Configuration

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)
end

Choosing the runner

runner selects the execution backend explicitly instead of letting Loadsmith infer one from the Ruby version:

  • :auto (default) — RactorRunner on Ruby 4.0+, otherwise ThreadRunner
  • :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.

Debugging with Dry Run

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)

Web UI

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

Output

Real-time Terminal

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
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

JSON File

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).

Running the Example

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 --web

Project Structure

lib/
  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)

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages