Skip to main content

Smartest vs RSpec

RSpec is a mature Ruby testing framework with a broad BDD DSL, nested example groups, shared examples, metadata, and a large matcher ecosystem.

Smartest is smaller and more focused. It is designed for tests where setup dependencies should be visible in the test signature and fixture dependency graph.

At a Glance

ConcernRSpecSmartest
Test shapedescribe / context / it blocksTop-level test("name") blocks
Reusable setuplet, let!, before, helper methodsClass-based fixtures
Dependency visibilityExamples call helper methods from the bodyTests request fixtures with keyword arguments
Setup dependenciesOften expressed by one let calling anotherFixture block keyword arguments
Teardownafter hooks or helper-owned teardownon_teardown inside the fixture that owns the resource
Best fitRich BDD structure and RSpec ecosystemExplicit pytest-style fixture injection

let Compared With Keyword Fixtures

RSpec let is useful when examples need lazy helper methods:

RSpec
RSpec.describe UserMailer do
let(:user) { User.new(name: "Alice") }
let(:mailer) { UserMailer.new(user) }

it "renders the subject" do
expect(mailer.subject).to eq("Welcome, Alice")
end
end

In Smartest, fixtures live in a Smartest::Fixture subclass and tests request them with required keyword arguments:

Smartest
class MailerFixture < Smartest::Fixture
fixture :user do
User.new(name: "Alice")
end

fixture :mailer do |user:|
UserMailer.new(user)
end
end

around_suite do |suite|
use_fixture MailerFixture
suite.run
end

test("renders the subject") do |mailer:|
expect(mailer.subject).to eq("Welcome, Alice")
end

The Smartest test signature shows that the test depends on mailer. The mailer fixture signature shows that it depends on user.

Resource Teardown

RSpec can clean up resources with hooks. Smartest puts teardown next to the resource acquisition:

Smartest
class WebFixture < Smartest::Fixture
fixture :server do
server = TestServer.start
on_teardown { server.stop }
server
end

fixture :client do |server:|
Client.new(base_url: server.url)
end
end

This keeps lifecycle ownership local. The fixture that starts server also registers the teardown for server.

Method Stubs

RSpec method stubs often live in before hooks:

RSpec
RSpec.describe Checkout do
let(:fixed_now) { Time.utc(2026, 1, 1, 0, 0, 0) }

before do
allow(Time).to receive(:now).and_return(fixed_now)
end

it "uses the fixed time" do
expect(Checkout.call.created_at).to eq(fixed_now)
end
end

Smartest keeps the stub in a fixture by default, so the test signature shows that fixed time is part of its setup:

Smartest
class TimeFixture < Smartest::Fixture
fixture :fixed_now do
fixed_now = Time.utc(2026, 1, 1, 0, 0, 0)
simple_stub(Time, :now) { fixed_now }
fixed_now
end
end

around_suite do |suite|
use_fixture TimeFixture
suite.run
end

test("uses the fixed time") do |fixed_now:|
expect(Checkout.call.created_at).to eq(fixed_now)
end

Smartest resets the stub when the fixture is torn down, including when the test fails. For allow_any_instance_of, use simple_stub_any_instance_of in the same fixture-first style:

RSpec
before do
allow_any_instance_of(ApplicationController)
.to receive(:current_user)
.and_return(user1)
end
Smartest
class AuthFixture < Smartest::Fixture
fixture :user1 do
create(:user)
end

fixture :logged_in_as_user1 do |user1:|
simple_stub_any_instance_of(ApplicationController, :current_user) { user1 }
user1
end
end

test("uses the current user") do |logged_in_as_user1:|
expect(call_api.user).to eq(logged_in_as_user1)
end

If a replacement is an intentional rule for every applicable test and does not depend on fixture data, around_test is available as an autouse-fixture-style alternative:

Smartest
around_test do |test|
simple_stub(PushNotifier, :deliver_later) { :stubbed }
test.run
end

The tests do not need a stub-only keyword. Smartest resets the method stub when the hook exits; use around_suite when one replacement should cover the whole suite.

Constant stubs are different from method stubs. Use with_stub_const with a block in a test body, around_test, or around_suite; it is intentionally not a fixture helper because Ruby constants are process-global:

Smartest
test("uses fake payment provider") do
with_stub_const("AppConfig::PAYMENT_PROVIDER", "fake") do
expect(Checkout.call).to eq(:paid)
end
end

When Smartest Is a Good Fit

Smartest is worth considering when:

  • tests have setup dependencies that should be visible at the test boundary
  • one fixture naturally depends on another fixture
  • teardown should live beside the resource setup
  • you want a small runner with pytest-style fixture injection for Ruby

RSpec may be a better fit when:

  • the suite relies heavily on nested describe and context structure
  • shared examples, metadata, or RSpec-specific integrations are central
  • the team already benefits from RSpec's matcher and extension ecosystem

Moving One Test at a Time

Smartest does not load test/ or spec/ by default. A project can keep RSpec tests in spec/ while adding Smartest tests under smartest/:

spec/
user_mailer_spec.rb
smartest/
test_helper.rb
fixtures/
mailer_fixture.rb
user_mailer_test.rb

Start by moving one setup concept into a fixture class, register it with use_fixture, then make the test request only the fixtures it needs.