20 April 2021
Last year, I read the amazing 99 Bottles of OOP by Sandi Metz, Katrina Owen & TJ Stankus and decided to create a tool to help me refactor code based on the method described in the book. I work in a consultancy and get to touch multiple codebases regularly. I wanted a tool that would allow me to refactor code on any ruby projects with no setup. Retest was born.
Retest promise
A simple CLI to watch file changes and run their matching ruby specs. Works on any ruby projects with no setup.
CI of retest v1.0.0
For some time I relied only on unit tests and manual testing of different ruby setups like Rails, Ruby ad-hoc, Hanami. This was becoming difficult as each setup can be paired with Minitest or RSpec.
E2E Testing retest is an interesting challenge. I need to run tests locally and on GitHub actions for a specific git branch. The latest state of the gem must be built and tested on multiple ruby setups. For each ruby setup, I need to test whether the gem:
Solution: GitHub strategies paired with minimal Docker repositories.
I have a love/hate relationship with Docker. We use it extensively at work. I understand its benefits and why people use it but most often than not Docker is slow and a frustrating experience. Unless you have an image laying around, you know you're up for a treat when a Docker app that hasn't been touched for a year needs an issue fixed. Fixing Docker often takes longer than fixing the issue itself…
However, I recently used Docker to test retest on different Ruby environments. Docker allows me to spin different ruby apps in a container with retest installed.
Currently, Retest is being tested on:
setup / test suite | RSpec | Minitest |
---|---|---|
rails | check | check |
hanami | - | check |
ruby progam | check | check |
Bonus: I also test git commands on a git-ruby docker container for the –diff feature
Check out the gem, those setups live in the features
folder. All feature specs follow the same structure.
I use a strategy to dynamically spin 6 jobs (one per ruby app) and call its corresponding test command.
app-tests:
name: ${{ matrix.repo }} feature specs
runs-on: ubuntu-latest
strategy:
matrix:
repo:
- ruby-app
- rails-app
- hanami-app
- rspec-rails
- rspec-ruby
- git-ruby
steps:
- uses: actions/checkout@v2
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 2.5
bundler-cache: true
- run: bin/test/${{ matrix.repo }}
A setup can be tested on GitHub actions and locally via a dedicated bin/test
command. In this example, we run the bin/test/rails-app
for the rails app using minitest.
#!/usr/bin/env bash
# Build the current state of the gem
bundle install
bundle exec rake build
# Move the .gem file to /features/rails-app folder and rename it retest.gem
ls -t pkg | head -n1 | xargs -I {} mv pkg/{} features/rails-app/retest.gem
# Build features/rails-app/docker-compose.yml and return the results of the tests
docker-compose -f features/rails-app/docker-compose.yml up --build --exit-code-from retest
The docker file fits the setup tested, in this case, a rails app without webpack :) One thing to note is that retest is also installed with RUN gem install retest.gem
# features/rails-app/Dockerfile
FROM ruby:2.4.1-alpine
ARG BUILD_PACKAGES="build-base git nodejs tzdata sqlite-dev"
RUN apk update && \
apk upgrade && \
apk add --update --no-cache $BUILD_PACKAGES && \
rm -rf /var/cache/apk/*
WORKDIR /usr/src/app
ENV LANG C.UTF-8
ENV BUNDLER_VERSION 2.1
COPY Gemfile Gemfile.lock retest.gem ./
RUN gem install bundler -v 2.1.4
RUN bundle install
RUN gem install retest.gem
COPY . /usr/src/app
CMD ["bin/setup"]
# features/rails-app/docker-compose.yml
version: '3'
services:
retest:
build: .
volumes:
- .:/usr/src/app
command: ruby retest/retest_test.rb
Each app has a retest/retest_test.rb
file which is a test suite tailored for the setup under test. Here are some examples of tests ussed.
require_relative 'test_helper'
require 'minitest/autorun'
$stdout.sync = true
include FileHelper
class MatchingTestsCommandTest < Minitest::Test
def teardown
end_retest @output, @pid
end
def test_start_retest
@output, @pid = launch_retest 'retest --rails'
assert_match <<~EXPECTED, @output.read
Launching Retest...
Ready to refactor! You can make file changes now
EXPECTED
end
def test_modify_a_file
@output, @pid = launch_retest 'retest --rails'
modify_file 'app/models/post.rb'
assert_match "Test File Selected: test/models/post_test.rb", @output.read
assert_match "1 runs, 1 assertions, 0 failures, 0 errors, 0 skips", @output.read
end
end
Because retest needs a separate window to display test results as people change files, I spawn a process in the container that runs retest and write into a log file. I spawn a retest process per test.
def launch_retest(command)
file = OutputFile.new
pid = Process.spawn command, out: file.path
sleep 1.5
[file, pid]
end
def end_retest(file, pid)
file&.delete
if pid
Process.kill('SIGHUP', pid)
Process.detach(pid)
end
end
Each repository has a group of helper methods to imitate the creation, update and deletion of a file in the repository under test (and trigger retest).
Each of those helper methods is implementing a different sleeping time based on the repository type. A rails app will take longer to run a test than a ruby program that is why the sleeping time is 10 seconds for a rails app but 1 second on a ruby program.
module FileHelper
def modify_file(path)
return unless File.exist? path
old_content = File.read(path)
File.open(path, 'w') { |file| file.write old_content }
sleep 10
end
def create_file(path, should_sleep: true)
File.open(path, "w").tap(&:close)
sleep 10 if should_sleep
end
def delete_file(path)
return unless File.exist? path
File.delete path
end
end
Overall CI runs in less than three minutes as each docker job is run in parallel and unit tests are run in less than 30 seconds.