Skip to main content

Test a Rails app locally

Rails system tests are a wonderful idea: they make it straightforward to drive a real browser through any edge case in your app, using FactoryBot, ActiveRecord, and Rails helpers to shape the state from Ruby. But two operational problems show up over time — system tests run on Capybara, which is hard to customize beyond its defaults, and the architecture tends to produce flaky tests. Smartest replaces the runner with a Capybara-independent design built on Playwright and pytest-style fixtures. It coexists with your existing system tests, so you can migrate gradually instead of rewriting everything at once.

bundle exec smartest --init-rails
bin/rails db:prepare
bin/rails db:test:prepare
bundle exec smartest smartest/example_rails_system_test.rb

The generated setup starts your Rails application as a test server, creates a Playwright browser page, and lets each test request the setup it needs through keyword fixtures.

test("suspended user sees account restriction page") do |suspended_user_page:|
suspended_user_page.goto("/dashboard")

expect(
suspended_user_page.get_by_role("heading", name: "Your account is suspended")
).to be_visible
end

When to use Smartest with Rails

Use Smartest when you want to build test state from Ruby and verify the result through a real browser.

Use caseRecommendation
You want the simplest Rails-default system test setupRails system test + Capybara
Your app already uses RSpec system specs heavilyRSpec system spec
You want to test a deployed staging or production-like environmentNode.js Playwright Test
You want to create Rails test state with FactoryBot, ActiveRecord, stubs, jobs, or mailers, then verify it with PlaywrightSmartest
You want to keep using Capybara DSL such as visit, fill_in, or assert_selectorCapybara
You want to use Playwright APIs directly from RubySmartest + playwright-ruby-client

Smartest is not a Capybara compatibility layer. Browser code uses Playwright APIs such as page.goto, page.get_by_role, locator.fill, and Playwright web-first assertions.

Smartest is also not intended to replace Node.js Playwright Test for production-like E2E suites that need Playwright traces, reports, browser project matrices, and parallel workers across deployed environments.

Quick start

Add Smartest to the test group:

bundle add smartest --group test

Generate the Rails browser-test scaffold:

bundle exec smartest --init-rails

The scaffold creates:

smartest/fixtures/rails_system_fixture.rb
smartest/matchers/playwright_matcher.rb
smartest/example_rails_system_test.rb

Smartest uses smartest/ by default so its files can be run by the Smartest runner without changing existing Rails test/ or RSpec spec/ directories. If you prefer a Rails-like layout, you can move tests under a structure such as smartest/system/ or test/smartest/system/ and pass that path to bundle exec smartest.

It also adds playwright-ruby-client to the Gemfile test group, installs the Playwright npm package, and downloads browsers.

If your Rails app runs in Docker and browsers should live in a Playwright sidecar container, initialize with SMARTEST_SKIP_BROWSER_DOWNLOAD=1 instead. See Test a Rails app with Docker for the sidecar setup.

Prepare the Rails databases:

bin/rails db:prepare
bin/rails db:test:prepare

On a fresh Rails app, db:prepare creates and migrates the app database first, which also writes db/schema.rb or db/structure.sql. Then db:test:prepare can load that schema into the test database.

Run the generated example:

bundle exec smartest smartest/example_rails_system_test.rb

Your first Rails browser test

The generated example registers the Rails system fixture and Playwright matcher from around_suite.

around_suite do |suite|
use_fixture RailsSystemTestFixture
use_matcher PlaywrightMatcher
suite.run
end

A test can request the generated page: fixture and use Playwright directly:

test("home page is visible") do |page:|
page.goto("/")

expect(page.locator("body")).to be_visible
end

The page: fixture is connected to the Rails test server. A relative URL such as "/" is resolved against the generated Rails server base_url.

Think in Rails state, then browser behavior

The main benefit of Rails system tests is not only that they drive a real browser. The bigger benefit is that a test can create application state directly from Ruby.

Smartest keeps that strength and makes the setup visible from the test signature.

The examples below assume FactoryBot is available and FactoryBot::Syntax::Methods has been included where the fixtures run. If your app does not use FactoryBot, create records with ActiveRecord directly:

