DevTools

Cheatsheet Ruby on Rails

Framework web Ruby full-stack com convenção sobre configuração

Voltar às linguagens
Ruby on Rails
75 cards encontrados
Categorias:
Versões:

Setup e CLI


9 cards
Instalar Rails
gem install rails
rails new minha_app
cd minha_app
rails server

gem install rails instala o framework globalmente. rails new gera a estrutura completa do projecto. rails server inicia o Puma em localhost:3000.

Comandos CLI essenciais
rails console        # IRB com o app carregado
rails db:migrate     # aplicar migrations
rails db:seed        # popular BD
rails routes         # listar todas as rotas
rails dbconsole      # aceder ao SQL directo

A rails console carrega todo o ambiente (models, configs). rails routes mostra verbos, paths e controllers mapeados. dbconsole abre o cliente SQL nativo.

Configurações
# config/application.rb
config.time_zone = "Lisbon"
config.i18n.default_locale = :pt

# config/credentials.yml.enc
rails credentials:edit
Rails.application.credentials.secret_api_key

config/application.rb define settings globais. credentials guarda segredos encriptados (API keys, passwords) — mais seguro que variáveis de ambiente em ficheiro.

Gerar model
rails g model Post titulo:string texto:text
rails g model User nome:string email:string

# Gera:
#   app/models/post.rb
#   db/migrate/xxxx_create_posts.rb

rails g model cria o ficheiro do model e a migration automaticamente. Os tipos (string, text, integer) definem as colunas da tabela.

Estrutura do projecto
app/
  models/          # ActiveRecord
  controllers/     # lógica HTTP
  views/           # templates ERB
  helpers/         # view helpers
config/routes.rb   # rotas
db/migrate/        # migrations
Gemfile            # dependências

Rails segue o padrão MVC: models/ (dados), controllers/ (lógica), views/ (apresentação). config/routes.rb mapeia URLs para controllers.

Gerar controller
rails g controller Posts index show new

# Gera:
#   app/controllers/posts_controller.rb
#   app/views/posts/ (index, show, new)
#   rotas em config/routes.rb

rails g controller cria o controller, as views e adiciona as rotas. As actions listadas geram métodos vazios e templates correspondentes.

Gemfile
# Gemfile
gem "rails", "~> 7.1"
gem "sqlite3"
gem "puma"

group :development do
  gem "debug"
end

bundle install
bundle add devise

O Gemfile lista as dependências. bundle install instala tudo. group :development limita gems a ambientes específicos. bundle add adiciona e instala numa só etapa.

Scaffold
rails g scaffold Post titulo:string texto:text

# Gera tudo: model, controller,
# views, migration, rotas, testes
rails db:migrate

scaffold gera o CRUD completo de uma vez: model, controller com 7 actions, views, migration e rotas. Ideal para prototipagem rápida.

Ambientes
# config/environments/
#   development.rb
#   test.rb
#   production.rb

RAILS_ENV=production rails server
Rails.env                # => "development"
Rails.env.production?    # => false

Rails tem 3 ambientes: development, test e production. Cada um tem configs próprias em config/environments/. Rails.env retorna o ambiente activo.

Models e ActiveRecord


9 cards
Definir model
class Post < ApplicationRecord
  validates :titulo, presence: true
  belongs_to :autor
  has_many :comentarios
end

Todo model herda de ApplicationRecord. O nome da classe é singular (Post) e a tabela é plural (posts). validates e associações são declarados no corpo da classe.

Associações
has_many :comentarios
belongs_to :post
has_one :perfil
has_many :tags, through: :taggings
has_and_belongs_to_many :categorias

has_many / belongs_to são as mais comuns. through cria associação via tabela intermédia. has_and_belongs_to_many usa join table sem model próprio.

Eager loading
# N+1 problem:
Post.all.each { |p| p.autor.nome }

# Solução:
Post.includes(:autor).each { |p|
  p.autor.nome }

Post.joins(:autor).where(
  autors: { ativo: true })

includes carrega associações em 2 queries (evita N+1). joins faz INNER JOIN — necessário para filtrar por campos da associação. preload força 2 queries separadas.

Validações
validates :email, presence: true,
  uniqueness: true,
  format: { with: URI::MailTo::EMAIL_REGEXP }

validates :idade, numericality: {
  greater_than: 0, only_integer: true }

validates :titulo, length: { maximum: 200 }

