assert_select, HTML::Selector en spinnen

Geplaatst door Remco van 't Veer di, 02 jan 2007 19:41:00 GMT

In Rails-1.2 is de assert_tag methode deprecated geworden ipv daarvan hebben we assert_select gekregen. Dat is mooi want van:
assert_tag :tag => 'div', :attributes => {
  :class => 'articles'
}, :descendant => {
  :tag => 'div', :attributes => {
    :id => "article_#{articles(:first).id}"
  }, :descendant => {
    :tag => 'h1'
  }
}
word ik een beetje scheel. De mensen bij W3C hebben een zeer krachtig taaltje ontwikkeld om dit soort selecties te doen, de CSS selectors, en assert_select geeft de mogelijkheid deze taal te gebruiken in functionele en integratie tests:
assert_select "div.articles div#article_%s h1" % articles(:first).id

Ik ben zo enthousiast dat ik zo’n selector ook in m’n test wil gebruiken om links uit m’n HTML te peuteren waarmee ik dan weer andere tests kan doen. Om precies te zijn, ik wil een spider integratie test welke gewoon alle links in m’n applicatie volgt.

Aan de slag!

script/generate integration_test spider

Hmm, maar hoe werkt assert_select eigenlijk?

De assert_select methode vind zijn origine in scrAPI van Assaf Arkin, een API om dmv CSS selectors stukjes uit HTML (en andere XML varianten) te schrapen. Een variant van deze API wordt in Rails-1.2 meegeleverd in de vorm van een module genaamd HTML en wordt gebruikt door assert_select.

Onze nieuwe favoriete assert is grof weg als volgt geïmplementeerd:

def assert_select(selector, count = 1)
  matches = HTML::Selector.new(selector).select(response_from_page_or_rjs)
  assert matches.size == count
end

De response_from_page_or_rjs method is ook nieuw en levert het root element van de resultaat HTML of een speciaal geprepareerd stuk JavaScript. Dat is dus de plek om naar links te gaan vissen voor onze spider:

HTML::Selector.new('a[href]').select(response_from_page_or_rjs)

Het bovenstaande levert alle links uit de resultaat HTML. Maar we willen alleen links binnen onze applicatie, ofwel alle relatieve links. Dat is gemakkelijk te testen met de URI class:

URI.parse(link).relative?

En bij een link met een hekje, zoals http://test.net/test#test, moet het deel met het hekje eraf gesloopt worden:

link.sub(/#.*$/, '')

En dat dan recursief en bijhouden welke links we al gevolgd hebben:

def spider(uri)
  get uri
  HTML::Selector.new('a[href]').select(response_from_page_or_rjs).each do |anchor|
    link = anchor['href'].sub(/#.*$/, '')
    if !@visited.include?(link) && URI.parse(link).relative?
      @visited << link
      spider link
    end
  end
end

Met een assert erin, @visited geïnitialiseerd in de setup methode en de selector herbruikbaar gemaakt, kom ik op de volgende integratie test:

require "#{File.dirname(__FILE__)}/../test_helper"

class SpiderTest < ActionController::IntegrationTest
  def test_spider
    spider('/')
  end

  def setup
    @visited = []
    @selector = HTML::Selector.new('a[href]')
  end

  def spider(uri)
    get uri
    assert !response.error?, "#{uri} responded with an error status"

    @selector.select(response_from_page_or_rjs).each do |anchor|
      link = anchor['href'].sub(/#.*$/, '')
      if !@visited.include?(link) && URI.parse(link).relative?
        @visited << link
        spider link
      end
    end
  end
end

Mogelijk verbeter punten?

Een beetje feedback zou niet gek zijn. M’n gloed nieuwe MacBook doet deze test in 90 seconden om alle 1500 links te volgen in de applicatie waar ik nu mee bezig ben. Sterker nog, in eerste instantie vond ik een eindeloze loop in m’n routing waarbij de URL steeds langer werden. Een puts uri in het begin van de spider methode?

Zo’n eindeloze loop is natuurlijk gemakkelijk te detecteren door een assert te doen op de lengte van de @visited array;

..
      @visited << link
      assert @visited.size < 10_000, "endless routing loop?"
..

Het is misschien ook wel leuk om alle terug gegeven HTML meteen te valideren op correctheid.

Geplaatst in ,  | 5 reacties

Reacties

  1. Matthijs Langenberg zei ongeveer 16 uur later:
    Een spider die alle URL’s binnen een pagina volgt en deze meteen valideerd heb ik al eens geschreven. Het valideren heb ik (quick’n dirty) middels de WWW::Mechanize library op de volgende manier gedaan:
    
    class WWW::Mechanize::Page
      def get_validation_errors
        page = self
        result = WWW::Mechanize.new.post('http://validator.w3.org/check', "fragment" => page.root)
        unless result.body =~ /This Page Is Valid/
          ## Use a more common directory inside a rails app.
          file = File.open("pages/#{Time.now.strftime("[%H:%M:%S]")} #{page.uri.to_s.gsub('/','_')}.html", 'w') do |f|
            f.puts result.body
          end
    
          ## parse error messages
          error_msgs = result.search("//span[@class='msg']")
          error_msgs.collect! { |element| CGI.unescapeHTML(element.inner_html) }
    
          result.body =~ /(\d+) error/
          [$1.to_i, error_msgs]
        else
          [0]
        end 
      end
    end
    
  2. RemVee zei ongeveer 18 uur later:
    Prachtig! Met de volgende assert in je test_helper kan je de beschreven spider integratie test gemakkelijk uitbreiden:
    require 'net/http'
    
    def assert_valid_html(message = 'HTML not valid!')
      result = Net::HTTP.post_form(
          URI.parse('http://validator.w3.org/check'),
          'fragment' => response.body)
      assert_match /This Page Is Valid/, result.body, message
    end

    Of het verstandig is om je test suite afhankelijk te maken van een webservice weet ik eigenlijk niet. Als ik autotest de hele dag draai, zal ik daarmee duizenden validaties uit laten voeren en dat is misschien niet zo vriendelijk..

  3. Matthijs Langenberg zei ongeveer 18 uur later:

    Het is ook mogelijk om de markup validator service lokaal te draaien, zie: http://validator.w3.org/docs/install.html.

  4. Erik van Oosten zei 2 dagen later:

    En hoe zit het dan met onclicks?

  5. RemVee zei 2 dagen later:

    Welke onclicks? Er zijn twee reden om geen onclicks te doen.

    Er horen helemaal geen onclick attributen in je response te zitten probeer CSS, HTML en JavaScript gescheiden te houden.

    In het “echte wereld” geval dat je toch onclicks hebt, wat wil je er dan mee doen? Onclicks zijn JavaScript snippets, wil je die dan match op iets als /document.location\s*=\s*(['"])(.+)\1/ en dan groep $2 ook volgen? Ik vind het nix..

    Bij <form>’s kan ik me nog wat voorstellen. Gewoon een beetje op submit knoppen rossen is wel een aardige test.

(Laat url/e-mail achter »)

   Voorvertoning