Neste curso, vamos baixar um projeto legado com rails 5.2.x e Ruby 2.7.x e atualizá-lo para uma versão mais atual como 7.x. Ao longo deste curso, abordaremos desde a instalação das tecnologias necessárias até testes desenvolvidos garantindo a qualidade do projeto.
- Ruby on Rails: Framework web MVC para desenvolvimento rápido de aplicações web em Ruby.
- Banco de Dados PostgreSQL: Banco de dados completo e integração com o Rails para armazenamento dos dados da aplicação.
- Bootstrap: Framework de estilos CSS
- JQuery: Framework de javascript jQuery
- Instalação e utilização do RVM para controle de versões do Ruby e gerenciamento de ambientes.
- Cobertura completa de testes utilizando MiniTest para garantir a qualidade do código.
Com o RVM instalado, devamos baixar e inicializar o projeto Rails:
git clone https://github.com/seu-usuario/receitas-app.git
Variáveis de ambiente com .env
:
Configurando as variáveis de ambiente. Precisamos criar o arquivo .env
baseado no arquivo .env.example
com o conteúdo:
APPLICATION_NAME=receita_mais
DATABASE_HOST=localhost
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=postgres
RAILS_MAX_THREADS=5
PRODUCTION_DATABASE_URL=
No arquivo .env
a constante PRODUCTION_DATABASE_URL
está em branco, preencha com o valor correto, por exemplo se usar heroku ou aws existe a URL do banco de dados. Este arquivo deve ser inserido ao final do arquivo .gitignore
Instale as dependências do Projeto e Iniciando:
gem install bundler -v 2.4.22 --no-doc
bundle install
rails db:drop db:create db:migrate db:seed
rails s
Configuraremos as bibliotecas necessárias para o desenvolvimento da aplicação, como simplecov e outras. Abaixo, esta o conteudo que sera acrescentado ao arquivo Gemfile
# Gemfile
group :development, :test do
# Ferramenta para debugar
gem 'byebug', platform: :mri
# Carrega variáveis de ambiente a partir de um arquivo .env
gem 'dotenv-rails'
# Analisa a cobertura de código dos testes
gem 'simplecov'
# Ferramenta de análise estática de código Ruby
gem 'rubocop', require: false
# Extensão do RuboCop para Rails
gem 'rubocop-rails', require: false
end
group :development do
# Anota os modelos com informações do schema do banco de dados
gem 'annotate'
# Ferramenta de segurança para Rails
gem 'brakeman'
# Ajuda a detectar queries N+1 em ActiveRecord
gem 'bullet'
# formata automaticamente código Ruby, mantendo consistência no estilo
gem 'rufo'
# Access an IRB console on exception pages or by using <%= console %> anywhere in the code.
gem 'listen', '~> 3.0.5'
gem 'web-console'
# Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
gem 'spring'
gem 'spring-watcher-listen', '~> 2.0.0'
end
Agora instale com:
rm Gemfile.lock
cd .
bundle install
Análise de código com qualidade usando rubocop:
E acrescentamos o arquivo .rubocop.yml
na raiz do projeto, mas atenção, configure com o caminho que o rvm estiver em sua máquina:
require: rubocop-rails
Metrics/BlockLength:
Max: 200
Enabled: false
Exclude:
- 'Rakefile'
- '**/*.rake'
- 'test/**/*.rb'
O Rails para o projeto legado está na versão 5.2.x e ruby 2.7.x. A intenção é atualizar para Rails 7.1.x e ruby 3.2.1. Outros pontos:
- Atualizar bibliotecas legadas
- Atualizar código legado
- Melhorar a segurança
- Implementar testes
Então primeiramente atualize os arquivos:
.ruby-version
:
ruby-3.2.1
.ruby-genset
:
receita_mais
.rvmrc
:
rvm use 3.2.1@receita_mais --create
Então vamos atualizar a versão do Rails atualizendo o arquivo Gemfile
para a versão mais atual, e colocar o que é necessário, basicamente o início do arquivo ficará assim:
# Gemfile
gem 'rails', '~> 7.1.3'
gem 'pg'
gem 'puma', '>= 5.0'
gem 'jquery-rails'
gem 'sprockets-rails'
gem 'importmap-rails'
gem 'turbo-rails'
gem 'stimulus-rails'
Note que algumas gems foram removidas:
coffee-rails
sass-rails
uglifier
turbolinks
jbuilder
Em seguida os comandos (quando perguntar se deseja prosseguir, informe que sim):
cd .
rm Gemfile.lock
gem install bundler
bundle install
rails app:update
rails db:migrate
Nossa! Muitos arquivos pedindo para serem atualizados, é por isso que usamos o git, pra entender o que aconteceu de novidade.
Para maiores informações do que vai ocorrer veja em https://edgeguides.rubyonrails.org/upgrading_ruby_on_rails.html
Começando com importmap-rails
que permite importar módulos JavaScript usando nomes lógicos que mapeiam para versões. Rode o comando:
rails importmap:install
Agora turbo-rails
que oferece a velocidade de um aplicativo da web de página única sem a necessidade de escrever nenhum JavaScript, também oferencendo usando partials
que são entregues de forma assíncrona por meio de uma conexão de web socket. Instale com os comandos:
rails turbo:install
rails turbo:install:redis
Em modo desenvolvimento o adaptador Async não permite Turbo Stream broadcasting por isto a gem redis
foi instalada automaticamente. O serviço então deve existir em sua máquina, sugiro usar docker para isto. Na pasta lib/dockers
existem alguns exemplos de composes e um deles é redis, facilmente subiria o serviço em sua máquina.
Agora com stimulus-rails
é uma estrutura JavaScript com ambições modestas. Instalando com:
rails stimulus:install
Migrar da gem paperclip
que não tem mais suporte para o nativo que é active_storage
. Primeiro geramos a migration e adicionamos a gem que ajuda no processamento de variação de tamanhos das imagens:
rails active_storage:install
rails db:migrate
bundle add image_processing
bundle install
Note que já existiam algumas migrations que tentam resolver isto, mas sem gem active_storage
.
A idéia então é:
- Sair da gem
paperclip
e utilizar oactive_storage
- Eliminar o model
RecipeImage
e a associação dentro deRecipe
ligando a ele - Colocar validações próprias dentro do model
Recipe
sobre as imagens geradas - No model
Recipe
utilizaractive_store
Sair da gem paperclip
e ir para a gem active_storage
Comece colocando no model Recipe
:
has_many_attached :images
validate :validates_images_types
private
def validates_images_types
return if images.blank?
images.each do |image|
errors.add(:images, 'must be an image file') unless image.content_type.starts_with?('image/')
end
end
Agora crie a rake para sair dos dados que estão no paperclip
para active_storage
. Crie o arquivo lib/tasks/images.rake
com o conteúdo:
# frozen_string_literal: true
namespace :images do
desc 'Migrate images from RecipeImage to Recipe'
task migrate_recipe_images: :environment do
RecipeImage.find_each(batch_size: 100) do |recipe_image|
recipe = recipe_image.recipe
recipe.images.attach(io: File.open(recipe_image.image.path), filename: recipe_image.image_file_name)
end
end
end
Rodamos a migração dos dados com:
bundle exec rake images:migrate_recipe_images
Após rodar migração de imagens para active_storage
é necessário remover como elas são chamadas nos models:
app/models/recipe.rb
Remover a linha:
has_many :recipe_images, dependent: :destroy
Também é necessário modificar nas views como os arquivos de imagens são chamados. Com a gem image_processing
que foi instalada, a forma que se processa o tamanho de imagem é em própria execução e utiliza cache.
Basicamente as views que sofrerão alterações são:
- app/views/home/_recipes.html.erb
- app/views/recipes/_form.html.erb
- app/views/recipes/show.html.erb
Todas basicamente mudando de recipe_images
para images
, atenção que deve ser acrescentada a variação de tamanho desejada (.variant...), um exemplo abaixo no arquivo:
- app/views/home/_recipes.html.erb
<% unless recipe.images.attached? %>
Sem imagens
<% else %>
<%= image_tag(recipe.images.first.variant(resize: "400x400").processed, class: "img-fluid", alt: "Imagem da Receita") %>
<% end %>
- app/views/recipes/_form.html.erb
<% unless recipe.images.blank? %>
<h4 class="mt-4 mb-4">Imagens da Receita</h4>
<div class="row">
<% recipe.images.each_slice(4) do |image_group| %>
<% image_group.each do |recipe_image| %>
<div class="col-md-3 mb-3">
<div class="card">
<%= image_tag image.variant(resize: "150x150").processed, class: "card-img-top img-thumbnail", style: "max-height: 150px; object-fit: cover;" %>
<div class="card-body">
<%= button_to 'Excluir', delete_image_recipe_path(id: recipe.id, record_id: image.record_id), method: :delete, class: 'btn btn-sm btn-danger btn-block', onclick: "return confirm('Tem certeza que deseja prosseguir?')" %>
</div>
</div>
</div>
<% end %>
<% (4 - image_group.size).times do %>
<div class="col-md-3"></div>
<% end %>
<% end %>
</div>
<% end %>
- app/views/recipes/show.html.erb
<% unless @recipe.images.blank? %>
<h4 class="mt-4 mb-4">Imagens da Receita</h4>
<div class="row">
<% @recipe.images.each_slice(4) do |image_group| %>
<% image_group.each do |image| %>
<div class="col-md-3 mb-3">
<div class="card">
<%= image_tag image.variant(resize: "150x150").processed, class: "card-img-top img-thumbnail", style: "max-height: 150px; object-fit: cover;" %>
</div>
</div>
<% end %>
<% (4 - image_group.size).times do %>
<div class="col-md-3"></div>
<% end %>
<% end %>
</div>
<% end %>
Removendo turbo_links
:
Como a gem foi removida, é necessário remover em alguns lugares:
- app/assets/javascripts/application.js
- app/views/layouts/application.html.erb
Em app/assets/javascripts/application.js
remova a linha:
//= require turbolinks
Em app/views/layouts/application.html.erb
modifique os conteúdos:
<%= stylesheet_link_tag 'application', media: 'all' %>
<%= javascript_include_tag 'application' %>
Removendo arquivos que não utilizaremos:
É preciso remover arquivos javascript, controllers, model, scss e coffee_script, então rode:
rm app/controllers/recipe_images_controller.rb
rm app/models/recipe_image.rb
rm app/assets/javascripts/cable.js
rm app/assets/stylesheets/*.scss
rm app/assets/javascripts/*.coffee
Corrigindo arquivos javascript:
No arquivo app/assets/config/manifest.js
o conteúdo deve ser:
//= link_tree ../images
//= link_directory ../stylesheets .css
//= link_tree ../../javascript .js
//= link_tree ../../../vendor/javascript .js
E no arquivo app/assets/javascripts/application.js
ficará somente com:
//= require jquery
//= require jquery_ujs
//= require_tree .
Corrigindo controllers:
Primeiramente no controller de app/controllers/recipes_controller.rb
é necessário:
- modificar os métodos
create
eupdate
com o código necessário - criar o método que irá remover
images
- remover e adicionar rotas corretas
- modificar para aceitar o parámetro
images[]
- corrigir views para salvar e excluir
images
def create
@recipe = current_user.recipes.build(recipe_params)
if @recipe.save
redirect_to recipe_url(@recipe), notice: 'Receita criada com sucesso.'
else
render :new, status: :unprocessable_entity
end
end
def update
redirect_to root_path if @recipe.user_id != current_user.id && return
if @recipe.update!(recipe_params)
redirect_to recipe_url(@recipe), notice: 'Receita atualizada com sucesso.'
else
render :edit, status: :unprocessable_entity
end
end
no começo do mesmo arquivo modifique a linha para e acrescente o método para remover a imagem:
before_action :set_recipe, only: %i[delete_image show edit update destroy]
def delete_image
image = @recipe.images.find_by(id: params[:record_id])
image.purge if image
redirect_to recipe_url(@recipe), notice: 'Imagem excluída com sucesso.'
end
e ao final do mesmo:
def recipe_params
params.require(:recipe).permit(:title, :ingredients, :instructions, images: [])
end
Alterando o arquivo config/routes.rb
deve ficar apenas com:
Rails.application.routes.draw do
devise_for :users
resources :recipes do
collection do
get 'by_user/:user_id', to: 'recipes#by_user', as: 'by_user'
end
member do
delete 'delete_image/:record_id', to: 'recipes#delete_image', as: :delete_image
end
end
root 'home#index'
end
Alterando o arquivo app/views/recipes/_form.html.erb
no início com:
<%= form_with(model: recipe, local: true) do |f| %>
No atributo images
do form altere para:
<%= f.file_field :images, multiple: true, class: 'form-control' %>
E ao final deve ser alterado para imagens processadas:
<% unless recipe.images.blank? %>
<h4 class="mt-4 mb-4">Imagens da Receita</h4>
<div class="row">
<% recipe.images.each_slice(4) do |image_group| %>
<% image_group.each do |recipe_image| %>
<div class="col-md-3 mb-3">
<div class="card">
<%= image_tag image.variant(resize: "150x150").processed, class: "card-img-top img-thumbnail", style: "max-height: 150px; object-fit: cover;" %>
<div class="card-body">
<%= button_to 'Excluir', delete_image_recipe_path(id: recipe.id, record_id: image.record_id), method: :delete, class: 'btn btn-sm btn-danger btn-block', onclick: "return confirm('Tem certeza que deseja prosseguir?')" %>
</div>
</div>
</div>
<% end %>
<% (4 - image_group.size).times do %>
<div class="col-md-3"></div>
<% end %>
<% end %>
</div>
<% end %>
Configuraremos o ambiente de desenvolvimento para rodar testes automatizados utilizando o Minitest que é o padrão do adotado pelo Rails. Também será realizada a cobertura de testes para garantir a qualidade do código.
Entao agora vamos configurar os testes, pare o servidor rails e rode os comandos:
Adicionar o simplecov
no início do arquivo test/test_helper.rb
com:
require 'simplecov'
SimpleCov.start do
add_filter 'test'
end
require 'minitest/autorun'
Basta rodar o comando:
rake test
E os testes serao rodados e a pasta coverage
sera criada na raiz do projeto. Inclusive ja adicione a mesma no arquivo .gitignore
Cada alteracao feita em qualquer parte do projeto ira rodar os testes.
A cobertura por testes tera aumentado se rodar o test
novamente, basta abrir o arquivo coverage/index.html
para ver o resultado.
Agora realmente eh hora de entender o que eh util em testes e como codificar com testes.
Como o projeto foi criado sem testes, precisamos criar arquivos manualmente para cada tipo de teste.
Entendendo fixtures:
Em Minitest, as fixtures são conjuntos predefinidos de dados usados para configurar o ambiente de teste antes da execução dos testes. Elas são armazenadas em arquivos YAML na pasta test/fixtures
e representam um estado conhecido do sistema em um determinado ponto no tempo.
Precisamos então as fixtures para cada model do sistema.
As fixtures de users
e recipes
já estão criadas em test/fixtures/...
.
Adicione onde serão armazenados os arquivos de teste editando o arquivo config/storage.yml
:
test_fixtures:
service: Disk
root: <%= Rails.root.join("tmp/storage_fixtures") %>
E agora as fixtures para cada arquivo, primeiro em test/fixtures/active_storage/attachments.yml
# test/fixtures/active_storage/attachments.yml
recipe_image0:
name: image
record: recipe_testing (Recipe)
blob: recipe_image_blob0
recipe_image1:
name: image
record: recipe_testing (Recipe)
blob: recipe_image_blob1
recipe_image2:
name: image
record: recipe_testing (Recipe)
blob: recipe_image_blob2
recipe_image3:
name: image
record: recipe_testing (Recipe)
blob: recipe_image_blob3
recipe_image4:
name: image
record: recipe_testing (Recipe)
blob: recipe_image_blob4
Veja que na posição record
indica onde se pertence esse attachment
e para na frente automaticamente (Recipe)
qe indica qual model.
E para realmente pegar os arquivos locais que serão usados nos testes no arquivo test/fixtures/active_storage/blobs.yml
:
# test/fixtures/active_storage/blobs.yml
recipe_image_blob0: <%= ActiveStorage::FixtureSet.blob filename: "0.png", service_name: "test_fixtures" %>
recipe_image_blob1: <%= ActiveStorage::FixtureSet.blob filename: "1.png", service_name: "test_fixtures" %>
recipe_image_blob2: <%= ActiveStorage::FixtureSet.blob filename: "2.png", service_name: "test_fixtures" %>
recipe_image_blob3: <%= ActiveStorage::FixtureSet.blob filename: "3.png", service_name: "test_fixtures" %>
recipe_image_blob4: <%= ActiveStorage::FixtureSet.blob filename: "4.png", service_name: "test_fixtures" %>
Algo muito importante é limpar os arquivos de teste que são executados cada vez que os testes são rodados, então no arquivo test_helper.rb
adicione ao final:
at_exit do
FileUtils.rm_rf(Dir["#{Rails.root.join('tmp/storage_fixtures/*')}"])
end
Testes de models:
Para cada model que corresponde a uma tabela no banco teremos seus testes para garantir que eles operam corretamente.
Iniciando com users
test/models/user_test.rb
require 'test_helper'
class UserTest < ActiveSupport::TestCase
test 'should save user with valid attributes' do
user = User.new(
email: '[email protected]',
password: 'password',
password_confirmation: 'password'
)
assert user.save, 'User was not saved with valid attributes'
end
test 'should not save user without email' do
user = User.new(
password: 'password',
password_confirmation: 'password'
)
assert_not user.save, 'User was saved without email'
end
test 'should not save user with invalid email format' do
user = User.new(
email: 'invalid-email',
password: 'password',
password_confirmation: 'password'
)
assert_not user.save, 'User was saved with invalid email format'
end
test 'should not save user with password confirmation mismatch' do
user = User.new(
email: '[email protected]',
password: 'password',
password_confirmation: 'mismatch'
)
assert_not user.save, 'User was saved with password confirmation mismatch'
end
end
Agora com recipes
test/models/recipe_test.rb
require 'test_helper'
class RecipeTest < ActiveSupport::TestCase
test 'should save recipe with valid attributes' do
user = users(:user_testing)
recipe = user.recipes.build(
title: 'Test Recipe',
ingredients: 'Ingredient 1, Ingredient 2',
instructions: 'Step 1, Step 2'
)
recipe.images.attach(io: File.open(Rails.root.join('test/fixtures/files/0.png')), filename: '0.png',
content_type: 'image/png')
assert recipe.save, 'Recipe was not saved with valid attributes'
end
test 'should not save recipe without images' do
user = users(:user_testing)
recipe = user.recipes.build(
title: 'Test Recipe',
ingredients: 'Ingredient 1, Ingredient 2',
instructions: 'Step 1, Step 2'
)
assert_not recipe.save, 'Recipe was saved without images'
assert_not_empty recipe.errors[:images], 'Recipe must have at least one image attached'
end
test 'should not save recipe with invalid image format' do
user = users(:user_testing)
recipe = user.recipes.build(
title: 'Test Recipe',
ingredients: 'Ingredient 1, Ingredient 2',
instructions: 'Step 1, Step 2'
)
recipe.images.attach(io: File.open(Rails.root.join('test/fixtures/files/invalid_image.txt')),
filename: 'invalid_image.txt', content_type: 'text/plain')
assert_not recipe.save, 'Recipe was saved with invalid image format'
assert_not_empty recipe.errors[:images], 'Recipe must have an image file attached'
end
end
Testes de controllers:
Os testes irâo cobrir todo o cenário de operações.
Testando HomeController
test/controllers/home_controller_test.rb
:
# test/controllers/home_controller_test.rb
require 'test_helper'
class HomeControllerTest < ActionController::TestCase
test "should get index" do
get :index
assert_response :success
assert_not_nil assigns(:recipes)
end
end
Perceba que se testa não está nula pois as fixtures são carregadas iniciamente automaticamente
Testando RecipesController
test/controller/recipes_controller_test.rb
test/controller/recipes_controller_test.rb
require 'test_helper'
class RecipesControllerTest < ActionController::TestCase
setup do
@user = users(:user_testing)
@recipe = recipes(:recipe_testing)
sign_in @user
end
test 'should get index' do
get :index
assert_response :success
assert_not_nil assigns(:recipes)
end
test 'should get new' do
get :new
assert_response :success
end
test 'should create recipe' do
assert_difference('Recipe.count') do
post :create,
params: { recipe: { title: @recipe.title, ingredients: @recipe.ingredients,
instructions: @recipe.instructions } }
end
assert_redirected_to recipe_url(assigns(:recipe))
end
test 'should render new template with unprocessable entity status if recipe is not saved' do
post :create, params: { recipe: { title: '', description: '' } }
assert_response :unprocessable_entity
assert_template :new
end
test 'should show recipe' do
get :show, params: { id: @recipe }
assert_response :success
end
test 'should get edit' do
get :edit, params: { id: @recipe }
assert_response :success
end
test 'should update recipe' do
patch :update,
params: { id: @recipe,
recipe: { title: @recipe.title, ingredients: @recipe.ingredients,
instructions: @recipe.instructions } }
assert_redirected_to recipe_url(assigns(:recipe))
end
test 'should render edit template with unprocessable entity status if recipe update fails' do
params = { title: '', description: 'Updated description' }
put :update, params: { id: @recipe.id, recipe: params }
assert_response :unprocessable_entity
assert_template :edit
end
test 'should destroy recipe' do
assert_difference('Recipe.count', -1) do
delete :destroy, params: { id: @recipe }
end
assert_redirected_to recipes_url
end
test 'should delete image' do
@recipe.images.attach(io: File.open(Rails.root.join('test/fixtures/files/0.png')), filename: '0.png',
content_type: 'image/png')
delete :delete_image, params: { id: @recipe.id, record_id: @recipe.images.last.id }
assert_redirected_to recipe_url(assigns(:recipe))
assert_equal 'Imagem excluída com sucesso.', flash[:notice]
end
test 'should get recipes by user' do
get :by_user, params: { user_id: @user.id }
assert_response :success
assert_not_nil assigns(:recipes)
end
end
Pronto, novamente com o comando de teste e tudo será visto como coberto e a aplicação pronta para receber acessos.
Aumentaremos a segurança da aplicação ocultando parâmetros sensíveis.
Em config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [
:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
]
Rode novamente os testes.
Aqui vão alguns comandos úteis que estão disponíveis por conta de gems:
Irá gerar um arquivo com as possíveis falhas de segurança em seu projeto:
brakeman -o coverage/output.html
Gera comentários úteis em seus arquivos:
annotate
Eu gosto muito do editor visual studio, então algumas das extensões que utilizo:
- Conhecimento básico de Ruby e programação web.
- Instalação do RVM.
- Ter o Ruby e o Rails instalados na máquina a partir do RVM.
Ao final deste curso, você estará apto a entender que uma aplicação legada em Ruby on Rails pode e deve ser atualizada para uma versão atualizada.