validates aceita múltiplas regras: presence, uniqueness, format, numericality, length. Se falhar, save retorna false e errors é populado.

Scopes
scope :ativos, -> { where(ativo: true) }
scope :recentes, -> {
  order(criado_em: :desc).limit(5) }

Post.ativos.recentes

scope define queries nomeadas reutilizáveis com lambda. Podem ser encadeadas como métodos normais. Equivalentes a métodos de classe que retornam ActiveRecord::Relation.

CRUD básico
Post.create(titulo: "Olá", texto: "Mundo")
Post.find(1)
Post.find_by(titulo: "Olá")
post.update(titulo: "Novo título")
post.destroy

create instancia e guarda. find lança RecordNotFound se não existir; find_by retorna nil. update valida e guarda. destroy remove o registo.

Callbacks
class Post < ApplicationRecord
  before_save :normalizar_titulo
  after_create :notificar

  private

  def normalizar_titulo
    self.titulo = titulo.strip.downcase
  end
end

Callbacks executam código em pontos do ciclo de vida: before_save, after_create, before_destroy. Úteis para normalização, logs e notificações automáticas.

Queries e condições
Post.where(ativo: true)
Post.where("views > ?", 100)
Post.order(criado_em: :desc)
Post.limit(10).offset(20)
Post.count
Post.pluck(:titulo)

where aceita hash ou SQL com placeholders ?. As queries são encadeáveis (lazy). pluck retorna apenas os valores de uma coluna. count faz SELECT COUNT(*).

Enum
class Post < ApplicationRecord
  enum :status, { rascunho: 0,
    publicado: 1, arquivado: 2 }
end

post.publicado!
post.status        # => "publicado"
Post.publicado     # => todos publicados

enum mapeia inteiros para nomes legíveis. Gera métodos de query (Post.publicado) e de transição (post.publicado!). A coluna deve ser integer na BD.

Controllers


9 cards
Actions RESTful
class PostsController < ApplicationController
  def index
    @posts = Post.all
  end

  def show
    @post = Post.find(params[:id])
  end
end

As 7 actions RESTful: index, show, new, create, edit, update, destroy. Variáveis com @ ficam disponíveis na view.

Flash e redirect
flash[:notice] = "Post criado!"
flash[:alert] = "Erro ao guardar."
redirect_to posts_path

redirect_to @post, notice: "OK"
redirect_to root_path, alert: "Negado"

flash guarda mensagens para o próximo request. notice (sucesso) e alert (erro) são atalhos inline no redirect_to. A mensagem desaparece após ser exibida.

Skip e herança
class ApplicationController < ActionController::Base
  before_action :autenticar!
end

class PublicController < ApplicationController
  skip_before_action :autenticar!
end

ApplicationController é o pai de todos — filtros definidos aqui aplicam-se globalmente. skip_before_action remove um filtro herdado em controllers específicos.

Strong parameters
def create
  @post = Post.new(post_params)
  if @post.save
    redirect_to @post, notice: "Criado!"
  else
    render :new, status: :unprocessable_entity
  end
end

private

def post_params
  params.require(:post).permit(:titulo, :texto)
end

params.require(:post) exige a chave. permit lista os campos aceites — protege contra mass assignment. Sem isto, ActiveModel::ForbiddenAttributesError é lançado.

Render vs Redirect
# Render: mesma request, sem redirect
render :new
render partial: "form"
render plain: "OK"

# Redirect: nova request HTTP
redirect_to posts_path

render mostra uma view sem mudar o URL (mesmo request). redirect_to envia HTTP 302 — o browser faz novo GET. Use render em erros de validação para manter dados do form.

Before actions
before_action :set_post, only: [:show,
  :edit, :update, :destroy]
before_action :autenticar!

private

def set_post
  @post = Post.find(params[:id])
end

before_action executa antes das actions. only / except limitam onde corre. Ideal para carregar recursos e verificar autenticação sem repetir código.

Rescue e erros
rescue_from ActiveRecord::RecordNotFound,
  with: :nao_encontrado

private

def nao_encontrado
  render file: "public/404.html",
    status: :not_found
end

rescue_from captura excepções globalmente no controller. Evita repetir begin/rescue. Pode renderizar páginas de erro ou redireccionar com mensagens.

Respond_to
def show
  @post = Post.find(params[:id])
  respond_to do |format|
    format.html
    format.json { render json: @post }
    format.xml  { render xml: @post }
  end
end

