对于controller的功能性测试,可以这样写(摘自IntegrationTest里的注释)

class UserFlowsTest < ActionDispatch::IntegrationTest
  test "login and browse site" do
    # login via https
    https!
    get "/login"
    assert_response :success

    post "/login", params: { username: users(:david).username, password: users(:david).password }
    follow_redirect!
    assert_equal '/welcome', path
    assert_equal 'Welcome david!', flash[:notice]

    https!(false)
    get "/articles/all"
    assert_response :success
    assert_select 'h1', 'Articles'
  end
end


这里的assert_response是这样定义的,它只是简单地将assertion转发给@response

module ActionDispatch
  module Assertions
    # A small suite of assertions that test responses from \Rails applications.
    module ResponseAssertions
      RESPONSE_PREDICATES = { # :nodoc:
        success:  :successful?,
        missing:  :not_found?,
        redirect: :redirection?,
        error:    :server_error?,
      }

      # Asserts that the response is one of the following types:
      #
      # * :success   - Status code was in the 200-299 range
      # * :redirect  - Status code was in the 300-399 range
      # * :missing   - Status code was 404
      # * :error     - Status code was in the 500-599 range
      #
      # You can also pass an explicit status number like assert_response(501)
      # or its symbolic equivalent assert_response(:not_implemented).
      # See Rack::Utils::SYMBOL_TO_STATUS_CODE for a full list.
      #
      #   # Asserts that the response was a redirection
      #   assert_response :redirect
      #
      #   # Asserts that the response code was status code 401 (unauthorized)
      #   assert_response 401
      def assert_response(type, message = nil)
        message ||= generate_response_message(type)

        if RESPONSE_PREDICATES.keys.include?(type)
          assert @response.send(RESPONSE_PREDICATES[type]), message
        else
          assert_equal AssertionResponse.new(type).code, @response.response_code, message
        end
      end


之所以能对response做assert,是因为ActionDispatch::IntegrationTest完成一轮请求响应后,有保存@response

def process(method, path, params: nil, headers: nil, env: nil, xhr: false, as: nil)

  # ......

  session = Rack::Test::Session.new(_mock_session)

  session.request(build_full_uri(path, request_env), request_env)

  @request_count += 1
  @request = ActionDispatch::Request.new(session.last_request.env)
  response = _mock_session.last_response
  @response = ActionDispatch::TestResponse.from_response(response)
  @response.request = @request
  @html_document = nil
  @url_options = nil

  @controller = @request.controller_instance

  response.status
end


这里包装response的ActionDispatch::TestResponse其实只是简单包装,与RESPONSE_PREDICATES无关

module ActionDispatch

  class TestResponse < Response
    def self.from_response(response)
      new response.status, response.headers, response.body
    end

    def initialize(*) # :nodoc:
      super
      @response_parser = RequestEncoder.parser(content_type)
    end

    # Was the response successful?
    alias_method :success?, :successful?

    # Was the URL not found?
    alias_method :missing?, :not_found?

    # Was there a server-side error?
    alias_method :error?, :server_error?

    def parsed_body
      @parsed_body ||= @response_parser.call(body)
    end
  end
end


RESPONSE_PREDICATES所用的方法是Rack本来就提供的

module Rack
  class Response
    module Helpers
      def invalid?;             status < 100 || status >= 600;        end

      def informational?;       status >= 100 && status < 200;        end
      def successful?;          status >= 200 && status < 300;        end
      def redirection?;         status >= 300 && status < 400;        end
      def client_error?;        status >= 400 && status < 500;        end
      def server_error?;        status >= 500 && status < 600;        end

      def ok?;                  status == 200;                        end
      def created?;             status == 201;                        end
      def accepted?;            status == 202;                        end
      def no_content?;          status == 204;                        end
      def moved_permanently?;   status == 301;                        end
      def bad_request?;         status == 400;                        end
      def unauthorized?;        status == 401;                        end
      def forbidden?;           status == 403;                        end
      def not_found?;           status == 404;                        end
      def method_not_allowed?;  status == 405;                        end
      def precondition_failed?; status == 412;                        end
      def unprocessable?;       status == 422;                        end

      def redirect?;            [301, 302, 303, 307, 308].include? status; end
    end

  include Helpers

  end
end


至于assert_select,使用的是nokogiri来抽取dom来检验

如果进行trace的话

binding.trace_tree(html: true, tmp: ['rails', 'assert_select.html']) do
  assert_select '.price', /\$[,\d]+\.\d\d/
end


可得调用栈如下

assert_select.html

大概流程如下


assert_select会根据所传参数来建立一个HTMLSelector,这个HTMLSelector所要select的页面是document_root_element

def assert_select(*args, &block)
  @selected ||= nil

  selector = HTMLSelector.new(args, @selected) { nodeset document_root_element }

  if selector.selecting_no_body?
    assert true
    return
  end

  selector.select.tap do |matches|
    assert_size_match!(matches.size, selector.tests,
      selector.css_selector, selector.message)

    nest_selection(matches, &block) if block_given? && !matches.empty?
  end
end


这个document_root_element实际上也是从response的body取出html,并用nokogiri包装(解析)出的root元素

#actionpack-5.0.2/lib/action_dispatch/testing/integration.rb
def document_root_element
  html_document.root
end


html_document如下

module ActionDispatch
  module Assertions
    autoload :ResponseAssertions, 'action_dispatch/testing/assertions/response'
    autoload :RoutingAssertions, 'action_dispatch/testing/assertions/routing'

    extend ActiveSupport::Concern

    include ResponseAssertions
    include RoutingAssertions
    include Rails::Dom::Testing::Assertions

    def html_document
      @html_document ||= if @response.content_type.to_s =~ /xml\z/
        Nokogiri::XML::Document.parse(@response.body)
      else
        Nokogiri::HTML::Document.parse(@response.body)
      end
    end
  end
end