Smartest

Smartest is a small Ruby test runner with a keyword-fixture-first design.

It lets you write tests like this:

test("factorial") do
  expect(1 * 2 * 3).to eq(6)
end

and fixture-driven tests like this:

test("GET /me") do |logged_in_client:|
  response = logged_in_client.get("/me")

  expect(response.status).to eq(200)
end

Smartest is designed around three ideas:

  1. Tests should be readable at the top level.
  2. Fixture dependencies should be explicit.
  3. Teardown should be written only when it is needed.

Installation

Add this line to your application's Gemfile:

gem "smartest"

Then run:

bundle install

Or install it directly:

gem install smartest

Quick start

Initialize a test scaffold:

bundle exec smartest --init

This creates:

smartest/test_helper.rb
smartest/fixtures/
smartest/example_test.rb

The generated example looks like this:

# smartest/example_test.rb
require "test_helper"

test("example") do
  expect(1 + 1).to eq(2)
end

Run the suite:

bundle exec smartest

By default, Smartest loads smartest/**/*_test.rb, so a separate test/ directory can remain available for Minitest.

You can also pass explicit paths:

bundle exec smartest smartest/**/*_test.rb

CLI help and version output are available with:

bundle exec smartest --help
bundle exec smartest --version

Expected output:

Running 1 test

✓ example

1 test, 1 passed, 0 failed

Defining tests

Use test at the top level:

test("adds numbers") do
  expect(1 + 2).to eq(3)
end

A test can request fixtures using required keyword arguments:

test("uses a user") do |user:|
  expect(user.name).to eq("Alice")
end

Smartest intentionally favors keyword arguments for fixture injection:

test("GET /me") do |logged_in_client:|
  # ...
end

This makes fixture usage explicit and avoids relying on positional argument order.

Expectations

Smartest uses an expectation style:

expect(actual).to eq(expected)
expect(actual).not_to eq(expected)

Examples:

test("string") do
  expect("hello").to eq("hello")
end

test("array") do
  expect([1, 2, 3]).to include(2)
end

Supported matchers include:

eq(expected)
include(expected)
be_nil
raise_error(ErrorClass)

Fixtures

Fixtures are defined in classes.

class AppFixture < Smartest::Fixture
  fixture :user do
    User.create!(
      name: "Alice",
      email: "alice@example.com"
    )
  end
end

use_fixture AppFixture

test("user") do |user:|
  expect(user.name).to eq("Alice")
end

A fixture is requested by name from a test block keyword argument.

test("user") do |user:|
  # Smartest resolves the `user` fixture
end

Fixture dependencies

Fixtures can depend on other fixtures using required keyword arguments.

class AppFixture < Smartest::Fixture
  suite_fixture :server do
    TestServer.start
  end

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

The dependency is explicit:

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

When a test requests client, Smartest resolves server first.

test("GET /health") do |client:|
  response = client.get("/health")

  expect(response.status).to eq(200)
end

Fixture scopes

Regular fixture definitions are test-scoped. Smartest creates a fresh value for each test that requests the fixture.

Use suite_fixture for expensive resources that should be created once and released after the full suite finishes:

class BrowserFixture < Smartest::Fixture
  suite_fixture :browser do
    browser = Browser.launch
    cleanup { browser.close }
    browser
  end

  fixture :page do |browser:|
    browser.new_page
  end
end

Suite fixtures are lazy: setup runs the first time a test requests the fixture, and cleanup runs once after all tests finish. Test-scoped fixtures can depend on suite fixtures, but suite fixtures cannot depend on test-scoped fixtures.

Fixtures with teardown

Not every fixture needs teardown. For fixtures that do, use cleanup.

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

    server.wait_until_ready!
    server
  end

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

cleanup blocks run after the fixture's scope finishes. For regular fixtures that means after the test. For suite_fixture, cleanup runs after the full suite.

They are executed in reverse order of registration.

fixture :temp_dir do
  dir = Dir.mktmpdir
  cleanup { FileUtils.rm_rf(dir) }

  dir
end

Recommended pattern:

fixture :server do
  server = TestServer.start
  cleanup { server.stop }

  server.wait_until_ready!
  server
end

Register cleanup immediately after acquiring the resource, before later setup steps that may fail.

Logged-in client example

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

    server.wait_until_ready!
    server
  end

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

  fixture :user do
    User.create!(
      name: "Alice",
      email: "alice@example.com"
    )
  end

  fixture :logged_in_client do |client:, user:|
    client.(user)
    client
  end
end

use_fixture WebFixture

test("GET /me") do |logged_in_client:|
  response = logged_in_client.get("/me")

  expect(response.status).to eq(200)
end

Dependency graph:

logged_in_client
  ├── client
  │   └── server
  └── user

Execution flow:

server setup
client setup
user setup
logged_in_client setup
test body
server cleanup

Registering fixture classes

Use use_fixture:

use_fixture AppFixture

Multiple fixture classes can be registered:

use_fixture UserFixture
use_fixture WebFixture
use_fixture ApiFixture

Fixture names must be unique across registered fixture classes.

If two fixture classes define the same fixture name, Smartest raises an error.

Hooks

Smartest may support simple hooks:

before do
  DatabaseCleaner.start
end

after do
  DatabaseCleaner.clean
end

Hooks are separate from fixture cleanup.

Use fixture cleanup for resource-specific teardown:

fixture :server do
  server = TestServer.start
  cleanup { server.stop }
  server
end

Use hooks for broad test-level behavior:

before do
  reset_global_state
end
smartest/
  test_helper.rb
  fixtures/
    app_fixture.rb
    web_fixture.rb
  example_test.rb
# smartest/test_helper.rb
require "smartest/autorun"

Dir[File.join(__dir__, "fixtures", "**", "*.rb")].sort.each do |fixture_file|
  require fixture_file
end

The generated helper loads Ruby files under smartest/fixtures/ in sorted order. Test files still register the fixture classes they need with use_fixture.

Example:

# smartest/fixtures/web_fixture.rb
class WebFixture < Smartest::Fixture
  fixture :server do
    server = TestServer.start
    cleanup { server.stop }
    server
  end

  fixture :client do |server:|
    Client.new(base_url: server.url)
  end
end
# smartest/example_test.rb
require "test_helper"

use_fixture WebFixture

test("GET /health") do |client:|
  expect(client.get("/health").status).to eq(200)
end

Design choices

Smartest intentionally does not use this style as the primary API:

test("GET /me") do |logged_in_client|
end

Instead, Smartest prefers:

test("GET /me") do |logged_in_client:|
end

Keyword arguments make fixture injection explicit.

Smartest also avoids this fixture dependency style:

fixture :client, with: [:server] do |server|
  Client.new(base_url: server.url)
end

Instead, it prefers:

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

The dependency declaration and usage are in one place.

Status

Smartest is currently a design-stage test runner.

The intended MVP includes:

  • top-level test
  • class-based fixtures
  • keyword-argument fixture injection
  • fixture dependencies through keyword arguments
  • fixture cleanup
  • expect(...).to eq(...)
  • console reporter
  • CLI runner
  • circular fixture dependency detection
  • duplicate fixture detection