respond_to permite responder em múltiplos formatos conforme o header Accept. O bloco define a resposta para cada formato. A extensão da URL (.json) também activa.

Cookies e session
session[:user_id] = user.id
session[:user_id]      # => 42
session.delete(:user_id)

cookies[:tema] = "escuro"
cookies.permanent[:lembrar] = token

session guarda dados temporários (limpa ao fechar browser). cookies persiste no cliente. cookies.permanent expira em 20 anos. Ambos acessíveis via hash.

Routes


8 cards
Resources
resources :posts
# Gera 7 rotas: index, show, new,
# create, edit, update, destroy

resources :posts, only: [:index, :show]
resources :posts, except: [:destroy]

resources gera as 7 rotas RESTful automaticamente. only / except limitam quais são criadas. Cada rota tem um helper nomeado (posts_path, post_path).

Namespace
namespace :admin do
  resources :users
  resources :posts
end

# /admin/users
# Admin::UsersController

namespace agrupa rotas sob um prefixo e módulo. Cria controllers em app/controllers/admin/. Os helpers ganham prefixo: admin_users_path.

Rotas personalizadas
get "sobre", to: "pages#sobre"
post "login", to: "sessions#create"
root "posts#index"

get "posts/:id/preview",
  to: "posts#preview"

get, post, put, delete definem rotas manuais. root define a página inicial. :id é um parâmetro dinâmico acessível via params[:id].

Scope e shallow
scope "/api", module: "api" do
  resources :posts
end

resources :posts, shallow: true do
  resources :comentarios
end
# /comentarios/5 (sem /posts/1)

scope personaliza prefixo/módulo sem criar namespace completo. shallow: true aninha apenas index/new/create — as restantes ficam no nível raiz.

Nested resources
resources :posts do
  resources :comentarios
end

# /posts/1/comentarios
# /posts/1/comentarios/5

Recursos aninhados geram URLs hierárquicos. O controller recebe params[:post_id]. Limite a 1 nível de nesting para manter URLs legíveis.

Member e collection
resources :posts do
  member do
    get :preview
  end
  collection do
    get :arquivados
  end
end
# /posts/1/preview
# /posts/arquivados

member adiciona rotas para um recurso específico (com :id). collection adiciona rotas para a colecção (sem :id). Geram helpers automáticos.

URL helpers
posts_path          # /posts
post_path(@post)    # /posts/1
new_post_path       # /posts/new
edit_post_path(@post) # /posts/1/edit

posts_url           # http://host/posts

Cada rota gera helpers: _path (relativo) e _url (absoluto). Use _path em views e _url em emails e redirects externos.

Constraints
constraints subdomain: "api" do
  resources :posts
end

get "posts/:id", to: "posts#show",
  constraints: { id: /\d+/ }

constraints restringe rotas por subdomínio, formato ou regex. Útil para APIs em subdomínios ou para validar parâmetros directamente na rota.

Views e ERB


9 cards
ERB básico
<h1><%= @post.titulo %></h1>
<p><%= @post.texto %></p>

<%# comentário (não renderiza) %>
<% if @post.ativo? %>
  <span>Publicado</span>
<% end %>

<%= %> imprime o resultado (com escape HTML). <% %> executa sem imprimir. <%# %> é comentário. Variáveis @ do controller ficam disponíveis.

Layouts e yield
<!-- layouts/application.html.erb -->
<body>
  <%= yield %>
  <%= yield :sidebar %>
</body>

<!-- Na view: -->
<% content_for :sidebar do %>
  <nav>Menu lateral</nav>
<% end %>

yield insere o conteúdo da view no layout. yield :sidebar insere conteúdo nomeado. content_for define blocos para slots específicos do layout.

Turbo e Stimulus
<%= turbo_frame_tag "posts" do %>
  <%= render @posts %>
<% end %>

<%= link_to "Próxima", posts_path,
  data: { turbo_frame: "posts" } %>

Turbo Frames actualizam partes da página sem reload completo. turbo_frame_tag define uma região actualizável. Links com data-turbo-frame carregam dentro do frame.

Iteração
<% @posts.each do |post| %>
  <li><%= link_to post.titulo, post %></li>
<% end %>

<%= render @posts %>

each itera sobre colecções. render @posts renderiza automaticamente o partial _post.html.erb para cada item — mais limpo que loop manual.

Asset helpers
<%= image_tag "logo.png", alt: "Logo" %>
<%= stylesheet_link_tag "app" %>
<%= javascript_include_tag "app" %>
<%= link_to "Ver post", post_path(@post) %>

