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.