Many developer job hiring processes include some sort of take-home exercise.
In Ruby it will usually be one of three things:
- A small Rails app
- A gem
- A script
A rails app can be initialized via the rails new
command as well as through some of the numerous rails application templates that can be found on the web.
If you are doing a gem, bundle gem <NAME>
command has you covered.
However, both of these options are not optimal for exercises, a Rails app has a lot of files, many of them generated, making the correction/review of the exercise really arduous.
A gem on the other hand, without a context to be used in, loses part of its utility, and won't be pushed to (or installed from) Rubygems.
So a script is the best option from the reviewer POV, but also the most manual if you are the person doing the exercise.
Both Rails and Bundler offer some taken decisions for you so you can run with the defaults and change only what you need, but a script scenario leaves you with the proverbial blank canvas.
As a reviewer, I prefer giving out the script kind of problem because it is what tells me the most about the candidates, including how do they make those choices usually provided by the framework or tooling.
When I am a candidate, on the other hand, I know those initial steps offer low "bang for the buck". You can do the right or bad but you are not probably going to impress any reviewer with them.
They also detract from the limited time you have to complete the challenge.
To work around this, I have a template folder for ruby-script projects, which can be used as-is or with small modifications depending on your needs and preferences.
Gems
First thing I include is a Gemfile
, which is the configuration file for bundler, the tool that manages library dependencies in Ruby.
# Gemfile
# frozen_string_literal: true
source 'https://rubygems.org'
group :development do
gem 'minitest'
gem 'rake'
gem 'rubocop'
gem 'simplecov'
gem 'solargraph'
end
You can generate your Gemfile.lock
with bundle
. I do it for convenience reasons related to my editor integration, but if you can't or don't want to, there are instructions below to bundle within a Docker container.
Docker
Your script will have to run on a different machine, and managing dependencies and language environment without knowing the target machine in advance can be challenging. Docker is almost a de facto standard nowadays and it is not far fetched to assume it will be available.
# Dockerfile
FROM ruby:3.2
# install necessary packages and jemalloc
RUN apt-get update && \
apt-get install libjemalloc2 && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Set enviroment
ENV LD_PPRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc2.so.2
WORKDIR /usr/src/app
COPY Gemfile Gemfile.lock ./
RUN bundle install --jobs=4
COPY . .
here you have an explanation on the jemalloc part. If you don't want or don't need it, just omit 2nd and 3rd instructions on the Dockerfile.
You can build this image with
docker build . -t my_ruby
Use whatever you want instead of 'my_ruby' it is a custom tag to identify your image.
And run it with
docker run -it my_ruby
By default it runs irb
Helper scripts
When you are developing it is very common to add dependencies, or change some aspects of your images. To avoid repeating long commands, you can set aliases on your system, but those require their own configuration. I prefer to include a bin
folder on the project with some helper scripts.
You can create a bin/build
file with the first command, then run chmod +x bin/build
to make it executable and then you can just call bin/build
as many times as you need.
# bin/build
docker build . -t my_ruby
For the second, you can create a bin/run
equivalent like so
# bin/run
# Run arbitrary commands (defaults to 'bash') within the container.
docker run -it my_ruby "${@:-bash}"
The $@
in bash gives you all the remaining arguments i.e:
$ bin/run ruby -e'puts "hello"'
hello
Here ruby -e'puts "hello"'
replaces the default command bash
If your project does not require any additional service, like a database, this is enough, if it does, keep reading to see how to use docker-compose command in the bin scripts instead.
Docker compose
Docker compose is a tool that allows you to declare more that one docker image/containers to be spun up at once and depend on one another. One common use case of this is to use containerized databases (i.e: Postgres, Mysql) or cache-stores (i.e Redis, Memcached)
Here you can see the template file I have with the parts to set up a Postgres db present but commented out.
# docker-compose.yml
version: '3'
# volumes:
# db-data:
services:
app:
build: .
environment:
RAKE_ENV: test
# POSTGRES_USER: username # The PostgreSQL user (useful to connect to the database)
# POSTGRES_PASSWORD: password # The PostgreSQL password (useful to connect to the database)
command: ['irb']
volumes:
- '.:/usr/src/app'
# depends_on:
# - database
# database:
# restart: always
# image: 'postgres:latest'
# ports:
# - 5432:5432
# environment:
# POSTGRES_USER: username # The PostgreSQL user (useful to connect to the database)
# POSTGRES_PASSWORD: password # The PostgreSQL password (useful to connect to the database)
# POSTGRES_DB: development # The PostgreSQL default database (automatically created at first launch)
# volumes:
# - 'db-data:/var/lib/postgresql/data/:rw'
With this config file, the command to build your main docker image (the Ruby one), and thus the content of your bin/build
script become:
# bin/build
docker-compose build
This will tag your image based on the folder name unless you set up a custom tag on the config file.
Comparatively, your bin/run
script would become:
docker-compose run --rm -e RAKE_ENV=development app ruby -Ilib lib/cli.rb
There is a lot going on here so I will go step by step.
docker-compose run <OPTIONS> <SERVICE> <COMMAND>
Options:
-
--rm
to remove the container after the command (saves disk space) -
-e RAKE_ENV=development
sets enviroment variables in the container
Service:
That app
is no special sttring there, it is whatever you call your main service on the docker-compose.yml
file.
Remember that
# docker-compose.yml (excerpt)
services:
app: # <----------------------
build: .
If you write 'my_app' on that file, you need 'my_app' on the run
command.
Command:
That ruby -Ilib lib/cli.rb
is whatever command you want to run. It overrides the command
value from the docker-compose.yml file, which overrides the CMD value from the Docker image (we did not set a custom one, but Ruby image we used as a base sets irb
)
If it looks like I am using a lot of options here, it is in part to reinforce the convenience of having all this complexity comfortably abstracted under bin/run
, but I can assure you all these are real options I have used on a project.
I have not explained yet what that lib/cli.rb
is yet, because I want to speak about the Ruby parts.
A Ruby script
Here are some choices I take:
- We will use Zeitwerk to load constants.- The script with have a Command Line Interface (cli) that will potentially accept options via Optparse.
- We will have linting/static analisys with Rubocop
- We will have tests written with Minitest
- We will measure test coverage with Simplecov
Zeitwerk
If you are use to work with Rails, you probably have never worried about requiring files in Ruby, because the framework takes care of it for us.
Even before Zeitwerk, Rails had a complex constant autoload system.
Thanks to Zeitwerk any gem can benefit from a similar system and Rails has adopted it as it has some advantages over the original system.
However Zeitwerk does not directly support scripts, so we require the setup class see https://github.com/fxn/zeitwerk/issues/38#issuecomment-484747340 for a detailed explanation.
# lib/setup.rb
require 'zeitwerk'
# run any preparation needed
module Setup
extend self
def run
$stdout.sync = true # instant console output
zeitwerk_setup # load constants with zeitwerk
end
def zeitwerk_setup
loader = Zeitwerk::Loader.new
loader.push_dir(File.absolute_path(__dir__))
loader.setup # ready!
end
end
Setup.run
And then on your main file (cli.rb
in my case) do require 'setup'
Options
Options can vary a lot from one project to another, so I have a skeleton file like this:
require 'optparse'
class Cli
# option parser for the Command line Interface
class Options
include Singleton
attr_reader :options
def parse
@options = {}
PARSER.parse!
end
PARSER = OptionParser.new do |opts|
opts.banner = 'Usage: cli.rb [options]'
opts.on('-v', '--[no-]verbose', 'Run verbosely') do |v|
@options[:verbose] = v
end
end
end
end
And I refer to Optparse's help for more.
CLI
I use a cli.rb
file to load dependencies, parse options and call the custom logic as needed.
Much of that is undefined at the beginning, so my barebones cli.rb
file is something like this:
# frozen_string_literal: true
# Zeitwerk does not directly support scripts, so we require the setup class
# see https://github.com/fxn/zeitwerk/issues/38#issuecomment-484747340
require 'setup'
# Command line interface
module Cli
extend self
def run
Options.parse # Options.instance.options keeps state
end
end
Cli.run if $PROGRAM_NAME == __FILE__
Tests
I use a quite default rake
configuration to run tests:
# Rakefile
# frozen_string_literal: true
require 'rake/testtask'
Rake::TestTask.new(:test) do |t|
t.libs = %w[lib test]
t.pattern = 'test/**/*_test.rb'
end
task default: :test
This allows you to call rake
within the Ruby container to run all tests.
To run the tests from outside the Docker images you can set up another bin script:
# bin/test
docker-compose run --rm -e RAKE_ENV=test app rake test
To have minitest config on a single place, I create a test/test_helper.rb
file:
# test/test_helper.rb
# frozen_string_literal: true
require 'minitest/autorun'
require 'minitest/pride'
require 'debug'
module Minitest
class Test
# include TestHelperClasses
end
end
Coverage
To add coverage measurements, make sure you have 'simplecov' con your Gemfile
and modify the test/test_helper.rb
file to start like this:
# test/test_helper.rb
# frozen_string_literal: true
require 'simplecov'
SimpleCov.start do
enable_coverage :branch
end
# ...
When you run your test suite, it will create a report on coverage/index.html
This will survive when the container ends because of the docker-compose volume on .:/usr/src/app
, however, it will be owned by root
user (the default user within the Docker container) unless you add ad-hoc configs. You can recursively chown
that folder instead.
Linting
On top of testing, I like to use static analysis tools to help me keep the codestyle consistent, avoid common mistakes, and follow community guidelines.
To use rubocop, you need to have it on your Gemfile.
Unless you adhere to 100% of the tool defaults, you might want to have a configuration file:
# .rubocop.yml
AllCops:
NewCops: enable
TargetRubyVersion: 3.2
Metrics/BlockLength:
AllowedMethods: ['describe', 'context']
Style/ModuleFunction:
EnforcedStyle: extend_self
Style/NumericPredicate:
Enabled: false
To run rubocop confortably, we can once again create a bin script:
# bin/lint
docker-compose run --rm app rubocop
Additional bins
I have still two more convenience bin scripts:
# bin/shell
docker-compose run --rm -e RAKE_ENV=development app bash
and
# bin/console
# Open an irb session with the code loaded in the style of 'rails console'
docker-compose run --rm -e RAKE_ENV=development app irb -Ilib
Summary
Here you have an overview of the files we have reviewed.
.
├── bin
│ ├── build
│ ├── console
│ ├── lint
│ ├── run
│ ├── shell
│ └── test
├── docker-compose.yml
├── Dockerfile
├── Gemfile
├── Gemfile.lock
├── lib
│ ├── cli
│ │ └── options.rb
│ ├── cli.rb
│ └── setup.rb
├── Rakefile
└── test
└── test_helper.rb
5 directories, 15 files
I hope this helps you build your own version of the project template, and that it can save you some precious time the next time you have a hiring process take-home challenge, or you want to start a pet project.
What about you?
- Did you have something similar? something different?
- Do you have a version of this idea for other language?
- Did it work for you?
- Did you make any interesting changes?
Please let me know in the comments.
--
Edited to fix the typos spotted by @raul, thanks pal!
Top comments (1)
Thanks for sharing this.
Bookmarking for later use and will let you know once I use it.