Ruby: How to Run a Rack app in a Background Thread
This post is going to hit with a very niche audience. Might just be me and like one other person, but I'm going to write it up anyway. So here we go...
Most of the software I've worked on needs to make HTTP requests. There are a variety of HTTP testing tools in Ruby. I typically reach for webmock, but I've used others (vcr).
But sometimes I really want to check things the whole way through.
My First Time
The first time I wanted this was for the flipper api and http adapter.
Sure, I could use rack-test for the API responses and webmock for the HTTP adapter requests. But they both live in the same project. Why not use them to test each other?
Good question. Glad you're paying attention.
The Full Spec
Let's start with the 🦜's 👁 view.
I'm going to drop a big chunk of code next. But don't feel overwhelmed.
Once you get through this here gibberish, I'll pick it apart and do my best to explain it.
require 'flipper/adapters/http'
require 'flipper/adapters/pstore'
require 'rack/handler/webrick'
FLIPPER_SPEC_API_PORT = ENV.fetch('FLIPPER_SPEC_API_PORT', 9001).to_i
RSpec.describe Flipper::Adapters::Http do
context 'adapter' do
subject do
described_class.new(url: "http://localhost:#{FLIPPER_SPEC_API_PORT}")
end
before :all do
dir = FlipperRoot.join('tmp').tap(&:mkpath)
log_path = dir.join('flipper_adapters_http_spec.log')
@pstore_file = dir.join('flipper.pstore')
@pstore_file.unlink if @pstore_file.exist?
api_adapter = Flipper::Adapters::PStore.new(@pstore_file)
flipper_api = Flipper.new(api_adapter)
app = Flipper::Api.app(flipper_api)
server_options = {
Port: FLIPPER_SPEC_API_PORT,
StartCallback: -> { @started = true },
Logger: WEBrick::Log.new(log_path.to_s, WEBrick::Log::INFO),
AccessLog: [
[log_path.open('w'), WEBrick::AccessLog::COMBINED_LOG_FORMAT],
],
}
@server = WEBrick::HTTPServer.new(server_options)
@server.mount '/', Rack::Handler::WEBrick, app
Thread.new { @server.start }
Timeout.timeout(1) { :wait until @started }
end
after :all do
@server.shutdown if @server
end
before(:each) do
@pstore_file.unlink if @pstore_file.exist?
end
it_should_behave_like 'a flipper adapter'
end
end
Got it? Good. Oh... maybe not? Let's break it down.
The Break Down
The beginning is pretty standard. I require some files, setup a default port for the server to bind to and create an RSpec describe block. Let's skip past that to the first bit that is interesting: the before(:all)
block.
The Setup
This tells rspec to start the server once for the suite (instead of before each spec). Doing so probably saves a wee bit of time, but I've also done it before each test in other projects.
dir = FlipperRoot.join('tmp').tap(&:mkpath)
log_path = dir.join('flipper_adapters_http_spec.log')
@pstore_file = dir.join('flipper.pstore')
@pstore_file.unlink if @pstore_file.exist?
api_adapter = Flipper::Adapters::PStore.new(@pstore_file)
flipper_api = Flipper.new(api_adapter)
app = Flipper::Api.app(flipper_api)
Inside that block, I setup a Flipper PStore adapter. The reason I chose PStore is that it's thread safe, so it can be used in the main thread (RSpec) and the background thread (API HTTP server).
If I had an in-memory thread safe adapter, I'd have used that. But I didn't, so let's move on.
The Server Instance
server_options = {
Port: FLIPPER_SPEC_API_PORT,
StartCallback: -> { @started = true },
Logger: WEBrick::Log.new(log_path.to_s, WEBrick::Log::INFO),
AccessLog: [
[log_path.open('w'), WEBrick::AccessLog::COMBINED_LOG_FORMAT],
],
}
@server = WEBrick::HTTPServer.new(server_options)
This chunk initializes the server instance. It sets the port for the server to listen on, configures a start callback to set an instance variable, and sends logs to somewhere that isn't STDOUT.
Mount the App
@server.mount '/', Rack::Handler::WEBrick, app
Now that we have a server instance, this tells the server to mount the Flipper API rack app. This is where you'd change it to whatever Rack app you'd like to run if you're trying this at home.
The Thread
Thread.new { @server.start }
Finally, we reach the key part. I create a new thread and start the server in it. 💥 Are you not entertained? I could have stopped there, but I try to avoid future pain for myself if I can.
Fail Fast
Timeout.timeout(1) { :wait until @started }
Because things can go wrong, I set a timeout for a second to just raise and move on until the webrick startup callback sets the instance variable to started. If for some reason the server doesn't start up and doesn't error, this ensures the spec won't just hang.
Now the server is started and running the Flipper API in a background thread so each spec in the main thread can use the HTTP adapter to hammer it.
The Cleanup
after :all do
@server.shutdown if @server
end
Since I've ran into my fair shair of intermittent test failures, I also add an after(:all)
block to shut the server down. Likely, I should have also stored the thread in an instance variable and killed it here. But I'll leave that as homework for you.
The Specs
Flipper has shared specs you can run your adapters against (and tests for Minitest). By including this:
it_should_behave_like 'a flipper adapter'
...the HTTP adapter (configured as the subject
) runs through a series of specs against the API and verifies the results.
Conclusion
That's it! It looks a bit messy, but by configuring a Webrick server to run your rack app and starting it in a background thread, you can run live, integration tests using both your client and server code. Pretty neat!
If you want a few more examples, head on over to a recent project of mine named brow. I have a fake server I use in the minitest tests and an echo server I use in the examples.