fixture :admin_user do
User.create!(
name: "Admin",
email: "admin@example.com",
role: "admin"
)
end
class ApplicationFixture < Smartest::Fixture
fixture :suspended_user do
create(:user, :suspended)
end

fixture :suspended_user_page do |page:, suspended_user:|
simple_stub_any_instance_of(ApplicationController, :current_user) do
suspended_user
end

page
end
end

Register the application-specific fixture:

around_suite do |suite|
use_fixture RailsSystemTestFixture
use_fixture ApplicationFixture
use_matcher PlaywrightMatcher
suite.run
end

Then request the stateful page fixture from the test:

test("suspended user sees account restriction page") do |suspended_user_page:|
suspended_user_page.goto("/dashboard")

expect(
suspended_user_page.get_by_role("heading", name: "Your account is suspended")
).to be_visible
end

In this example, requesting suspended_user_page: means:

  1. create a suspended user,
  2. stub ApplicationController#current_user,
  3. open a Playwright page connected to the Rails test server,
  4. reset the stub when the test finishes.

The setup is not hidden in a before block or helper. It is visible in the test signature.

Smartest fixtures are not Rails YAML fixtures

Rails already has "fixtures" for loading database records from YAML files. Smartest fixtures are different.

A Smartest fixture is a dependency-injected test resource. It can create records, open browser pages, apply stubs, prepare mailers, configure jobs, or combine several of those into a named test state.

