Building The Conversation

Email helpers in your acceptance specs

Wouldn’t it be nice if you could write acceptance specs for your email like this?

    feature 'Subscribe to email list' do

      scenario 'Successful subscription' do
        visit '/'
        fill_in 'Email', with: 'don@example.com'
        click_button 'Subscribe'
        page.should have_content 'Check your email'

        "don@example.com".should receive_email(:subject => 'Welcome')

        click_link_in_email "Confirm my subscription", :subject => 'Welcome'
        page.should have_content 'Subscription confirmed'
      end

    end


Yes, yes it would. Here is how you do it. Drop this in your spec support folder:

    module AcceptanceEmailHelpers

      # Finds the latest email to match the given options, locates an HTML link
      # matching the given text, and visits it. Options are not validated - they
      # are passed straight through to the found mail object.
      def click_link_in_email(text, options)
        mail = find_email_matching(options)
        unless mail
          fail "Could not locate mail matching: #{options.inspect}"
        end

        element = Nokogiri::HTML(mail.body.to_s).css('a').detect {|x| x.text == text }
        unless element
          fail "Could not find link with text '#{text}' in:\n\n#{mail.body.to_s}"
        end

        visit strip_hostname(element.attribute('href').value)
      end

      def strip_hostname(url)
        uri = URI.parse(url)
        if uri.host.blank?
          raise "Host was not set in email url => #{url}"
        end
        [uri.path, uri.query].compact.join('?')
      end

      def find_email_matching(options)
        options[:from] = extract_plain_email(options[:from]) if options[:from]
        options[:to]   = extract_plain_email(options[:to]) if options[:to]

        ActionMailer::Base.deliveries.reverse.detect do |mail|
          options.all? do |method, value|
            [*mail.send(method)].any? {|x| x.include?(value) }
          end
        end
      end

      # Emails can look like "bill@example.com" or "Bill <bill>", hence
      # grab only the plain email address bit
      def extract_plain_email(email)
        email[/([\w\.%\+\-]+)@([\w\-]+\.)+([\w]{2,})/i]
      end

      # Called on an Author, Editor, User, or Institution. Will regexp match all the
      # given fields against any email delivered to that user. Supported fields:
      #   :subject
      #   :body
      RSpec::Matchers.define :receive_email do |options = {}|
        match do |recipient|
          find_email_matching(options_with_recipient(options, recipient))
        end

        failure_message_for_should do |recipient|
          <<-EOS
            Expected to receive an email matching #{options_with_recipient(options, recipient).inspect}"}
            Received:
              #{ActionMailer::Base.deliveries.map {|x| "#{x.to.join(', ')}: #{x.subject}" }.join("\n  ")}
          EOS
        end

        failure_message_for_should_not do |recipient|
          <<-EOS
            Expected not to receive an email matching #{options_with_recipient(options, recipient).inspect}"}
            Received:
              #{ActionMailer::Base.deliveries.map {|x| "#{x.to.join(', ')}: #{x.subject}" }.join("\n  ")}
          EOS
        end

        def options_with_recipient(options, recipient)
          options.merge(to: email_for_recipient(recipient))
        end

        def email_for_recipient(recipient)
          if recipient.is_a?(String)
            recipient
          else
            recipient.email
          end
        end
      end
    end

    RSpec.configuration.include EmailHelpers


There’s a bit of complexity in there but it’s all helpful. It’s a veritable treasure chest.