Slide 1

Slide 1 text

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

Slide 51

Slide 51 text

Usa fakes para testear servicios remotos visit '/sign_in' fill_in 'email', with: '[email protected]' fill_in 'password, with: 'password' click_button 'Submit' FakeKissmetrics.should have_recorded_event('Signed up')

Slide 52

Slide 52 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 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

Slide 63

Slide 63 text

Refactoring de los tests

Slide 64

Slide 64 text

xunitpatterns.com

Slide 65

Slide 65 text

Working Effectively with Legacy Code

Slide 66

Slide 66 text

robots.thoughtbot.com/tagged/testing

Slide 67

Slide 67 text

Practiquen BDD! github.com/hgimenez/stack github.com/hgimenez/undo_redo_stack github.com/hgimenez/string_set github.com/coreyhaines/practice_game_of_life

Slide 68

Slide 68 text

¡Gracias! RubyConf Uruguay - 12 de Noviembre, 2011. Harold Giménez - @hgimenez