Rails conceptSmartest equivalent
let(:user) { create(:user) }fixture :user do create(:user) end
before { login_as(user) }`fixture :signed_in_page do
before { allow(...).to receive(...) }`fixture :stubbed_page do
scenario "..." do ... end`test("...") do
hidden setupexplicit keyword fixture dependencies

A fixture can also depend on other fixtures by keyword argument:

fixture :admin_user do
create(:user, :admin)
end

fixture :admin_page do |page:, admin_user:|
simple_stub_any_instance_of(ApplicationController, :current_user) do
admin_user
end

page
end

Stateful page fixtures

Rails browser tests are often easier to read when user state, stubs, and the browser page are combined into a named page fixture.

simple_stub and simple_stub_any_instance_of are Smartest stub helpers. Stubs installed inside fixtures are automatically reset during fixture teardown.

class ApplicationFixture < Smartest::Fixture
fixture :admin_user do
create(:user, :admin)
end

fixture :suspended_user do
create(:user, :suspended)
end

fixture :admin_page do |page:, admin_user:|
simple_stub_any_instance_of(ApplicationController, :current_user) do
admin_user
end

page
end

fixture :suspended_user_page do |page:, suspended_user:|
simple_stub_any_instance_of(ApplicationController, :current_user) do
suspended_user
end

page
end

fixture :page_with_push_stubbed do |page:|
simple_stub(PushNotifier, :deliver_later) { :stubbed }

page
end
end

The examples use ApplicationController#current_user stubbing because it shows how Smartest fixtures can create app-specific browser states. In a real Rails app, you may prefer your existing authentication helper, Devise/Warden test helpers, or a cookie/session-based login fixture.

Tests can request the exact state they need:

test("admin opens the user management page") do |admin_page:|
admin_page.goto("/admin/users")

expect(admin_page.get_by_role("heading", name: "Users")).to be_visible
end

test("suspended user sees account restriction page") do |suspended_user_page:|
suspended_user_page.goto("/dashboard")

expect(
suspended_user_page.get_by_role("heading", name: "Your account is suspended")
).to be_visible
end

test("push failure banner is not shown when push is stubbed") do |page_with_push_stubbed:|
page_with_push_stubbed.goto("/settings/notifications")

expect(page_with_push_stubbed.get_by_text("Push failed")).not_to be_visible
end

The examples use string paths for simplicity. If you want Rails route helpers such as admin_users_path, expose them from your own fixture or helper module.

This style is especially useful for edge cases that are hard to create by hand through the UI, such as suspended users, admin-only states, billing failures, feature flags, push notification failures, or external service failures.

From Capybara to Playwright

Smartest does not provide Capybara methods. Use Playwright locators instead.

CapybaraSmartest + Playwright
visit root_pathpage.goto("/")
fill_in "Email", with: user.emailpage.get_by_label("Email").fill(user.email)
click_button "Log in"page.get_by_role("button", name: "Log in").click
assert_text "Dashboard"expect(page.get_by_text("Dashboard")).to be_visible
assert_selector "h1", text: "Users"expect(page.locator("h1")).to have_text("Users")

Playwright locators wait for the page to become ready before performing many actions and assertions. This is useful for Rails apps with Turbo, Stimulus, React, Vue, or other asynchronous UI behavior.

How the generated Rails fixture works

The generated Rails fixture starts Rails.application with Smartest::Rails::TestServer.

# frozen_string_literal: true

require "smartest/rails"
require "playwright"

ENV["RAILS_ENV"] = "test"
ENV["RACK_ENV"] = "test"
require_relative "../../config/environment"

class RailsSystemTestFixture < Smartest::Fixture
suite_fixture :rails_server do
server = Smartest::Rails::TestServer.new(
app: Rails.application,
host: ENV["SMARTEST_RAILS_TEST_SERVER_HOST"],
port: ENV["SMARTEST_RAILS_TEST_SERVER_PORT"],
)
server.start
server.wait_for_ready

on_teardown do
server.stop
server.wait_for_stopped
end

server
end

suite_fixture :base_url do |rails_server:|
ENV.fetch("SMARTEST_RAILS_BASE_URL", rails_server.base_url)
end

suite_fixture :browser do
ws_endpoint = ENV["PLAYWRIGHT_WS_ENDPOINT"]

if ws_endpoint && !ws_endpoint.empty?
playwright_execution = Playwright.connect_to_browser_server(
ws_endpoint,
browser_type: selected_browser_type.to_s,
)
on_teardown { playwright_execution.stop }

playwright_execution.browser
else
playwright_execution = Playwright.create(
playwright_cli_executable_path: ENV.fetch(
"PLAYWRIGHT_CLI_EXECUTABLE_PATH",
"./node_modules/.bin/playwright",
)
)
on_teardown { playwright_execution.stop }

playwright = playwright_execution.playwright
browser = playwright.public_send(selected_browser_type).launch(**browser_launch_options)
on_teardown { browser.close }
browser
end
end

private

def selected_browser_type
case ENV.fetch("BROWSER", "chromium")
when "firefox"
:firefox
when "webkit"
:webkit
else
:chromium
end
end

def browser_launch_options
launch_options = {}
launch_options[:headless] = !%w[0 false].include?(ENV.fetch("HEADLESS", "true"))
if (slow_mo = ENV.fetch("SLOW_MO", "0").to_i) > 0
launch_options[:slowMo] = slow_mo
end

launch_options
end
end

Smartest::Rails::TestServer is loaded only when smartest/rails is required. Plain require "smartest" does not load Puma.

The generated fixture forces RAILS_ENV and RACK_ENV to test, then loads config/environment when test_helper requires the fixture file. That makes Rails constants such as ActiveRecord models available inside test files and around_test hooks before per-test fixtures are resolved.

The generated fixture keeps expensive resources suite-scoped:

  • rails_server
  • base_url
  • browser

The browser fixture either connects to PLAYWRIGHT_WS_ENDPOINT or starts a local Playwright runtime and browser, then tears down the resources it created.

Each test gets its own browser context and page:

class RailsSystemTestFixture < Smartest::Fixture
fixture :browser_context do |base_url:, browser:|
context = browser.new_context(baseURL: base_url)
on_teardown { context.close }
context
end

fixture :page do |browser_context:|
page = browser_context.new_page
on_teardown { page.close }
page
end
end

The Rails app runs in the same Ruby process as the Smartest test runner. This is important for Rails-native tests that use Ruby-side state, method stubs, and test helpers.

warning

Do not start the app separately with bin/rails server -e test for tests that depend on FactoryBot-created state or method stubs.

If the Rails server runs in another process, stubs installed by the Smartest test process are not visible to the Rails application process.

Test server host and port

By default, the Rails test server binds to 127.0.0.1 and asks the OS for an available port.

Set SMARTEST_RAILS_TEST_SERVER_PORT when you need a fixed port:

SMARTEST_RAILS_TEST_SERVER_PORT=4001 bundle exec smartest smartest/example_rails_system_test.rb

This is useful when debugging, inspecting browser traffic, or integrating with tools that expect a stable local port.

Set SMARTEST_RAILS_TEST_SERVER_HOST only when the Rails test server must bind to another interface. For example, Docker sidecar runs usually need SMARTEST_RAILS_TEST_SERVER_HOST=0.0.0.0 plus SMARTEST_RAILS_BASE_URL=http://web:4001; see Test a Rails app with Docker.

Method stubs

Rails browser tests often need to stub methods that are called inside the Rails server thread.

Because the generated Rails server runs in the same Ruby process as the test runner, method stubs installed by Smartest fixtures are visible to the Rails server thread.

fixture :suspended_user_page do |page:, suspended_user:|
simple_stub_any_instance_of(ApplicationController, :current_user) do
suspended_user
end

page
end

The stub is applied during fixture setup and reset from fixture teardown.

warning

Smartest method stubs are process-wide. They are visible across Fibers and Threads in the same Ruby process.

Run Rails browser tests that use method stubs serially inside one Ruby process. Do not rely on method-stub isolation for multi-threaded parallel test execution.

Database setup and cleanup

Prepare the databases before running Smartest:

bin/rails db:prepare
bin/rails db:test:prepare
bundle exec smartest smartest/example_rails_system_test.rb

Browser requests run through the Rails test server. Depending on your database connection setup, records created inside an uncommitted transaction may not be visible from the browser request.

A conservative starting point is to use truncation cleanup for browser tests:

require "database_cleaner/active_record"

around_suite do |suite|
DatabaseCleaner[:active_record].clean_with(:truncation)

around_test do |test|
DatabaseCleaner[:active_record].strategy = :truncation

DatabaseCleaner[:active_record].cleaning do
test.run
end
end

use_fixture RailsSystemTestFixture
use_matcher PlaywrightMatcher
suite.run
end

If your app setup makes records created by fixtures visible from browser requests under transaction cleaning, you can switch the per-test strategy to :transaction for faster cleanup:

around_test do |test|
DatabaseCleaner[:active_record].strategy = :transaction

DatabaseCleaner[:active_record].cleaning do
test.run
end
end

If records created by Smartest fixtures are not visible from the browser, use truncation or another application-specific cleanup strategy.

Parallel execution

Keep Rails browser tests serial by default.

For parallel execution, run each worker in a separate Ruby process with its own database and Rails test server. Smartest does not provide automatic database separation or method-stub isolation between parallel workers.

Troubleshooting

The Rails app booted in development mode

Make sure RAILS_ENV is set before config/environment is loaded.

The generated fixture forces the Rails and Rack environments to test while test_helper is loading fixture files:

ENV["RAILS_ENV"] = "test"
ENV["RACK_ENV"] = "test"

require_relative "../../config/environment"

Method stubs do not affect the browser request

Check that the Rails app is not running in a separate process.

This will not see stubs installed by the Smartest process:

bin/rails server -e test

Use the generated RailsSystemTestFixture so the Rails app runs in the same Ruby process as the test runner.

Records created by fixtures are not visible in the browser

Use truncation cleanup instead of transaction cleanup, or adjust your database connection strategy so the Rails server request can see the records created by the test.

The test server port changes every run

Set SMARTEST_RAILS_TEST_SERVER_PORT:

SMARTEST_RAILS_TEST_SERVER_PORT=4001 bundle exec smartest smartest/example_rails_system_test.rb

Summary

Smartest for Rails is best understood as a Rails-native browser test runner:

  • Rails test state is created from Ruby.
  • Setup is expressed as explicit keyword fixtures.
  • Browser behavior is verified with Playwright locators and web-first assertions.
  • Rails runs in the same Ruby process as the test runner.
  • Stubs and database cleanup should be handled carefully, especially when introducing parallel execution.

Use it when you want Rails system-test power with a more explicit fixture model and Playwright-based browser assertions.