image_tag, stylesheet_link_tag e javascript_include_tag geram tags com paths do asset pipeline. link_to cria links com helpers de rota.

Partials
<%= render "form" %>
<%= render "posts/card", post: @post %>

<!-- app/views/posts/_card.html.erb -->
<div class="card">
  <%= post.titulo %>
</div>

Partials são ficheiros com prefixo _. render "form" procura _form.html.erb na mesma pasta. Passe variáveis locais como segundo argumento (hash).

Number e date helpers
<%= number_to_currency(19.99) %>
<%= number_with_delimiter(1000000) %>
<%= time_ago_in_words(@post.created_at) %>
<%= l(@post.created_at, format: :long) %>

number_to_currency formata valores monetários. time_ago_in_words retorna "há 3 dias". l() formata datas segundo o locale activo.

Form helpers
<%= form_with model: @post do |f| %>
  <%= f.label :titulo %>
  <%= f.text_field :titulo %>
  <%= f.text_area :texto, rows: 5 %>
  <%= f.select :status, ["rascunho", "publicado"] %>
  <%= f.submit "Guardar" %>
<% end %>

form_with model: gera form para create ou update automaticamente. f.text_field, f.text_area, f.select criam inputs com nomes correctos. O CSRF token é incluído automaticamente.

Content tags e safe
<%= content_tag :div, "Olá", class: "msg" %>
<%= tag.br %>
<%= tag.input type: "text", name: "q" %>

<%= raw @html %>
<%= sanitize @html, tags: %w[b i em] %>

content_tag gera elementos HTML dinamicamente. tag.br cria tags self-closing. raw imprime sem escape (cuidado!). sanitize remove tags perigosas mantendo seguras.

Migrations


8 cards
Criar migration
rails g migration CreatePosts \
  titulo:string texto:text ativo:boolean

rails g migration AddViewsToPosts \
  views:integer

rails g migration gera ficheiro com timestamp. Nomes CreateX geram create_table. Nomes AddXToY geram add_column. O timestamp define a ordem de execução.

Change reversível
def change
  create_table :posts do |t|
    t.string :titulo, null: false
    t.text :texto
    t.timestamps
  end
end

def change é auto-reversível — Rails sabe inverter create_table, add_column, etc. Use def up / def down apenas para operações irreversíveis.

Tipos de coluna
t.string   :titulo
t.text     :corpo
t.integer  :views
t.float    :rating
t.decimal  :preco, precision: 8, scale: 2
t.boolean  :ativo, default: false
t.date     :publicado_em
t.datetime :created_at
t.references :autor, foreign_key: true

Tipos mapeiam para SQL: string (varchar), text (sem limite), decimal (precisão exacta). references cria coluna autor_id com foreign key.

Timestamps e defaults
t.timestamps
# Cria: created_at + updated_at

t.boolean :ativo, default: true
t.string :status, default: "rascunho"
t.integer :views, default: 0

t.timestamps cria created_at e updated_at (actualizado automaticamente). default define valor inicial da coluna. Rails gere timestamps sem código extra.

Alterar esquema
add_column :posts, :views, :integer
remove_column :posts, :views
rename_column :posts, :old, :new
add_index :posts, :titulo
change_column_null :posts, :titulo, false

add_column / remove_column adicionam/removem colunas. add_index melhora performance de queries. change_column_null torna coluna obrigatória (NOT NULL).

Índices e constraints
add_index :posts, :titulo, unique: true
add_index :posts, [:autor_id, :created_at]

add_foreign_key :posts, :autors
add_check_constraint :posts,
  "views >= 0", name: "views_positivos"

add_index com unique: true impede duplicados. Índices compostos (array) optimizam queries com múltiplas colunas. add_check_constraint valida a nível de BD.

Comandos de gestão
rails db:migrate
rails db:rollback STEP=1
rails db:migrate:status
rails db:reset
rails db:schema:load

db:migrate aplica pendentes. db:rollback reverte a última (ou N com STEP). migrate:status mostra quais foram aplicadas. db:reset faz drop + create + migrate.

Seed de dados
# db/seeds.rb
Post.create!(titulo: "Primeiro",
  texto: "Conteúdo inicial")

5.times do |i|
  Post.create!(titulo: "Post #{i}")
end

# Executar:
rails db:seed

