# Testing con RSpec en Rails

Referencia de configuración y patrones de testing con RSpec, FactoryBot,
Capybara y SimpleCov para Rails 7+.

---

## Configuración base

```ruby
# spec/rails_helper.rb
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'
abort("Entorno no es test") if Rails.env.production?

require 'rspec/rails'
require 'capybara/rspec'
require 'factory_bot_rails'

RSpec.configure do |config|
  config.fixture_path        = Rails.root.join('spec/fixtures')
  config.use_transactional_fixtures = true
  config.infer_spec_type_from_file_location!
  config.filter_rails_from_backtrace!

  config.include FactoryBot::Syntax::Methods
  config.include Devise::Test::IntegrationHelpers, type: :request
end

# spec/spec_helper.rb
require 'simplecov'
SimpleCov.start 'rails' do
  add_filter '/spec/'
  enable_coverage :branch  # coverage de ramas, no solo líneas
  minimum_coverage 90
end

RSpec.configure do |config|
  config.expect_with(:rspec) { |c| c.syntax = :expect }
  config.order = :random
  config.warnings = true
end
```

---

## FactoryBot — factories, traits y sequences

```ruby
# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    sequence(:email) { |n| "usuario#{n}@ejemplo.com" }
    nombre { "Usuario de Prueba" }
    password { "contraseña_segura_123" }

    trait :admin do
      role { :admin }
    end

    trait :con_posts do
      after(:create) { |user| create_list(:post, 3, author: user) }
    end
  end
end

# spec/factories/posts.rb
FactoryBot.define do
  factory :post do
    sequence(:titulo) { |n| "Post de prueba #{n}" }
    cuerpo { "Contenido del post de prueba" }
    status { :publicado }
    association :author, factory: :user

    trait :borrador do
      status { :borrador }
    end

    trait :con_comentarios do
      after(:create) { |post| create_list(:comment, 2, post: post) }
    end
  end
end
```

**Regla**: preferir `build` sobre `create` cuando el test no requiere persistencia
— es significativamente más rápido.

```ruby
build(:post)            # objeto en memoria, sin BD
create(:post)           # persiste en BD
build_stubbed(:post)    # objeto stub, más rápido que build para asociaciones
create(:post, :borrador, :con_comentarios)  # traits combinados
```

---

## Tests de modelo

```ruby
# spec/models/post_spec.rb
RSpec.describe Post, type: :model do
  subject(:post) { build(:post) }

  describe 'validaciones' do
    it { is_expected.to validate_presence_of(:titulo) }
    it { is_expected.to validate_length_of(:titulo).is_at_most(200) }
    it { is_expected.to validate_presence_of(:cuerpo) }
  end

  describe 'asociaciones' do
    it { is_expected.to belong_to(:author).class_name('User') }
    it { is_expected.to have_many(:comments).dependent(:destroy) }
  end

  describe 'scopes' do
    describe '.publicados' do
      let!(:publicado) { create(:post, status: :publicado) }
      let!(:borrador)  { create(:post, status: :borrador) }

      it 'incluye solo posts publicados' do
        expect(Post.publicados).to include(publicado)
        expect(Post.publicados).not_to include(borrador)
      end
    end
  end
end
```

---

## Request specs para APIs (mejor que controller specs)

Los request specs prueban el stack completo de la request (middleware incluido).
Son la opción recomendada para APIs REST.

```ruby
# spec/requests/posts_spec.rb
RSpec.describe "Posts", type: :request do
  let(:user) { create(:user) }

  before { sign_in user }

  describe "GET /posts" do
    let!(:posts) { create_list(:post, 3, author: user) }

    it "devuelve lista de posts con HTTP 200" do
      get posts_path
      expect(response).to have_http_status(:ok)
      expect(response.body).to include(posts.first.titulo)
    end
  end

  describe "POST /posts" do
    context "con parámetros válidos" do
      let(:params) { { post: attributes_for(:post) } }

      it "crea el post y redirige" do
        expect { post posts_path, params: params }
          .to change(Post, :count).by(1)
        expect(response).to redirect_to(Post.last)
      end
    end

    context "con parámetros inválidos" do
      let(:params) { { post: { titulo: "" } } }

      it "no crea el post y responde 422" do
        expect { post posts_path, params: params }
          .not_to change(Post, :count)
        expect(response).to have_http_status(:unprocessable_entity)
      end
    end
  end
end
```

---

## System specs con Capybara + Selenium

Para probar flujos con JavaScript (Turbo, Stimulus).

