Rodrigo Rosenfeld Rosas
Introducing RackToolkit: a fast server and DSL designed to test Rack apps
I started to experiment with writing big Ruby web applications as a set of smaller and fast Rack applications connected by a router using Roda's multi_run plugin.
Such design allows the application to boot super fast in the development environment (and in the production environment too unless you prefer to eager load your code in production). Here's how the design looks like (I've written about AutoReloader in another article):
1 | # config.ru |
2 | if ENV['RACK_ENV'] == 'development' |
3 | require 'auto_reloader' |
4 | AutoReloader.activate reloadable_paths: [ 'apps', 'lib', 'models' ] |
5 | run ->(env) do |
6 | AutoReloader.reload! do |
7 | ActiveSupport::Dependencies.clear # avoid some issues |
8 | require_relative 'apps/main' |
9 | Apps::Main.call env |
10 | end |
11 | end |
12 | else |
13 | require_relative 'apps/main' |
14 | run Apps::Main |
15 | end |
16 | |
17 | # apps/main.rb |
18 | require 'roda' |
19 | module Apps |
20 | class Main < Roda |
21 | plugin :multi_run |
22 | # other plugins and middlewares are added, such as :error_handler, :not_found, :environments |
23 | # and a logger middleware. They take some space, so I'm skipping them. |
24 | |
25 | def self.register_app(path, &app_block) |
26 | # if you want to eager load files in production you'd change this method a bit |
27 | ->(env) do |
28 | require_relative path |
29 | app_block[].call env |
30 | end |
31 | end |
32 | |
33 | run 'sessions', register_app('session'){ Session } |
34 | run 'admin', register_app('admin') { Admin } |
35 | # other apps |
36 | end |
37 | end |
38 | |
39 | # apps/base.rb |
40 | require 'roda' |
41 | module Apps |
42 | class Base < Roda |
43 | # add common plugins for rendering, CSRF protection, middlewares |
44 | # like ETag, authentication and so on. Most apps would inherit from this. |
45 | route{|r| process r } |
46 | private |
47 | def process(r) |
48 | protect_from_csrf # added by some CSRF plugin |
49 | end |
50 | end |
51 | end |
52 | |
53 | # apps/admin.rb |
54 | require_relative 'base' |
55 | module Apps |
56 | class Admin < Base |
57 | private |
58 | def process(r) |
59 | super # protects from forgery and so on |
60 | r.get('/'){ "TODO Admin interface" } |
61 | # ... |
62 | end |
63 | end |
64 | end |
Then I want to be able to test those applications separately and for some of them I would only get confidence if I tested against a real server since I would want them to handle with cookies or streaming and checking for some HTTP headers injected by the real server and so on. And I wanted to be able to write such tests that could run as quickly as possible.
I started experimenting with Puma and noticed it can start a new server really fast (like 1ms in my development environment). I didn't want to add many dependencies so I decided to create some simple DSL over 'net/http' stdlib since its API is not much friendly. The only dependencies so far are http-cookie and Puma (WEBrick does not support full hijack support and it doesn't provide a simple API to serve Rack apps either and it's much slower to boot). Handling cookies correctly to keep the user session is not trivial so I decided to introduce the http-cookie dependency to manage a cookie jar.
That's how rack_toolkit was born.
Usage
This way I can start the server before the test suite starts, change the Rack app served by the server dynamically, and stop it when the suite finishes (or you can simply start and stop it for each example since it boots really fast). Here's a spec_helper.rb you could use if you are using RSpec:
1 | # spec/spec_helper.rb |
2 | require 'rack_toolkit' |
3 | RSpec.configure do |c| |
4 | c.add_setting :server |
5 | c.add_setting :skip_reset_before_example |
6 | |
7 | c.before(:suite) do |
8 | c.server = RackToolkit::Server.new start: true |
9 | c.skip_reset_before_example = false |
10 | end |
11 | |
12 | c.after(:suite) do |
13 | c.server.stop |
14 | end |
15 | |
16 | c.before(:context){ @server = c.server } |
17 | c.before(:example) do |
18 | @server = c.server |
19 | @server.reset_session! unless c.skip_reset_before_example |
20 | end |
21 | end |
Testing the Admin app should be easy now:
1 | # spec/apps/admin_spec.rb |
2 | require_relative '../../apps/admin' |
3 | RSpec.describe Admin do |
4 | before(:all){ @server.app = Admin } |
5 | it 'shows an expected main page' do |
6 | @server.get '/' |
7 | expect(@server.last_response.body).to eq 'TODO Admin interface' |
8 | end |
9 | end |
Please take a look at the project's README for more examples and supported API. RackToolkit allows you to get the current_path, referer, manages cookies sessions, provides a DSL for get, post and post_data on top of 'net/http' from stdlib, allows overriding the environment variables sent to the Rack app, simulating an https request as if the app was behind some proxy like Nginx, supports "virtual hosts", default domain, performing requests to external Internet urls and many other options.
Future development
It currently doesn't provide a DSL for quickly access elements from the response body, filling in forms and submitting them, but I plan to work on this once I need it. It won't ever support JavaScript though unless it would be possible at some point to do so without slowing it down significantly. If you want to work on such DSL, please let me know.
Performance
The test suite currently runs 33 requests and finishes in ~50ms (skipping the external request example). It's that fast.
Feedback
Looking forward your suggestions to improve it. Your feedback is very welcomed.