db/seeds.rb popula a BD com dados iniciais. create! (com bang) lança excepção se validação falhar. Execute com rails db:seed — seguro para idempotência.

Auth e Segurança


8 cards
has_secure_password
gem "bcrypt"

class User < ApplicationRecord
  has_secure_password
end

user.authenticate("senha123")

has_secure_password adiciona hash bcrypt e método authenticate. Requer coluna password_digest. Retorna o user se a senha bater, false caso contrário.

Pundit (autorização)
gem "pundit"

class PostPolicy < ApplicationPolicy
  def update?
    user.admin? || record.autor == user
  end
end

authorize @post

Pundit faz autorização com policies. Cada model tem uma classe Policy com métodos booleanos. authorize no controller verifica permissão — lança NotAuthorizedError se negado.

Devise
gem "devise"
rails g devise:install
rails g devise User
rails db:migrate

# Helpers disponíveis:
current_user
user_signed_in?
authenticate_user!

Devise é a gem de auth mais popular. Gera registo, login, recuperação de senha e confirmação de email. authenticate_user! como before_action protege rotas.

SQL injection
# SEGURO (placeholder):
Post.where("titulo LIKE ?", "%#{q}%")

# PERIGOSO (nunca fazer):
Post.where("titulo LIKE '%#{q}%'")

# SEGURO (hash):
Post.where(titulo: params[:titulo])

Use sempre placeholders ? ou hash em where. Interpolação directa permite SQL injection. O ActiveRecord faz escape automático com placeholders.

CSRF protection
# ApplicationController
protect_from_forgery with: :exception

# No layout (automático):
<%= csrf_meta_tags %>

# Em formulários: token incluído

Rails protege contra CSRF por defeito. csrf_meta_tags insere o token no HTML. Forms gerados com form_with incluem o token automaticamente. APIs usam skip_forgery_protection.

Headers de segurança
# config/application.rb
config.action_dispatch.default_headers = {
  "X-Frame-Options" => "DENY",
  "X-Content-Type-Options" => "nosniff",
  "X-XSS-Protection" => "1; mode=block"
}

Rails já inclui headers seguros por defeito. X-Frame-Options previne clickjacking. X-Content-Type-Options evita MIME sniffing. Personalize em default_headers.

XSS e sanitize
<%= @texto %>          # escapa HTML
<%= raw @html %>       # NÃO escapa
<%= sanitize @html,
  tags: %w[b i em p] %>

ERB escapa HTML automaticamente com <%= %>. raw desactiva o escape (perigoso). sanitize remove tags/scripts maliciosos mantendo uma whitelist segura.

Rate limiting
# config/initializers/rate_limit.rb
Rails.application.config.action_dispatch
  .rate_limit = {
    limit: 100,
    period: 1.minute
  }

# Ou manual com Rack::Attack

Rails 7.1+ tem rate limiting nativo. Limita requests por IP/período. Para controlo fino, use Rack::Attack (gem) com regras por endpoint, token ou IP.

API e Avançado


7 cards
API mode
rails new api_app --api

class PostsController < ApplicationController
  def index
    render json: Post.all
  end

  def show
    render json: Post.find(params[:id])
  end
end

--api cria app sem views, assets ou cookies. Controllers herdam de ActionController::API (mais leve). render json: serializa objectos automaticamente.

Caching
# Fragment caching na view:
<% cache @post do %>
  <%= render @post %>
<% end %>

# Low-level caching:
Rails.cache.fetch("populares", expires_in: 1.hour) do
  Post.order(views: :desc).limit(10)
end

cache na view guarda HTML renderizado (invalida ao actualizar). Rails.cache.fetch guarda qualquer dado com expiração. Use Redis como store em produção.

Serializers
gem "jsonapi-serializer"

class PostSerializer
  include JSONAPI::Serializer
  attributes :titulo, :texto
  belongs_to :autor
  attribute :url do |post|
    post_url(post)
  end
end

jsonapi-serializer formata respostas no padrão JSON:API. attributes lista campos. attribute com bloco cria campos computados. Mais rápido que to_json manual.

Action Cable (WebSockets)
rails g channel Notificacoes

class NotificacoesChannel < ApplicationCable::Channel
  def subscribed
    stream_from "notificacoes"
  end
end

ActionCable.server.broadcast(
  "notificacoes", { msg: "Olá" })

Action Cable integra WebSockets no Rails. stream_from subscreve um canal. broadcast envia mensagens a todos os subscritores. Ideal para notificações em tempo real.

