A take-home exercise scaffold for ruby
Tags: job, ruby, exercise
Published: 2023-09-23 21:12:48

([cover](https://unsplash.com/es/fotos/WN9QRESOu5c) by [@reeto](https://unsplash.com/es/@reetoo))

---

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](https://guides.rubyonrails.org/rails_application_templates.html) that can be found on the web.

If you are doing a gem, `bundle gem <NAME>` [command](https://bundler.io/guides/creating_gem.html) 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](https://bundler.io/guides/getting_started.html), the tool that manages library dependencies in Ruby.

```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

# 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

# Throw errors if Gemfile has been modified since Gemfile.lock
# omit this if don't bundle outside docker
RUN bundle config --global frozen 1

WORKDIR /usr/src/app

COPY Gemfile Gemfile.lock ./

RUN bundle install --jobs=4

COPY . .
```
here you have an explanation on [the jemalloc part](https://www.dsdev.in/blog/jemalloc-rails-memory). 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 
```bash
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 
```bash
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.
```bash
# bin/build
docker build . -t my_ruby
```

For the second, you can create a `bin/run` equivalent like so
```bash
# bin/run
docker run -it my_ruby $@
```
The `$@` in bash gives you all the remaining arguments i.e:
```bash
$ bin/run ruby -e'puts "hello"'
hola
```
Here `ruby -e'puts "hello"'` replaces the default command `irb`

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](https://docs.docker.com/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](https://www.postgresql.org/), [Mysql](https://www.mysql.com/)) or cache-stores (i.e [Redis](https://redis.io/), [Memcached](https://www.memcached.org/))

Here you can see the template file I have with the parts to set up a Postgres db present but commented out.

```yaml
# 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:
```bash
# 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:

```bash
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
```yaml
# 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](https://github.com/fxn/zeitwerk#introduction) to load constants.- The script with have a Command Line Interface (cli) that will potentially accept options via [Optparse](https://ruby-doc.org/3.2.2/stdlibs/optparse/OptionParser.html).
- We will have linting/static analisys with [Rubocop](https://rubocop.org/)
- We will have tests written with [Minitest](https://github.com/minitest/minitest)
- We will measure test coverage with [Simplecov](https://github.com/simplecov-ruby/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.

```ruby
# 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:

```ruby
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](https://ruby-doc.org/3.2.2/stdlibs/optparse/OptionParser.html)'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:

```ruby
# 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:
```ruby
# 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:

```bash
# 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:

```ruby
# 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:

```ruby
# 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:

```yml
# .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:

```bash
# bin/lint
docker-compose run --rm app rubocop
```
## Additional bins

I have still two more convenience bin scripts:
```bash
# bin/shell
docker-compose run --rm -e RAKE_ENV=development app bash 
```
and
```bash
# 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.

```bash
.
├── 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](https://ruby.social/@raul/111119446885324099), thanks pal!