Rails MVP: the twenty-eight lines the generators skip
The two commands that start a Rails MVP take about ten seconds, and the app they produce cannot pass its own test suite and has no way for a stranger to become a user. Neither of those is a secret and neither is a bug, but both are work, and both are cheaper to do on Saturday morning than in week three. This page is the work.
What a generated app contains, how much memory it uses and how fast it serves a page is
counted separately. Everything here starts from the point where
rails new mvp -d postgresql --skip-ci, bin/rails generate authentication and one
bin/rails generate scaffold have already run, on Rails 8.1.4 and Ruby 4.0.5, against PostgreSQL
17.7 on an Apple M2 Max.
The suite is red before you write a line
Three generators, no edits, then bin/rails test:
$ bin/rails generate scaffold Link title:string url:string clicks:integer
$ bin/rails db:migrate
$ bin/rails test
Running 19 tests in a single process (parallelization threshold is 50)
Run options: --seed 38647
# Running:
....E
Error:
SessionsControllerTest#test_create_with_valid_credentials:
NameError: undefined local variable or method 'root_url' for an instance of SessionsController
app/controllers/concerns/authentication.rb:38:in 'Authentication#after_authentication_url'
app/controllers/sessions_controller.rb:11:in 'SessionsController#create'
Finished in 1.027778s, 18.4865 runs/s, 53.5135 assertions/s.
19 runs, 55 assertions, 7 failures, 1 errors, 0 skips
Two separate causes, and the interesting thing about the first is that the generator wrote both
halves of it. Line 38 of the concern it writes is
session.delete(:return_to_after_authenticating) || root_url. Line 14 of the test it writes, in
test/controllers/sessions_controller_test.rb, is assert_redirected_to root_path. And
config/routes.rb, written by a different generator, ships # root "posts#index" commented out.
So bin/rails generate authentication produces a test that cannot pass in the application it just
generated. That the sign-in itself raises on the happy path is
covered elsewhere; what matters here is that the suite is carrying a
red mark from minute one, and a red suite you have learned to ignore is worth less than no suite.
The seven failures are the other half of include Authentication landing in ApplicationController.
Every controller generated afterwards inherits before_action :require_authentication, and the
scaffold generator does not know that, so all seven of its tests get a redirect where they expect a
200:
Failure:
LinksControllerTest#test_should_get_index [test/controllers/links_controller_test.rb:10]:
Expected response to be a <2XX: success>, but was a <302: Found> redirect to <http://www.example.com/session/new>
This is the failure that teaches people to delete generated tests, which is a bad habit to acquire
in week one, because the fix is one line and the generator already wrote it for you.
test/test_helpers/session_test_helper.rb defines sign_in_as(user) and sign_out, and
ActiveSupport.on_load(:action_dispatch_integration_test) includes the module into every
integration test. So:
class LinksControllerTest < ActionDispatch::IntegrationTest
setup { sign_in_as users(:one) }
and the seven go green:
$ bin/rails test test/controllers/links_controller_test.rb
7 runs, 11 assertions, 0 failures, 0 errors, 0 skips
users(:one) is a fixture the authentication generator wrote too, in test/fixtures/users.yml,
with password_digest computed once at the top of the file by
<% password_digest = BCrypt::Password.create("password") %> rather than per record. Two users,
one@example.com and two@example.com.
The cost of that setup line is real and worth naming: the scaffold tests now never exercise the
signed-out case, so the page that is supposed to be public is the one nobody notices is not. A
marketing page, a pricing page, a shared link. That is what allow_unauthenticated_access is for,
and it belongs in the controller with a test asserting the 200, not hidden behind a sign_in_as
that makes the red go away.
The twenty-eight lines
bin/rails generate authentication declares resource :session and
resources :passwords, param: :token and nothing else. There is no users#create, no
registrations controller, and no route that turns a visitor into a row in the users table. The
generator's USAGE file, at railties-8.1.4/lib/rails/generators/rails/authentication/USAGE, is
honest about it in one line:
Description:
Generates a basic authentication system with users, sessions, and password reset.
"Users" there means the model. AuthenticationGenerator#create_authentication_files makes ten
template calls and none of them is a registrations controller.
Here is the whole of what closes that. A controller:
class UsersController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create
def new
@user = User.new
end
def create
@user = User.new(params.expect(user: [ :email_address, :password, :password_confirmation ]))
if @user.save
start_new_session_for @user
redirect_to after_authentication_url, notice: "Welcome."
else
render :new, status: :unprocessable_entity
end
end
end
A form at app/views/users/new.html.erb, nine lines, nothing in it you have not written before.
resources :users, only: %i[ new create ] in the routes. And one line in the model:
validates :email_address, presence: true, uniqueness: true
Nineteen lines of controller, nine of ERB, two elsewhere. start_new_session_for and
after_authentication_url are private methods on the concern the generator already gave you, which
is why sign-up ends with the visitor signed in without a single line here about cookies. The
rate_limit macro is the same one SessionsController and PasswordsController already use.
The validates line is the one to argue about, and leaving it out is the wrong turn. The generated
migration is email_address:string!:uniq, which becomes
add_index :users, :email_address, unique: true, and app/models/user.rb carries
has_secure_password, has_many :sessions, dependent: :destroy, a normalizes call and no
validation at all. A
registration controller written against that model does not render an error on a duplicate address.
It raises:
ActiveRecord::RecordNotUnique: PG::UniqueViolation: ERROR: duplicate key value violates unique constraint "index_users_on_email_address"
which is a 500 on one of the first things a confused visitor does: signing up twice, because the
first attempt did not obviously work. The validation costs one extra query per valid?:
User Exists? (0.9ms) SELECT 1 AS one FROM "users" WHERE "users"."email_address" = 'probe@example.com' LIMIT 1 /*application='Mvp'*/
It still races under concurrency, and the unique index is what actually guarantees the constraint. You want both. The generator gives you one, and it is the one that produces a stack trace rather than a form error.
Three tests, run against all of the above:
$ bin/rails test test/integration/registration_test.rb
3 runs, 19 assertions, 0 failures, 0 errors, 0 skips
They assert that a sign-up creates one User and one Session and sets cookies[:session_id];
that New@Example.com is stored as new@example.com, because normalizes runs before the
uniqueness check rather than after it; that a mismatched confirmation renders 422 carrying
Password confirmation doesn't match Password, escaped, which is the form the assertion has to
match and the one that costs ten minutes the first time; and that DUP@example.com with
a trailing space is refused as a duplicate rather than raising. That last one is the assertion worth
keeping, because it is what fails the day somebody removes normalizes or the validates line.
The dead end: rails new exits 0 on a broken app
Four of the five apps generated for this page came out like this, and the reason took a while to find:
rails importmap:install
/Users/mehdifarsi/.rvm/rubies/ruby-4.0.5/lib/ruby/4.0.0/bundled_gems.rb:60:in 'Kernel.require': cannot load such file -- bootsnap/setup (LoadError)
from .../config/boot.rb:4:in '<top (required)>'
from bin/rails:3:in '<main>'
rails turbo:install stimulus:install
...same...
rails solid_cache:install solid_queue:install solid_cable:install
...same...
Three failed generator steps, and echo $? printed 0. The resulting app has no
config/importmap.rb, no app/javascript/, and none of db/queue_schema.rb,
db/cache_schema.rb or db/cable_schema.rb. You find out later, when Hotwire does not load or
db:prepare cannot build the queue database.
The cause is two ordinary lines. In railties-8.1.4/lib/rails/generators/actions.rb,
execute_command ends on
in_root { run("#{sudo}#{Shellwords.escape Gem.ruby} bin/#{executor} #{command}", config) }, and
the config it passes sets abort_on_failure: options[:abort_on_failure]. None of the three
installer calls passes that option, so it is nil and a non-zero child is ignored. And line 1 of
the config/boot.rb the generator has just written is
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__), with ||=. Dumping the
environment from inside the generator shows what that ||= is preserving:
BUNDLE_GEMFILE=/private/tmp/.../scratchpad/Gemfile
RUBYOPT=-r/Users/mehdifarsi/.rvm/rubies/ruby-4.0.5/lib/ruby/4.0.0/bundler/setup
These runs were from a directory sitting under another Ruby project. The rails binary had bundled
itself against that enclosing Gemfile and exported both variables to every child process. ||= kept
them. The new app's bin/rails therefore resolved against a Gemfile with no bootsnap in it and died
on line 4 of its own boot.rb. Run the identical command from a directory with no Gemfile anywhere
above it and the same rails new prints zero errors and produces all five: config/importmap.rb,
app/javascript/, and the three schema files.
So rails new inside an existing Ruby project produces a silently incomplete application, and this
is not an exotic place to be: a monorepo puts you there, and so does rails new ../mvp typed from
inside the app you already have open. Generate somewhere clean, or check for config/importmap.rb
before you do anything else.
The first half hour
Before the first feature: uncomment root, add sign_in_as to the generated controller tests,
write the twenty-eight lines with the validates line in them, and get bin/rails test back to
zero. On the app this page was written against that is:
$ bin/rails test
22 runs, 78 assertions, 0 failures, 0 errors, 0 skips
Half an hour, all of it mechanical, and each piece is a thing that looks finished and is not.
The position, and what would change it: bin/rails generate authentication should write a
UsersController and uncomment root, or it should stop shipping a test that asserts a route it
does not create. The argument against generating registration is that registration is where products
differ most, and a generated one would be wrong for invite-only apps, for team sign-up, for anything
with a waiting list. That argument is real, and it is not what happens in practice: what happens in
practice is that every Rails 8 app grows the same nineteen lines. A --registration flag in the
style of the existing --api flag would settle it, and so would writing root commented-in against
the scaffold you are about to generate anyway. Either one and the complaint goes away.
What this page does not cover
The inventory. What rails new installs, how many gems, how much memory five processes hold and
what one Puma worker serves per second is its own count, along with
the password reset that fails silently in production and the routes resources :passwords declares
for actions that do not exist.
Whether to use the generator at all. The case for Devise against the Rails 8 generator is a different post, and nothing here is an argument against Devise.
Email confirmation, which is a separate hole from registration: the twenty-eight lines above sign the visitor straight in without verifying that the address belongs to them, which is the right default for a product with no free tier to abuse and the wrong one the moment there is.
Billing, which is not a generator gap. There is no payment gem in a generated app and there was never going to be one.
Comments
No comments yet. Be the first.