Active Job
rails g job EnviarEmail

class EnviarEmailJob < ApplicationJob
  queue_as :default

  def perform(user)
    UserMailer.bemvindo(user).deliver_now
  end
end

EnviarEmailJob.perform_later(user)

Active Job é a interface para background jobs. perform_later enfileira (não bloqueia). queue_as define a fila. Use Sidekiq ou Solid Queue como backend.

Engines e concerns
# app/models/concerns/pesquisavel.rb
module Pesquisavel
  extend ActiveSupport::Concern

  included do
    scope :pesquisar, ->(q) {
      where("titulo LIKE ?", "%#{q}%") }
  end
end

class Post < ApplicationRecord
  include Pesquisavel
end

Concerns são módulos reutilizáveis (mixins). ActiveSupport::Concern permite hooks included. Mantêm models DRY. Engines são mini-apps empacotáveis (gems internas).

Action Mailer
rails g mailer UserMailer

def bemvindo(user)
  @user = user
  mail(to: user.email,
    subject: "Bem-vindo!")
end

# app/views/user_mailer/bemvindo.html.erb
UserMailer.bemvindo(user).deliver_later

Action Mailer envia emails com templates ERB. deliver_later envia via background job. Configure SMTP em config/environments/production.rb ou use ActionMailer::Base.delivery_method.

Testes e Deploy


8 cards
Minitest (unit)
class PostTest < ActiveSupport::TestCase
  test "titulo é obrigatório" do
    post = Post.new(titulo: nil)
    assert_not post.valid?
    assert_includes post.errors[:titulo],
      "can't be blank"
  end
end

Minitest vem com Rails. test "..." define um caso. assert_not verifica falsidade. assert_includes confirma presença em array. Execute com rails test.

System tests
class PostsSystemTest < ApplicationSystemTestCase
  test "criar post via browser" do
    visit new_post_path
    fill_in "Titulo", with: "Novo"
    click_button "Guardar"
    assert_text "Post criado!"
  end
end

System tests correm num browser real (Capybara). visit, fill_in, click_button simulam interacção do utilizador. Testam o fluxo completo (front + back).

Integration tests
class PostsFlowTest < ActionDispatch::IntegrationTest
  test "criar post" do
    post posts_path, params: {
      post: { titulo: "Novo" } }
    assert_response :redirect
    follow_redirect!
    assert_select "h1", "Novo"
  end
end

Testes de integração simulam requests HTTP completos. post posts_path envia request. assert_response verifica status. assert_select faz assert no HTML retornado.

Preparar produção
RAILS_ENV=production rails db:migrate
rails assets:precompile
rails secret

# config/environments/production.rb
config.force_ssl = true
config.log_level = :info

assets:precompile compila CSS/JS com fingerprint. force_ssl força HTTPS. rails secret gera secret_key_base. Logs em :info reduzem ruído.

Fixtures e factories
# test/fixtures/posts.yml
primeiro:
  titulo: "Olá"
  texto: "Mundo"

# Com FactoryBot (gem):
FactoryBot.define do
  factory :post do
    titulo { "Teste" }
  end
end

Fixtures são dados YAML estáticos. FactoryBot gera dados dinâmicos com create(:post). Factories são mais flexíveis e evitam acoplamento entre testes.

Deploy (Capistrano)
gem "capistrano-rails"

# config/deploy.rb
set :application, "minha_app"
set :repo_url, "git@github.com:user/app.git"
set :deploy_to, "/var/www/app"

cap production deploy

Capistrano automatiza deploy via SSH. Faz pull do git, bundle install, migrations e restart. Suporta rollback com cap production deploy:rollback.

RSpec (alternativa)
RSpec.describe Post do
  it "requer titulo" do
    post = Post.new(titulo: nil)
    expect(post).not_to be_valid
  end

  it "cria com titulo" do
    post = Post.create(titulo: "OK")
    expect(post).to be_persisted
  end
end

RSpec é a alternativa mais popular ao Minitest. Sintaxe describe/it/expect. Matchers como be_valid, eq, include tornam testes legíveis.

Logs e debugging
Rails.logger.info("Processando...")
Rails.logger.error("Falha: #{e.message}")

# No controller/view:
debugger  # pára execução (gem debug)
logger.debug params.inspect

Rails.logger escreve em log/development.log. debugger pára a execução para inspecção interactiva. params.inspect mostra todos os parâmetros recebidos.