Optimized, Parallelized CircleCI Configuration for ReactOnRails

react on railsJune 21, 2017Dotby Rob Wise

At ShakaCode, our internal app, FriendsAndGuests, tends to be our guinea pig for the bleeding-edge use cases of ReactOnRails. Just getting CI to work was no exception. There are a lot of workarounds and “gotchas,” but this should hopefully save you the pain I went through. We chose to use CircleCI as our CI provider and CodeCov as our code coverage provider. This guide assumes you’ve read through the basic documentation for both.

Parallelization

When your app starts to scale, your test suite is inevitably going to get slower. Your first line of defense should always be to stay judicious with what and how you test (see Martin Fowler’s Test Pyramid). Still, this slowdown is inevitable with growth. CircleCI has a great feature called parallelization that allows you to easily configure your tests to run across multiple containers. Parallelizing your build can greatly increase your speed.

CircleCI let’s you easily add more containers. With the proper setup, it’s like a “instantly make my test suite faster” button!
CircleCI let’s you easily add more containers. With the proper setup, it’s like a “instantly make my test suite faster” button!

If your testing frameworks can be configured to use JUnit reporters (as RSpec, Jest, and ESlint can), CircleCI will read those reports, figure out the median time to test each file, and then properly distribute those tests across the containers. Distributing the files in this way ensures that one container doesn’t end up with only fast unit tests and the other with only slow feature specs. You want the containers to finish their suites around the same time—you’re only as fast as your slowest container.

As a bonus, if any tests fail, they show up in an easy-to-read format right at the top of the page. That means no more searching through endless lines of noise to find your failing test.

Using a JUnit reporter makes for pretty test summaries that show right at the top. No more sifting through hundreds of lines of noise to find your failing test!
Using a JUnit reporter makes for pretty test summaries that show right at the top. No more sifting through hundreds of lines of noise to find your failing test!

If your tool does not have a JUnit reporter but still allows for passing files via the command line in a space-separated format (by the way, scss-lint, slim-lint, and rubocop all let you do this), then you can still use parallelization! The only difference here is that CircleCI won’t be able to tell how long each file takes to be run through the tool, so it falls back to using file size and you may not get as well-calibrated division of the files across the containers.

CircleCI reverts to using file size to split up your tests across containers if it can’t find the corresponding Junit information
CircleCI reverts to using file size to split up your tests across containers if it can’t find the corresponding Junit information

Here’s a list of tools we currently use where we can’t use parallelization:

  • Brakeman
  • flow
  • Bundle-audit

CodeCov Flags

One of the cool new features coming out of CodeCov is the use of flags. They allow you to easily split up the coverage of different test suites.

CodeCov “flags” in action for a backend-only PR
CodeCov “flags” in action for a backend-only PR

I’ve got guys on my team who only do backend and likewise guys who only do frontend, and they don’t care about each others’ code coverage, so why combine the two? Flags allow you to do that.

circle.yml

There’s a lot going on here, make sure you’re up to speed with the basics of how CircleCI works and what different options mean. Also, watch out for those pwd: client lines, that means we’re running all of the commands in the “present working directory” of the client folder.

machine:

  environment:

    RAILS_ENV: test

    RACK_ENV: test

    YARN_VERSION: 0.24.6

    PATH: "${PATH}:${HOME}/.yarn/bin"

  node:

    version: 7.7.3

dependencies:

  pre:

    # Install Yarn

    - |

      if [[ ! -e ~/.yarn/bin/yarn || $(yarn --version) != "${YARN_VERSION}" ]]; then

        echo "Download and install Yarn."

        curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version $YARN_VERSION

      else

        echo "The correct version of Yarn is already installed."

      fi

  cache_directories:

    - "~/.yarn"

    - "~/.cache/yarn"

  override:

    - yarn install --no-progress --no-emoji

    - bundle check --path=vendor/bundle || bundle install --path=vendor/bundle --jobs=4 --retry=3

  post:

    - yarn run build:all:rspec:

        pwd: client

    - mkdir -p $CIRCLE_TEST_REPORTS/jest

    # HACK: junit-reporter fails if file doesn't already exist

    - touch $CIRCLE_TEST_REPORTS/jest/test-results.xml

test:

  override:

    - bundle exec rspec -r rspec_junit_formatter --format progress --format RspecJunitFormatter -o $CIRCLE_TEST_REPORTS/rspec/junit.xml:

        parallel: true

        environment:

          CODECOV_FLAG: backend # tags all Ruby tests

        files:

          - spec/**/*_spec.rb

  post:

    # HACK: for why maxWorkers is needed, see https://github.com/facebook/jest/issues/535#issuecomment-171445481

    - $(yarn bin)/jest --testResultsProcessor jest-junit-reporter --coverage --maxWorkers 3:

        pwd: client

        environment:

          NODE_ENV: test

          TEST_REPORT_PATH: $CIRCLE_TEST_REPORTS/jest # used by jest-junit-reporter

        files:

          - 'app/**/*.spec.js'

          - 'app/**/*.spec.jsx'

    - $(yarn bin)/codecov -p .. --F frontend:

        pwd: client

        parallel: true

    # Other stuff such as rubocop, eslint, flow, scss-lint, brakeman, bundle-audit