```ruby
# spec/system/crear_post_spec.rb
RSpec.describe "Crear post", type: :system do
  let(:user) { create(:user) }

  before do
    driven_by :selenium, using: :headless_chrome
    sign_in user
  end

  it "el usuario puede crear un post y verlo en la lista" do
    visit new_post_path

    fill_in "Título", with: "Mi nuevo post"
    fill_in "Cuerpo", with: "Contenido detallado del post"
    click_button "Publicar"

    expect(page).to have_text("Publicación creada")
    expect(page).to have_text("Mi nuevo post")
  end

  it "muestra errores con parámetros inválidos" do
    visit new_post_path
    click_button "Publicar"

    expect(page).to have_text("Título no puede estar en blanco")
  end
end
```

---

## Shared examples y shared contexts

```ruby
# spec/support/shared_examples/authenticatable.rb
RSpec.shared_examples "requiere autenticación" do
  context "sin sesión iniciada" do
    before { sign_out :user }

    it "redirige al login" do
      subject
      expect(response).to redirect_to(new_user_session_path)
    end
  end
end

# Uso en spec
RSpec.describe "Posts", type: :request do
  describe "GET /posts/new" do
    subject { get new_post_path }
    it_behaves_like "requiere autenticación"
  end
end

# Shared context para setup reutilizable
RSpec.shared_context "usuario autenticado" do
  let(:user) { create(:user) }
  before { sign_in user }
end

RSpec.describe "Posts", type: :request do
  include_context "usuario autenticado"
end
```

---

## Mocking con instance_double y class_double

```ruby
# instance_double verifica que los métodos existen en la clase real
RSpec.describe Posts::CrearService do
  let(:mailer) { instance_double(NotificacionMailer) }

  before do
    allow(NotificacionMailer).to receive(:nueva_publicacion).and_return(mailer)
    allow(mailer).to receive(:deliver_later)
  end

  it "encola notificación al crear post exitosamente" do
    result = described_class.call(autor: create(:user), parametros: attributes_for(:post))

    expect(result.exitoso?).to be true
    expect(NotificacionMailer).to have_received(:nueva_publicacion)
  end
end

# class_double para stubs de métodos de clase
let(:job) { class_double(NotificarSeguidoresJob).as_stubbed_const }
before    { allow(job).to receive(:perform_later) }
```

---

## DatabaseCleaner

Necesario cuando se mezclan specs con `js: true` (que usan transacciones separadas).

```ruby
# spec/support/database_cleaner.rb
RSpec.configure do |config|
  config.before(:suite) do
    DatabaseCleaner.clean_with(:truncation)
  end

  config.before(:each) do
    DatabaseCleaner.strategy = :transaction
  end

  config.before(:each, js: true) do
    DatabaseCleaner.strategy = :truncation
  end

  config.before(:each) { DatabaseCleaner.start }
  config.after(:each)  { DatabaseCleaner.clean }
end
```

---

## SimpleCov con branch coverage

```ruby
# spec/spec_helper.rb (ya mostrado arriba, recordatorio)
SimpleCov.start 'rails' do
  add_filter '/spec/'
  add_filter '/config/'
  add_group  'Controllers',    'app/controllers'
  add_group  'Models',         'app/models'
  add_group  'Services',       'app/services'
  add_group  'Jobs',           'app/jobs'
  enable_coverage :branch
  minimum_coverage 90
end
```

Ejecutar y ver reporte:

```bash
bundle exec rspec
open coverage/index.html
```

---

## Convenciones Rubocop-RSpec

```ruby
# .rubocop.yml — sección RSpec
RSpec:
  Max: 250  # líneas por archivo spec

RSpec/ExampleLength:
  Max: 10   # líneas por ejemplo

RSpec/MultipleExpectations:
  Max: 3    # expects por ejemplo

RSpec/NestedGroups:
  Max: 4    # niveles de anidamiento describe/context
```

**Reglas de convención:**
- Un `describe` por clase pública, un `context` por escenario
- Nombrar contexts: `"cuando [condición]"`, `"con [estado]"`, `"sin [algo]"`
- Nombrar examples: `"[sujeto] [verbo] [resultado esperado]"`
- Preferir `let!` sobre `before { @var = ... }` para legibilidad

---

## Referencias externas

- [RSpec Rails — documentación](https://relishapp.com/rspec/rspec-rails/docs)
- [FactoryBot — guía](https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md)
- [Capybara — DSL](https://github.com/teamcapybara/capybara#the-dsl)
- [SimpleCov](https://github.com/simplecov-ruby/simplecov)
- [Rubocop-RSpec](https://docs.rubocop.org/rubocop-rspec/)
