DEV Community

Cover image for A take-home exercise scaffold for ruby
Oinak
Oinak

Posted on • Updated on

A take-home exercise scaffold for ruby

(cover by @reeto)


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
Enter fullscreen mode Exit fullscreen mode

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 . .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode

The $@ in bash gives you all the remaining arguments i.e:

$ bin/run ruby -e'puts "hello"'
hello
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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__
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
# ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

To run rubocop confortably, we can once again create a bin script:

# bin/lint
docker-compose run --rm app rubocop
Enter fullscreen mode Exit fullscreen mode

Additional bins

I have still two more convenience bin scripts:

# bin/shell
docker-compose run --rm -e RAKE_ENV=development app bash 
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
leni1 profile image
Leni Kadali Mutungi

Thanks for sharing this.

Bookmarking for later use and will let you know once I use it.