In the previous post, I described my setup for Rails development with Docker and Kubernetes, using Telepresence so that my local containers appear as if they were part of the Kubernetes cluster. In this post, I will show how I now use separate Selenium containers to run Capybara/system tests, instead of Chrome and chromedriver installed in the app’s own Docker image. I have had a few problems with setting up Chrome/chromedriver and the relevant Capybara configuration correctly, so I found that using an external, ready Selenium setup instead can actually simplify things a lot.

Besides using Selenium containers to run tests, since my last post I also switched from Alpine to the Debian-based Ruby image for my app. This is because I had problems using Alpine with Rails 6 and it was just easier to use the Debian version in order to save some headaches with incompatibility issues due to the use of musl instead of glibc, although this means that the final Docker image is of course somewhat bigger.

This is what my current Dockerfile looks like:

ARG RUBY_VERSION=2.6.4 FROM ruby:$RUBY_VERSION-slim AS development RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends curl wget RUN curl -sL https://deb.nodesource.com/setup_11.x | bash - RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ && echo 'deb http://dl.yarnpkg.com/debian/ stable main' > /etc/apt/sources.list.d/yarn.list RUN apt-get update -qq \ && DEBIAN_FRONTEND=noninteractive apt-get -yq dist-upgrade \ && DEBIAN_FRONTEND=noninteractive apt-get remove -yq cmdtest \ && DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ sudo \ build-essential \ default-mysql-client default-libmysqlclient-dev \ nodejs yarn \ git-core \ imagemagick \ tzdata \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \ && truncate -s 0 /var/log/*log ENV RAILS_ENV=development ENV RACK_ENV=development ENV RAILS_LOG_TO_STDOUT=true ENV RAILS_ROOT=/home/rails/app ENV LANG=C.UTF-8 ENV GEM_HOME=/home/rails/bundle ENV BUNDLE_PATH=$GEM_HOME ENV BUNDLE_APP_CONFIG=$BUNDLE_PATH ENV BUNDLE_BIN=$BUNDLE_PATH/bin ENV PATH=$RAILS_ROOT/bin:$BUNDLE_BIN:$PATH RUN gem update --system RUN useradd --create-home -s /bin/bash rails \ && echo "rails ALL=(ALL:ALL) NOPASSWD: ALL" | tee -a /etc/sudoers WORKDIR $RAILS_ROOT RUN mkdir -p $RAILS_ROOT/tmp/cache \ && mkdir -p $RAILS_ROOT/node_modules \ && mkdir -p $RAILS_ROOT/public/packs \ && mkdir -p $BUNDLE_PATH \ && chown -R rails:rails /home/rails USER rails COPY --chown=rails:rails Gemfile Gemfile.lock ./ RUN gem install bundler \ && bundle install -j "$(getconf _NPROCESSORS_ONLN)" \ && rm -rf $BUNDLE_PATH/cache/*.gem \ && find $BUNDLE_PATH/gems/ -name "*.c" -delete \ && find $BUNDLE_PATH/gems/ -name "*.o" -delete COPY --chown=rails:rails package.json yarn.lock ./ RUN yarn install COPY --chown=rails:rails . ./ EXPOSE 3000 CMD ["bundle", "exec", "puma", "-Cconfig/puma.rb"] # Production FROM ruby:$RUBY_VERSION-slim AS production RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends curl RUN curl -sL https://deb.nodesource.com/setup_11.x | bash - RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ && echo 'deb http://dl.yarnpkg.com/debian/ stable main' > /etc/apt/sources.list.d/yarn.list RUN apt-get update -qq \ && DEBIAN_FRONTEND=noninteractive apt-get -yq dist-upgrade \ && DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ default-mysql-client default-libmysqlclient-dev \ git-core \ imagemagick \ tzdata \ nodejs yarn \ sudo WORKDIR /app ENV RAILS_ENV=production ENV RACK_ENV=production ENV RAILS_LOG_TO_STDOUT=true ENV RAILS_SERVE_STATIC_FILES=true ENV RAILS_ROOT=/home/rails/app ENV LANG=C.UTF-8 ENV GEM_HOME=/home/rails/bundle ENV BUNDLE_PATH=$GEM_HOME ENV BUNDLE_APP_CONFIG=$BUNDLE_PATH ENV BUNDLE_BIN=$BUNDLE_PATH/bin ENV PATH=/app/bin:$BUNDLE_BIN:$PATH ENV SECRET_KEY_BASE=blah RUN useradd --create-home -s /bin/bash rails \ && echo "rails ALL=(ALL:ALL) NOPASSWD: ALL" | tee -a /etc/sudoers WORKDIR $RAILS_ROOT COPY --from=development --chown=rails:rails $BUNDLE_PATH $BUNDLE_PATH COPY --from=development --chown=rails:rails $RAILS_ROOT ./ RUN RAILS_ENV=production bundle exec rake assets:precompile RUN rm -rf node_modules tmp/* log/* app/assets vendor/assets lib/assets test \ && yarn cache clean RUN mkdir -p $RAILS_ROOT/tmp/cache \ && mkdir -p $RAILS_ROOT/tmp/pids \ && mkdir -p $RAILS_ROOT/node_modules \ && mkdir -p $RAILS_ROOT/public/packs \ && mkdir -p $BUNDLE_PATH \ && chown -R rails:rails /home/rails USER rails EXPOSE 3000 CMD ["bundle", "exec", "puma", "-Cconfig/puma.rb"]

