Kirill Platonov
Kirill Platonov
All Posts Aug 17, 2021

Testing Shopify Apps in Rails

Let’s talk for a bit about testing embedded Shopify Apps. With unit tests everything is straightforward. But how would you write integration and system tests for the app that is rendered in iframe? Especially now, when it uses JWT instead of cookies for Shop authentication? 🤯

The approach I use allows to skip the iframe, fallback to cookies and write integration and system tests as we normally do in Rails. Let’s take a look how to implement that step by step.

1. Escape iframe

The first thing to do is to stop redirecting app to the iframe. The redirect is managed by AppBridge. We need to cancel it before initialization. To manage redirect I use force_iframe config option:

# config/environment/test.rb

Rails.application.configure do
  # ...
  config.force_iframe = false
end

This makes it easy to manage redirect across different environment. For the development environment I use DISABLE_IFRAME variable to disable redirect when I need to develop locally.

DISABLE_IFRAME=1 bin/rails s

What this configuration does?

  • Cancels AppBridge initialization in app/javascript/shopify_app/shopify_app.js file.
  • Skips redirect to splash screen.
  • Disables redirect via turbo streams in turbo_redirect_to helper.

Here’s the implementation example: https://github.com/kirillplatonov/shopify-hotwire-sample/commit/0401aea25fe7e9504d2b0df20f82b7633537fddb

Having config like that will be useful not only for testing purposes. You could also use it to develop app locally. At least for features that don’t rely on AppBridge.

2. Fallback to cookies

Now when we’re outside of iframe our JWT auth won’t work. But we can easily fallback to the old good cookies by replacing one line in the ShopifyApp config:

# config/initializers/shopify_app.rb

ShopifyApp.configure do |config|
  # ...
  config.allow_cookie_authentication = !Rails.configuration.force_iframe
end

3. Login as shop

The final piece of this setup is helpers for integration and system tests that will mock OmniAuth and allow us to login as Shop in our tests.

For integration tests:

class ActionDispatch::IntegrationTest
  teardown do
    clear_login
  end

  def login(shop)
    OmniAuth.config.test_mode = true
    OmniAuth.config.add_mock(:shopify,
      provider: 'shopify',
      uid: shop.shopify_domain,
      credentials: { token: shop.shopify_token },
    )

    Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:shopify]
    Rails.application.env_config['omniauth.params'] = { shop: shop.shopify_domain }
    Rails.application.env_config['jwt.shopify_domain'] = shop.shopify_domain

    post "/auth/shopify"
    follow_redirect!
  end

  def clear_login
    Rails.application.env_config.delete('omniauth.auth')
    Rails.application.env_config.delete('omniauth.params')
    Rails.application.env_config.delete('jwt.shopify_domain')
  end
end

For system tests:

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  # ...

  teardown do
    clear_login
  end

  def login(shop)
    OmniAuth.config.test_mode = true
    OmniAuth.config.add_mock(:shopify,
      provider: 'shopify',
      uid: shop.shopify_domain,
      credentials: { token: shop.shopify_token },
    )
    OmniAuth.config.allowed_request_methods = %i[post get]

    Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:shopify]
    Rails.application.env_config['omniauth.params'] = { shop: shop.shopify_domain }
    Rails.application.env_config['jwt.shopify_domain'] = shop.shopify_domain

    visit "/auth/shopify"
  end

  def clear_login
    Rails.application.env_config.delete('omniauth.auth')
    Rails.application.env_config.delete('omniauth.params')
    Rails.application.env_config.delete('jwt.shopify_domain')
  end
end

UPD Feb 9, 2023:

I’ve updated the code to support ShopifyAPI 10+:

class ActionDispatch::IntegrationTest
  # ...

  def login(shop)
    stubbed_session = ShopifyAPI::Auth::Session.new(
      shop: shop.shopify_domain,
      access_token: shop.shopify_token,
      is_online: true,
      scope: ShopifyApp.configuration.scope
    )

    ShopifyAPI::Utils::SessionUtils.stubs(:current_session_id).returns("session_id")
    ShopifyApp::SessionRepository.stubs(:load_session).returns(stubbed_session)
  end

  def clear_login
    ShopifyAPI::Utils::SessionUtils.stubs(:current_session_id)
    ShopifyApp::SessionRepository.stubs(:load_session)
  end
end
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  # ...

  def login(shop)
    stubbed_session = ShopifyAPI::Auth::Session.new(
      shop: shop.shopify_domain,
      access_token: shop.shopify_token,
      is_online: true,
      scope: ShopifyApp.configuration.scope
    )

    ShopifyAPI::Utils::SessionUtils.stubs(:current_session_id).returns("session_id")
    ShopifyApp::SessionRepository.stubs(:load_session).returns(stubbed_session)
  end

  def clear_login
    ShopifyAPI::Utils::SessionUtils.stubs(:current_session_id)
    ShopifyApp::SessionRepository.stubs(:load_session)
  end
end

Examples

Now we can finally test something.

Sample controller test:

require "test_helper"

class HomeControllerTest < ActionDispatch::IntegrationTest
  setup do
    @shop = shops(:regular)
  end

  test "redirects unauthenticated requests to login" do
    get home_url
    assert_redirected_to %r{/login}
  end

  test "redirects authenticated requests to tasks" do
    login(@shop)

    get home_url
    assert_redirected_to tasks_path
  end
end

Sample system test:

require "application_system_test_case"

class GroupsTest < ApplicationSystemTestCase
  setup do
    @shop = shops(:regular)
    login(@shop)
  end

  test "empty groups list" do
    @shop.groups.delete_all

    visit groups_url

    assert_selector ".Polaris-Page-Header" do
      assert_selector "h1", text: "Groups"
      assert_selector ".Polaris-Button", text: "Create group"
    end

    assert_selector ".Polaris-EmptyState" do
      # ...
    end
  end
end

Limitations

Using this approach you can’t use system tests for features that relies on AppBridge. Things like ResourcePicker, Modal, Toast won’t work. I’m not sure that there’s any solution. You have to be creative with that limitation. In some cases you could generate data before hand to avoid ResourcePicker. And Toast can be tested as plain Rails flash messages.

The full example of this setup you can find in shopify-hotwire-sample repo. If you like reading about Shopify app development follow me on Twitter.

Subscribe to get new blog posts via email

Or grab the RSS feed