Creando Test Suites
Mantenibles
RubyConf Uruguay - 12 de Noviembre, 2011.
Harold Giménez - @hgimenez
Slide 2
Slide 2 text
¿Quién soy?
Desarrollador en thoughtbot
Consulting, Training, Productos
Enfoque en Ruby, Rails, Javascript.
Proceso, ágil y desarrollo de software de alta calidad, rápido.
Comunidad y Open Source
Slide 3
Slide 3 text
¿Qué es un Test Suite mantenible?
Detector de bugs, previene regresiones
Facilita el Refactoring
Elimina todo tipo de comportamiento aleatorio
Independencia entre tests
Ejecutable sin conección de red
Slide 4
Slide 4 text
El Plan
Slide 5
Slide 5 text
No utilices generadores de data
aleatoria
Slide 6
Slide 6 text
random-data, Faker, forgery
user = User.create(password: Forger(:basic).password, email:
Forgery(:basic).email)
user.purchase!(item)
user.should have_purchased(item)
Finished in 0.0418 seconds
1 example, 0 failures
Slide 7
Slide 7 text
Pasa un tiempo y necesitas mas restricciones con los passwords
class User
validates :password, length: { min: 8 }
end
Slide 8
Slide 8 text
user.purchase!(item)
user.should have_purchased(item)
Finished in 0.0223 seconds
1 example, 0 failures
Slide 9
Slide 9 text
user.purchase!(item)
user.should have_purchased(item)
Finished in 0.0223 seconds
1 example, 1 failure
user.name
# => José
user.name.length
# => 4
Slide 10
Slide 10 text
FAIL
Slide 11
Slide 11 text
Usa factories
Slide 12
Slide 12 text
Usa factories
Fixtures compartidos inmantenibles y lentos
Manten los setups a lo mínimo, y cerca del test
Que revelen la intención del test
Slide 13
Slide 13 text
FactoryGirl
Slide 14
Slide 14 text
Usa factories
subject do
User.create(email: '[email protected]',
password: 'password',
password_confirmation: 'password',
date_of_birth: '01-13-1983')
end
its(:age) { should == 27 }
Slide 15
Slide 15 text
Usa factories
subject do
User.create(email: '[email protected]',
password: 'password',
password_confirmation: 'password',
date_of_birth: '01-13-1983')
end
its(:age) { should == 27 }
Slide 16
Slide 16 text
Usa factories
spec/factories.rb
FactoryGirl.define do
factory :user do
email
password 'password'
end
end
spec/models/user_spec.rb
subject { create(:user, date_of_birth: '02-03-1983') }
its(:age) { should == 27 }
Slide 17
Slide 17 text
Fresh Fixtures
let(:data) { File.read('fixtures/data.xml) }
subject { build(:scores, data: data) }
it 'builds a parsed XML document from the data' do
subject.document.at('.//event/description').text.should ==
'Boston vs. Montreal'
end
Slide 18
Slide 18 text
Fresh Fixtures
let(:data) { File.read('fixtures/data.xml) }
subject { build(:scores, data: data) }
it 'builds a parsed XML document from the data' do
subject.document.at('.//event/description').text.should ==
'Boston vs. Montreal'
end
Slide 19
Slide 19 text
Fresh Fixtures
let(:data) { build_xml(home_team: 'Boston', away_team: 'Montreal'
}
subject { build(:scores, data: data) }
it 'builds a parsed XML document from the data' do
subject.document.at('.//event/description').text.should ==
'Boston vs. Montreal'
end
def build_xml(opts)
builder = Builder::XmlMarkup.new
builder.event do |event|
event.description "#{opts[:home_team]} vs. #
{opts[:away_team]}"
# ...
end
end
Slide 20
Slide 20 text
No sobre-especificar
Slide 21
Slide 21 text
No sobre-especificar
No digas cómo, sino qué
No crear stubs en el SUT
Complica hacer refactoring
Señal de violaciones del Single Responsability Principle, Law of Demeter
Traza la linea
Slide 22
Slide 22 text
No digas cómo, sino qué
class User
def card_number
fetch_credit_card.number
end
def fetch_credit_card
card = Net::HTTP.new('https://gateway.com').get("/cards/#
{self.remote_id}")
CreditCard.new(card['data'])
end
end
Slide 23
Slide 23 text
No digas cómo, sino qué
describe User, '#card_number' do
subject { create(:user) }
it "returns the user's credit card" do
mock_http = stub
mock_http.stubs(get: { number: 1234 })
Net::HTTP.stubs(new: mock_http)
subject.card_number.should == '1234'
end
end
Slide 24
Slide 24 text
No digas cómo, sino qué
describe User, '#card_number' do
subject { create(:user) }
it "returns the user's credit card" do
mock_http = stub
mock_http.stubs(get: { number: 1234 })
Net::HTTP.stubs(new: mock_http)
subject.card_number.should == '1234'
end
end
Slide 25
Slide 25 text
Stubs en el SUT
describe User, '#card_number' do
subject { create(:user) }
it "returns the user's credit card" do
card = CreditCard.new(number: '1234')
subject.stubs(fetch_credit_card: card)
subject.card_number.should be == '1234'
end
end
Slide 26
Slide 26 text
Stubs en el SUT
describe User, '#card_number' do
subject { create(:user) }
it "returns the user's credit card" do
card = CreditCard.new(number: '1234')
subject.stubs(fetch_credit_card: card)
subject.card_number.should be == '1234'
end
end
Slide 27
Slide 27 text
Stubs en el SUT
describe User, '#card_number' do
subject { create(:user) }
it "returns the user's credit card" do
fake_card = stub(number: '1234')
CreditCard.stubs(new: fake_card)
subject.card_number.should be == '1234'
end
end
Slide 28
Slide 28 text
Usa Inyección de Dependencias
class User
def card_number
fetch_credit_card.number
end
def fetch_credit_card
CreditCard.new(user: self).fetch
end
end
Slide 29
Slide 29 text
Usa Inyección de Dependencias
class User
def card_number
fetch_credit_card.number
end
def fetch_credit_card
credit_card_fetcher.new(user: self).find
end
def credit_card_fetcher
@fetcher ||= CreditCard
end
def credit_card_fetcher=(fetcher_class)
@fetcher = fetcher_class
end
end
Slide 30
Slide 30 text
Usa Inyección de Dependencias
describe User, '#card_number' do
class FakeCreditCard
def initialize(user); end
def fetch
CreditCard.new(number: '1234')
end
end
subject { create(:user) }
before { subject.credit_card_fetcher = FakeCreditCard }
it "returns the user's card number" do
subject.card_number.should be == '1234'
end
end
Slide 31
Slide 31 text
Usa Inyección de Dependencias
describe User, '#card_number' do
class FakeCreditCard
def initialize(user); end
def fetch
CreditCard.new(number: '1234')
end
end
subject { create(:user) }
before { subject.credit_card_fetcher = FakeCreditCard }
it "returns the user's card number" do
subject.card_number.should be == '1234'
end
end
Slide 32
Slide 32 text
No dependas del tiempo
Slide 33
Slide 33 text
No dependas del tiempo
Muy fácil crear tests que pasan en la mañana y no en la tarde
Muy fácil crear tests que pasan ahora, pero no cuando cambia el DST.
Muy fácil crear tests que pasan en tu computador, pero no en CI.
Tiempo del servidor distinto al cliente: tests para Javascript se complican
Slide 34
Slide 34 text
No dependas del tiempo
let(:article) { build(:article) }
before { article.publish! }
it 'displays the published date and time' do
assigns[:article] = article
render
response.should contain("Published at #{Time.now.to_s}")
end
.
Finished in 0.1428 seconds
1 example, 0 failures
Slide 35
Slide 35 text
No dependas del tiempo
let(:article) { build(:article) }
before { article.publish! }
it 'displays the published date and time' do
assigns[:article] = article
render
response.should contain("Published at #{Time.now.to_s}")
end
F
Finished in 0.1234 seconds
1 example, 1 failure
Slide 36
Slide 36 text
No dependas del tiempo
Solución 1: Timecop
let(:article) { build(:article) }
before do
Timecop.freeze
article.publish!
end
after { Timecop.return }
it 'displays the published date and time' do
assigns[:article] = article
render
response.should contain("Published at #{Time.now.to_s}")
end
.
Finished in 0.0981 seconds
1 example, 0 failures
Slide 37
Slide 37 text
No dependas del tiempo
Solución 2: parametizar tiempo de publicación
class Article
def publish(time = Time.now)
update_attributes(published: true, published_at: time)
end
end
let(:article) { build(:article) }
let(:publish_time) { Time.now }
before { article.publish!(publish_time) }
it 'displays the published date and time' do
assigns[:article] = article
render
response.should contain("Published at #{publish_time.to_s}")
end
.
Finished in 0.0981 seconds
1 example, 0 failures
Slide 38
Slide 38 text
No dependas del tiempo
Javascript: forzar la hora del servidor en el cliente
Ejemplo en Rails:
<%= javascript_tag do -%>
Date.currentDate = function() {
<% if Rails.env.test? -%>
return new Date(<%== Time.now.to_json %>);
<% else -%>
return new Date();
<% end -%>
};
<% end -%>
Luego ser juiciozo con siempre utilizar Date.currentDate() en lugar de new
Date()
Slide 39
Slide 39 text
No Duermas
Slide 40
Slide 40 text
No Duermas
Comportamiento asincrónico como Ajax es complicado de testear
Cómo NO testearlo:
make_call()
sleep(5)
check_response()
Si el response es en 1 segundo, tu suite pierde 4 segundos esperando
Peor aun, si el response es en 6 segundos, tienes un falso negativo.
Slide 41
Slide 41 text
No Duermas
Ejemplo con capybara
before do
create(:album, title: 'Dark side of the moon')
create(:album, title: 'Dark Magus')
create(:album, title: 'Jimi: Blues')
end
visit '/search'
fill_in 'Search', with: 'Dark'
click_button 'Submit'
sleep(5)
page.should have_content('Dark side of the moon')
page.should have_content('Dark Magus')
page.should have_no_content('Jimi: Blues')
Slide 42
Slide 42 text
No Duermas
before do
create(:album, title: 'Dark side of the moon')
create(:album, title: 'Dark Magus')
create(:album, title: 'Jimi: Blues')
end
visit '/search'
fill_in 'Search', with: 'Dark'
click_button 'Submit'
sleep(5)
page.should have_content('Dark side of the moon')
page.should have_content('Dark Magus')
page.should have_no_content('Jimi: Blues')
Slide 43
Slide 43 text
No Duermas
before do
create(:album, title: 'Dark side of the moon')
create(:album, title: 'Dark Magus')
create(:album, title: 'Jimi: Blues')
end
visit '/search'
fill_in 'Search', with: 'Dark'
click_button 'Submit'
wait_limit = 5
start_time = Time.now
until done do
raise TimeoutError if (Time.now - start_time) > wait_limit
done = page.has_content?('Here are your results')
sleep(0.1)
end
page.should have_content('Dark side of the moon')
Slide 44
Slide 44 text
No Duermas
before do
create(:album, title: 'Dark side of the moon')
create(:album, title: 'Dark Magus')
create(:album, title: 'Jimi: Blues')
end
visit '/search'
fill_in 'Search', with: 'Dark'
click_button 'Submit'
Capybara.wait_until(5) do
page.should have_content('Dark side of the moon')
page.should have_content('Dark Magus')
page.should have_no_content('Jimi: Blues')
end
Slide 45
Slide 45 text
Fakes para servicios remotos
Slide 46
Slide 46 text
Usa fakes para testear servicios
remotos
Elimina necesidad por connecciones de red
Tests mas rapidos
Puedes imponer el estado del servicio para testear edge cases
Puedes testear comportamiento de tu sistema cuando hay timeouts en el
servicio remoto
Elimina fallas de tus tests causados por problemas en el servicio
Slide 47
Slide 47 text
Ejemplo: FakeKissmetrics para sham_rack
class FakeKissmetrics
cattr_accessor :requests
def self.clear_requests
@@events = []
end
def call(env)
request = Rack::Request.new(env)
@@events << request.params
[200, { 'Content-Length' => 0 }, []]
end
def self.has_recorded_event?(event_name)
@@events.map { |r| r['_n'] }.include? event_name
end
end
Slide 48
Slide 48 text
Ejemplo: FakeKissmetrics para sham_rack
class FakeKissmetrics
cattr_accessor :requests
def self.clear_requests
@@events = []
end
def call(env)
request = Rack::Request.new(env)
@@events << request.params
[200, { 'Content-Length' => 0 }, []]
end
def self.has_recorded_event?(event_name)
@@events.map { |r| r['_n'] }.include? event_name
end
end
Slide 49
Slide 49 text
Ejemplo: FakeKissmetrics para sham_rack
class FakeKissmetrics
cattr_accessor :requests
def self.clear_requests
@@events = []
end
def call(env)
request = Rack::Request.new(env)
@@events << request.params
[200, { 'Content-Length' => 0 }, []]
end
def self.has_recorded_event?(event_name)
@@events.map { |r| r['_n'] }.include? event_name
end
end
Slide 50
Slide 50 text
Ejemplo: FakeKissmetrics para sham_rack
require 'sham_rack'
Before do
ShamRack.mount(FakeKissmetrics.new, 'trk.kissmetrics.com')
end
After do
FakeKissmetrics.clear_requests
end
Fake Heroku en suspenders
Scenario: User uses the --heroku=true command line argument
When I suspend a project called "test_project" with:
| argument | value |
| --heroku | true |
Then the "test_project-production" heroku app should exist
And the "test_project-production" heroku app should exist
Slide 53
Slide 53 text
Fake Heroku en suspenders
Scenario: User uses the --heroku=true command line argument
When I suspend a project called "test_project" with:
| argument | value |
| --heroku | true |
Then the "test_project-production" heroku app should exist
And the "test_project-production" heroku app should exist
Slide 54
Slide 54 text
Then /^the "([^"]*)" heroku app should exist$/ do |app_name|
FakeHeroku.should have_created_app(app_name)
end
Slide 55
Slide 55 text
class FakeHeroku
def initialize(args)
@args = args
end
def run!
File.open(RECORDER, 'a') do |file|
file.write @args.join(' ')
end
end
def self.clear!
FileUtils.rm_rf(RECORDER)
end
def self.has_created_app?(app_name)
File.open(RECORDER, 'r').read.include?("create #{app_name}")
end
end
After do
FakeHeroku.clear!
end
Slide 56
Slide 56 text
class FakeHeroku
def initialize(args)
@args = args
end
def run!
File.open(RECORDER, 'a') do |file|
file.write @args.join(' ')
end
end
def self.clear!
FileUtils.rm_rf(RECORDER)
end
def self.has_created_app?(app_name)
File.open(RECORDER, 'r').read.include?("create #{app_name}")
end
end
After do
FakeHeroku.clear!
end
Slide 57
Slide 57 text
class FakeHeroku
def initialize(args)
@args = args
end
def run!
File.open(RECORDER, 'a') do |file|
file.write @args.join(' ')
end
end
def self.clear!
FileUtils.rm_rf(RECORDER)
end
def self.has_created_app?(app_name)
File.open(RECORDER, 'r').read.include?("create #{app_name}")
end
end
After do
FakeHeroku.clear!
end
Slide 58
Slide 58 text
class FakeHeroku
def initialize(args)
@args = args
end
def run!
File.open(RECORDER, 'a') do |file|
file.write @args.join(' ')
end
end
def self.clear!
FileUtils.rm_rf(RECORDER)
end
def self.has_created_app?(app_name)
File.open(RECORDER, 'r').read.include?("create #{app_name}")
end
# recuerda:
# FakeHeroku.should have_created_app(app_name)
end
Slide 59
Slide 59 text
Cuarentena
Slide 60
Slide 60 text
Cuarentena para tests con
resultados impredecibles
Tests que tienen failures esporádicos
No permitir que hayan mas de 7
Un dia a la semana/mes/lo que sea dedicado a limpiar la cuarentena
Slide 61
Slide 61 text
Con cucumber
features/something_failing.feature
@quarantine
Scenario: Something that is failing right now
When I go to the home page
And I vote for Pedro
Then ...
config/cucumber.yml
default: --tags ~@quarantine
quarantine: --tags @quarantine
Slide 62
Slide 62 text
Con Rspec
describe 'something that is failing right now', quarantine: true
do
it 'does stuff' do
(2 + 2).should == 5
end
end
spec/spec_helper.rb
Rspec.configure do |config|
config.filter_run_excluding quarantine: true
end
> rspec --tags ~quarantine
> rspec --tags quarantine