Test Incoming Webhook Requests with Faraday

Nathan L. Walls

I was recently working with a client who has an application that accepts incoming webhooks from GitHub. We track receipt of these webhooks and display state information to the user.

We test this functionality with request specs, which provide unit-level guarantees. We also want to use a Capybara feature spec to cover the entire workflow, not just individual units in the system.

Previously, we’ve written about stubbing external services in tests. We pursue that strategy elsewhere for testing this application, but it isn’t a fit for this problem. In this situation, we need the webhooks triggered outside of the application to be received by the application after a user begins one task and before they proceed to the next step in the application workflow.

First, let’s add some code in to support responding to webhooks, using Faraday:

# spec/support/fake_github_webhook.rb
require "faraday"

class FakeGitHubWebhook
  def initialize(fixture:, host:, path:, port:)
    @fixture = fixture
    @host = host
    @path = path
    @port = port

    load_fixture
    construct_connection
  end

  def send
    connection.post do |request|
      request.url path
      request.headers = headers
      request.body = JSON.generate(body)
    end
  end

  private

  attr_accessor(
    :body,
    :connection,
    :fixture,
    :headers,
    :path,
    :session
  )

  def construct_connection
    @connection = Faraday.new(url: "http://#{@host}:#{@port}")
  end

  def fixture_path
    "#{Rails.root}/spec/fixtures/github_webhooks/#{fixture}"
  end

  def load_fixture
    fixture_json = JSON.parse(File.read(fixture_path))

    @headers = fixture_json.fetch("headers")
    @body = fixture_json.fetch("body")
  end
end

This application needs to receive several different webhook calls. In support of that, this class will load a JSON-formatted fixture file to populate request headers and the request body.

To create the fixture, we can look at the webhooks configured against the application and see example successful requests in GitHub. If your GitHub repository has webhooks configured for it, you can see those by going to Settings then Webhooks & Services in your repository. You can then see Recent Deliveries, which will look similar to this:

Recent webhook deliveries

Click on one of the hash values and you’ll see headers and the request payload, like so:

Expanded webhook delivery

Copy that data and make a JSON fixture file like the following, in spec/fixtures/github_webhooks/ping.json:

{
  "headers": {
    "Content-Type": "application/json",
    "User-Agent": "GitHub-Hookshot/9a55c2d",
    "X-GitHub-Delivery": "ae35f100-e900-11e4-8a9e-df298010fa27",
    "X-GitHub-task": "ping"
  },
  "body": {
    "zen": "Keep it logically awesome.",
    "hook_id": 4648018,
    "hook": {
      "url": "https://api.github.com/repos/example-user/test-repository/hooks/4648018",
      "test_url": "https://api.github.com/repos/example-user/test-repository/hooks/4648018/test",
      "ping_url": "https://api.github.com/repos/example-user/test-repository/hooks/4648018/pings",
      "id": 5555,
      "name": "web",
      "active": true,
      "tasks": [
        "*"
      ]
    }
  }
}

The repository name and GitHub user name are important in the sense that they need to line up with our test expectations. We’re using example-user and test-repository, respectively. You may see other information in the headers and payload you want to sanitize.

If you’re hand-rolling these fixture files, validate them with a JSON linter. I decided to make the process easy by installing jsonlint locally, which let me test the fixture files like so:

% npm install jsonlint -g
% jsonlint spec/fixtures/github_webhooks/ping.json

Now, we’re ready to write a Capybara feature spec using FakeGitHubWebhook:

require "rails_helper"

feature "User opens task", :js do
  scenario "and can complete GitHub steps" do
    user = create(:user, username: "example-user")
    task = create(:task)
    ping_verification = task.steps.first.verification

    sign_in(user: user)
    visit task_path(task)
    click_on t("links.begin_task")

    expect(page).to have_content(ping_verification.error_text)

    fake_github_webhook("ping").send

    visit current_url
    expect(page).to have_content(ping_verification.success_text)
  end

  def fake_github_webhook(fixture)
    FakeGitHubWebhook.new(
      fixture: "#{fixture}.json",
      host: Capybara.current_session.server.host,
      path: "/github/callbacks",
      port: Capybara.current_session.server.port,
    )
  end
end

This test is declared as a JavaScript test so Capybara will provide access to a session backed by a running instance of the application at 127.0.0.1 instead of an unreachable one at www.example.com. Similarly, we need to provide the port because Capybara is going to run the test instance of the application on a new port and our FakeGitHubWebhook will need to reach it.

We setup our test and start walking through the application workflow. Then, we can use our FakeGitHubWebhook Faraday client to send in a webhook notification to the application at the spot in the workflow where we expect to have it. Now, we have an proper end-to-end test, including having the application receive requests from outside.