As you can see, it’s more or less the same configuration as with the Alpine version, but the Debian way. The biggest difference is that I am no longer installing Chrome and chromedriver, since like I said I am now using an external Selenium setup for my tests.

While for the development environment I use the integration with Kubernetes thanks to Telepresence, for the test environment I set up everything with local containers so that tests run faster. I use Docker Compose for this, so this is what my docker-compose.yml looks like:

version: '3.7' networks: myapp: volumes: myapp_mysql: myapp_redis: myapp_rails_cache: myapp_bundle: myapp_node_modules: myapp_packs: services: selenium: image: selenium/hub container_name: myapp-selenium ports: - 4444:4444 networks: - myapp environment: GRID_MAX_SESSION: 10 chrome: image: selenium/node-chrome networks: - myapp depends_on: - selenium environment: HUB_HOST: myapp-selenium NODE_MAX_INSTANCES: 5 NODE_MAX_SESSION: 5 mysql: container_name: myapp-mysql image: percona ports: - 3308:3306 env_file: - ~/.secrets/myapp-test.env volumes: - myapp_mysql:/var/lib/mysql networks: - myapp redis: container_name: myapp-redis image: redis volumes: - myapp_redis:/var/lib/redis networks: - myapp app: container_name: myapp-test image: username/myapp-dev env_file: - ~/.secrets/myapp-test.env volumes: - ${PWD}:/home/rails/app:cached - myapp_rails_cache:/home/rails/app/tmp/cache - myapp_bundle:/home/rails/bundle - myapp_node_modules:/home/rails/app/node_modules - myapp_packs:/home/rails/app/public/packs depends_on: - selenium - mysql - redis networks: myapp: aliases: - myapp.com

Like for the development environment, I am using volumes to cache bundle, node_mdules, cache and packs between builds/runs. Note the two services selenium and chrome: the first one is to start a Selenium hub which nodes will connect to; the hub will then load balance sessions between any number of nodes. The second service is for the actual node using the Chrome browser. Also note that I am specifying a domain alias for the app container; the browser in the nodes will connect to this domain to run the tests.

To set up the test environment with Docker Compose, you can run:

docker-compose up -d

This will create a single Chrome node, so if you want to use parallel tests with Rails 6 you’ll need to scale the containers. For example, on my Mac I have 4 cores, so I run:

docker-compose scale chrome=4

This will create an additional 3 nodes, and all the nodes will register with the hub. You can verify by looking at the logs of the selenium container:

docker logs -f myapp-selenium

You should see something like this:

10:08:51.964 INFO [DefaultGridRegistry.add] - Registered a node http://172.28.0.5:5555 10:08:56.565 INFO [DefaultGridRegistry.add] - Registered a node http://172.28.0.9:5555 10:08:56.720 INFO [DefaultGridRegistry.add] - Registered a node http://172.28.0.7:5555 10:08:56.766 INFO [DefaultGridRegistry.add] - Registered a node http://172.28.0.8:5555

Selenium is now ready to accept connections and manage sessions using the available nodes. Please note that with this setup, you can even add remote nodes! This enables you to scale your testing workers with multiple machines, if you wish. The important bit is that you set the HUB_HOST environment variable correctly for the remote nodes.

Next, we need to enable parallel testing in Rails 6. This is super easy and it only requires a line in test/test_helper.rb:

class ActiveSupport::TestCase parallelize(workers: :number_of_processors) ... end

I prefer leaving the workers argument set to automatically use the available cores, but you can override it if you wish.

Finally, open test/application_system_test_case.rb and change the driven_by line as follows:

driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400], options: { url: "http://myapp-selenium:4444/wd/hub" }

The url option is what tells Rails/Capybara that we want to use an external Selenium setup instead of a local Chrome/chromedriver setup.

That’s it! You should now be able to open a shell in the app container:

docker-compose exec app bash

and run system tests as usual:

rails test:system

If all is set up correctly the tests will be performed by the Selenium nodes and will take a lot less time to run compared to a single worker setup.

Please note that for this to work you cannot set a static port with Capybara, because otherwise it will attempt to run multiple worker processes all binding to the same port. So instead of something like:

Capybara.server_port = "3000"

you should have something like:

Capybara.always_include_port = true

and change your config/environments/test.rb as follows:

config.action_mailer.default_url_options = { host: 'myapp.com' }

without specifying the port. myapp.com should be the same as specified for the network alias in the docker-compose.yml file.

I really like the paralle tests feature in Rails 6. Compared to other solutions I have tried in the past it makes it a lot easier to set up parallel testing and it does make test runs a lot quicker. Give it a try, and let me know in the comments if you run into any issues with this setup.