Ruby: How to Run a Rack app in a Background Thread
3 min read

Ruby: How to Run a Rack app in a Background Thread

Stubbing and mocking are fine, but sometimes you want to test full integration. This will help you get your main thread http client talking to your rack app in another 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.

If you enjoyed this post,
you should subscribe for more.