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.