general:

  artifacts:

    - tmp/capybara # great for debugging feature test screenshots

    - log/test.log # sometimes needed for hard-to-track bugs

    - tmp/brakeman-report.html # if you're using brakeman

yarn

Since we’re using yarn instead of npm, we need to use override during the dependencies step to avoid installing via npm. That means we now need to manually run bundle check and bundle install as well.

We deviate a bit from the docs regarding yarn because sometimes we want to install newer versions of yarn than what comes with the CircleCI container. We use a simple shell script and cache technique that CircleCI itself used to recommend before they added built-in yarn support. In the post step, we build our webpack bundles ahead of time.

RSpec

Although CircleCI can automatically detect we are using Ruby and configure itself correctly, we have to override this and run RSpec ourselves because we want to specify the custom environment variable CODECOV_FLAG so that CodeCov will know that these are backend tests.

Jest

Next, we run Jest. We’re not using our normal test script from the package.json because we need to do some special things for CI. Jest has a weird issue on CircleCI where it can go over memory if you don’t limit the number of workers. To get around this, we can specify that the maxWorkers is 3. While we certainly could run Jest in parallel, there seems to be some type of strange issue with how the code coverage results get merged together across containers, so opt not to do it here.

Jest JUnit Reporter

In order to get JUnit output (again, this allows for those nice little errors at the top of the page), we need to specify using jest-junit-reporter via the --testResultsProcesser flag. The test results file must be put in a place where CircleCI can find it, however, so we need to use the special TEST_REPORT_PATH that the jest-junit-reporter package will pick up (docs). Unfortunately, there seems to be a bug where if the file does not already exist, the process fails, so we need to put create a blank file at that location first using the touch command.

Jest Coverage

Then, we tell Jest to collect coverage with the --coverage flag. After the tests run, we can use the codecov package to upload the results. We must take care to tell it that the root of the project is actually a folder level up using -p .., otherwise CodeCov will get confused and think that your Rails app folder is the same folder as the client/app folder! While we’re at it, we pass the -F frontend flag to tag our coverage as frontend.

Note: Don’t use --root or --flags as the CLI help recommends; these don’t actually work. Use the short versions I listed above. Also, see comment in the “Jest” section about parallelization needing to be turned off due to coverage report merge problems.

Codecov.yml

coverage:

  status:

    project: off

    patch:

      frontend:

        flags: frontend

      backend:

        flags: backend

flags:

  backend:

    paths:

      - app

      - lib

      - db

  frontend:

    paths:

      - client

Here’s how we set CodeCov to give us separate frontend and backend coverage status (I prefer to only see the patch status so I turned project off). Note that the partial line coverage feature seems to cause “file not found in report” errors in CodeCov when trying to view your files, so I don’t enable it here.

client/package.json

// ...

"devDependencies": {

  // ...

  "codecov": xxx,

  "identity-obj-proxy": xxx,

  "jest": xxx,

  "jest-junit-reporter": xxx,

},

"jest": {

    "collectCoverageFrom": [

      "app/**/*.{js,jsx}",

      "!**/types/**"

    ],

    "coverageReporters": [

      "lcov"

    ],

    "moduleNameMapper": {

      "\\.(css|scss)$": "identity-obj-proxy"

    },

    "resetModules": true,

    "resetMocks": true,

    "roots": [

      "<rootDir>/app/"

    ]

  }

}

Most of this is self-explanatory if you’ve read the documentation. We are using the identity-obj-proxy package to fake our css and Sass files during Jest testing since Webpack usually handles that. You may need to make a file mock for other types of imports. Babel support is automatically included in Jest’s core, so no need to configure anything there.

Note: You may also need to set up any aliases you’ve made with Webpack, but my colleague Alex Fedoseev set us up with babel-plugin-module-resolver and has eliminated our need to do that!

Additionally, the Jest coverage output can get kind of spammy because it spits the entire coverage file to STDOUT, so we override that with coverageReporters so that it uses lcov only (this is the type that’s needed by CodeCov). No more spammy output!

spec/rails_helper.rb

if ENV["CI"]

  require "simplecov"

  require "codecov"

SimpleCov.formatter = SimpleCov::Formatter::Codecov

  SimpleCov.start("rails")

end

unless ENV["CI"]

  ReactOnRails::TestHelper.configure_rspec_to_compile_assets(config)

end

Here we startup SimpleCov with CodeCov as the formatter. In addition, we use ReactOnRails’s test helper in development to make sure we don’t forget to run our tests against stale Webpack bundles. However, we already made sure to build the bundles in our circle.yml config, so we can skip this step when in CI.

Gemfile

# ...

group :test do

  gem "codecov", require: false

  gem "rspec_junit_formatter"

end

Self-explanatory, and that’s it!

bonus: flow

If you use Flow, it can take a while to initialize the server. You can tell CircleCI to start up the server in the background at the beginning of your test suite. Assuming you’ve got enough jobs going on (so that you avoid a race condition)your server finishes starting up, then when you start your Flow check, it should be pretty much instantaneous.

test:

  pre:

    - $(yarn bin)/flow start: # start up flow server in background so it's ready to go

        pwd: client

        background: true
Are you looking for a software development partner who can
develop modern, high-performance web apps and sites?
See what we've doneArrow right