Skip to content

Instantly share code, notes, and snippets.

@zmoazeni
Created July 17, 2018 19:13
Show Gist options
  • Save zmoazeni/e5b6c7ab4c0bef5b33ef4f2d8748c7b7 to your computer and use it in GitHub Desktop.
Save zmoazeni/e5b6c7ab4c0bef5b33ef4f2d8748c7b7 to your computer and use it in GitHub Desktop.
TestLock Concept

The main concept of a Test Lock is basically a mutex between the thread running the tests and the thread handling the server requests.

Our main issue had to do with our teardown destroying database records while a request was ongoing. It's extremely tricky to debug because it's entirely timing based. (Request begins, before_actions are happy, teardown begins, records are destroyed, request sometimes freaks out).

So the idea is that once we TestLock.join, we flip a gate that disallows any new requests, and blocks until the existing request is complete. Then we know we can safely purge the database (and other elements).

# In the test.rb environment file:
config.middleware.insert 0, TestLock
# In integration test helper:
# The integration tests need to make sure the lock is rest to start with
setup do
TestLock.reset
# ...
end
# And the first thing our tests do is lock incoming requests and block until existing ones are done
teardown do
TestLock.join(name_and_location)
# ...
end
# We sometimes use this to put in explicit waits in our tests without having `sleep 3` trash.
# It will asert that an ajax request came in and left. The implementation isn't wonderful but it's very useful
def assert_ajax(url, params_contains: nil, method: :get, successful: true)
timeout(Capybara.default_max_wait_time) do
loop do
request = TestLock.find_async_request(url, params_contains: params_contains, method: method, successful: successful)
if request
request.found!
break
else
sleep 0.1
end
end
end
rescue TimeoutError
output = "Expected #{method} #{url}#{params_contains ? " with query params #{params_contains}" : ''} to complete!\n"
async_requests = TestLock.async_requests.reject(&:found?)
if async_requests.present?
output << "Completed Async Requests:\n"
async_requests.each do |async_request|
output << "- #{Rack::Utils.unescape(async_request.method_and_path)}\n"
end
else
output << "No requests pending"
end
fail(output)
end
class TestLock
cattr_accessor :requesting
self.requesting = false
cattr_accessor :async_requests
self.async_requests = []
cattr_accessor :locked
self.locked = false
def initialize(app)
@app = app
end
def call(env)
request = AuditedRequest.new(env)
if name_and_location = locked
unless request.ignorable_asset?
message = "Ignoring request at the end of #{name_and_location} because we are tearing down the test. The ignored request was: #{request.method_and_path}"
Rails.logger.tagged('TEST_LOCK') { Rails.logger.debug(message) }
end
[409, {}, [message]]
else
begin
self.requesting = request
ActiveRecord::Base.connection.with do
response = @app.call(env)
request.rack_response = response
response
end
ensure
async_requests << request if request.should_track?
self.requesting = false
end
end
end
class << self
def reset
self.requesting = false
self.locked = false
self.async_requests = []
end
def join(name_and_location)
self.locked = name_and_location
reported = false
Timeout.timeout(15) do
loop do
if request = requesting
unless reported
Rails.logger.tagged('TEST_LOCK') { Rails.logger.debug("Blocking at the end of #{name_and_location}. The blocked request was: #{request.method_and_path}") }
reported = true
end
sleep(1)
else
break
end
end
end
Rails.logger.tagged('TEST_LOCK') { Rails.logger.debug('TestLock is finished') }
end
def find_async_request(*args)
async_requests.detect do |async_request|
async_request.matches?(*args)
end
end
end
class AuditedRequest
attr_reader :rails_request, :rails_response
delegate :url, :request_method, to: :rails_request
def initialize(rack_env)
@rails_request = ActionDispatch::Request.new(rack_env)
@found = false
end
def rack_response=(rack_response)
@rails_response = ActionDispatch::Response.new(*rack_response)
end
def should_track?
@rails_request.xhr? || @rails_request.format.json?
end
def matches?(url, params_contains:, method:, successful:)
return if found?
return false unless successful == response_successful?
return false unless Rack::Utils.unescape(@rails_request.url).downcase.include?(Rack::Utils.unescape(url).downcase)
return false unless @rails_request.request_method == method.to_s.upcase
return false unless !params_contains || querystring_contains?(params_contains)
true
end
def found!
@found = true
end
def found?
!!@found
end
def method_and_path
"#{@rails_request.request_method} #{@rails_request.fullpath} [status: #{@rails_response.try(:status)}]"
end
def response_successful?
if @rails_response
200 <= @rails_response.status && @rails_response.status < 300
end
end
def ignorable_asset?
@rails_request.path.include?("/assets/")
end
private
def querystring_contains?(querystring_hash)
page_querystring = Rack::Utils.parse_query(URI.parse(@rails_request.url).query)
querystring_hash.all? { |key, value| page_querystring[key.to_s] == value.to_s }
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment