Skip to content

Commit 346d3ff

Browse files
authored
Merge pull request #2455 from mroderick/speedup-ci-tests
Speed up CI with parallel test execution
2 parents da058ae + e945cf1 commit 346d3ff

10 files changed

Lines changed: 116 additions & 23 deletions

File tree

.github/workflows/ruby.yml

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,26 @@ on: [ push, pull_request ]
44

55
jobs:
66
test:
7-
name: 'Tests'
7+
name: 'Tests (Group ${{ matrix.ci_node_index }})'
88
runs-on: ubuntu-latest
9+
strategy:
10+
fail-fast: false
11+
matrix:
12+
ci_node_total: [6]
13+
ci_node_index: [0, 1, 2, 3, 4, 5]
914

1015
env:
1116
# prevent unnecessary log output -- https://bundler.io/man/bundle-config.1.html
1217
BUNDLE_IGNORE_FUNDING_REQUESTS: true
1318
BUNDLE_IGNORE_MESSAGES: true
19+
RAILS_ENV: test
20+
PARALLEL_TEST_PROCESSORS: ${{ matrix.ci_node_total }}
1421
services:
1522
postgres:
1623
image: postgres:17
1724
ports: ["5432:5432"]
1825
env:
1926
POSTGRES_PASSWORD: postgres
20-
POSTGRES_DB: test
2127
options: >-
2228
--health-cmd pg_isready
2329
--health-interval 10s
@@ -34,20 +40,69 @@ jobs:
3440
# .ruby-version provides the Ruby version implicitly.
3541
bundler-cache: true
3642

37-
- name: Setup test database
43+
- name: Setup test databases
3844
env:
39-
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
40-
RAILS_ENV: test
41-
run:
42-
bundle exec rake db:create db:migrate
45+
DATABASE_URL: postgres://postgres:postgres@localhost:5432
46+
run: |
47+
bundle exec rake parallel:setup
4348
44-
- name: Run tests
49+
- name: Run tests in parallel
4550
env:
46-
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
47-
RAILS_ENV: test
48-
run: bundle exec rake spec
51+
DATABASE_URL: postgres://postgres:postgres@localhost:5432
52+
CI_NODE_INDEX: ${{ matrix.ci_node_index }}
53+
run: |
54+
bundle exec parallel_rspec spec/ \
55+
-n ${{ matrix.ci_node_total }} \
56+
--only-group ${{ matrix.ci_node_index }}
57+
58+
- name: Preserve coverage results
59+
run: |
60+
# Copy resultset immediately after tests complete to prevent deletion
61+
cp coverage/.resultset.json coverage/resultset-${{ matrix.ci_node_index }}.json
62+
63+
- name: Upload coverage artifacts
64+
uses: actions/upload-artifact@v4
65+
with:
66+
name: coverage-${{ matrix.ci_node_index }}
67+
path: coverage/resultset-${{ matrix.ci_node_index }}.json
68+
retention-days: 1
69+
70+
coverage:
71+
name: 'Report Coverage'
72+
runs-on: ubuntu-latest
73+
needs: test
74+
steps:
75+
- name: Checkout code
76+
uses: actions/checkout@v6
77+
78+
- name: Set up Ruby
79+
uses: ruby/setup-ruby@v1
80+
with:
81+
bundler-cache: true
82+
83+
- name: Download all coverage artifacts
84+
uses: actions/download-artifact@v4
85+
with:
86+
path: coverage-results
87+
88+
- name: Merge coverage results
89+
run: |
90+
mkdir -p coverage
91+
bundle exec ruby -e '
92+
require "json"
93+
require "simplecov"
94+
95+
resultsets = {}
96+
Dir["coverage-results/coverage-*/resultset-*.json"].each do |file|
97+
data = JSON.parse(File.read(file))
98+
resultsets.merge!(data)
99+
end
100+
101+
File.write("coverage/.resultset.json", JSON.generate(resultsets))
102+
'
103+
49104
- name: Report to Coveralls
50-
continue-on-error: true # Don't fail the build if Coveralls fails to upload.
105+
continue-on-error: true
51106
uses: coverallsapp/github-action@v2
52107
with:
53108
github-token: ${{ secrets.github_token }}

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ group :development, :test do
9090
gem 'faker'
9191
gem 'irb' # LOCKED: Added because of byebug
9292
gem 'launchy'
93+
gem 'parallel_tests'
9394
gem 'pry-rails'
9495
gem 'pry-byebug'
9596
gem 'pry-remote'

Gemfile.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,8 @@ GEM
338338
json
339339
yaml
340340
parallel (1.27.0)
341+
parallel_tests (5.5.0)
342+
parallel
341343
parser (3.3.10.1)
342344
ast (~> 2.4.1)
343345
racc
@@ -651,6 +653,7 @@ DEPENDENCIES
651653
omniauth-github
652654
omniauth-rails_csrf_protection
653655
pagy (~> 43.2)
656+
parallel_tests
654657
pg
655658
pickadate-rails
656659
premailer-rails

config/database.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ development: &default
99

1010
test:
1111
<<: *default
12-
database: planner_test
12+
database: planner_test<%= ENV['TEST_ENV_NUMBER'] %>
1313
pool: 5

config/puma.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@
3434
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]
3535
worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development"
3636
environment ENV.fetch("RAILS_ENV") { "development" }
37+
38+
# Silence Puma output in test environment
39+
if ENV.fetch("RAILS_ENV", "development") == "test"
40+
quiet
41+
end
42+
3743
preload_app!
3844
# "worker" is the Puma term, not a background job.
3945
on_worker_boot do

spec/features/admin/manage_workshop_attendances_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@
5050
expect(page).to have_content('1 are attending as students')
5151
expect(page).to_not have_selector('i.fa-magic')
5252

53-
find('span', text: 'Select a member to RSVP', visible: true).click
54-
find('li', text: "#{other_invitation.member.full_name} (#{other_invitation.role})", visible: true).click
53+
# Use the select_from_chosen helper to select the member
54+
select_from_chosen("#{other_invitation.member.full_name} (#{other_invitation.role})", from: 'workshop_invitations')
5555

5656
expect(page).to have_content('2 are attending as students')
5757

spec/features/member_feedback_spec.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@
5959
context 'Submitting a feedback request' do
6060
scenario 'I can see success page with message and link to homepage when valid data is given', js: true do
6161
visit feedback_path(valid_token)
62+
63+
# Wait for Chosen dropdowns to initialize
64+
expect(page).to have_css('#feedback_coach_id_chosen')
65+
expect(page).to have_css('#feedback_tutorial_id_chosen')
66+
6267
within('.rating') { all('li').at(3).click }
6368
select_from_chosen(coach.full_name, from: 'feedback_coach_id')
6469
select_from_chosen(@tutorial.title, from: 'feedback_tutorial_id')

spec/spec_helper.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ def self.branch_coverage?
2828

2929
SimpleCov.start do
3030
add_filter 'spec/'
31+
32+
# Support parallel test execution
33+
# In CI: Use CI_NODE_INDEX (0, 1, 2, 3) set by GitHub Actions matrix
34+
# Locally: Use TEST_ENV_NUMBER ('', '2', '3', '4') set by parallel_tests
35+
if ENV['CI_NODE_INDEX']
36+
command_name "RSpec-#{ENV['CI_NODE_INDEX']}"
37+
use_merging true
38+
merge_timeout 3600
39+
elsif ENV.key?('TEST_ENV_NUMBER')
40+
# TEST_ENV_NUMBER is '' for first process, '2', '3', etc. for others
41+
suffix = ENV['TEST_ENV_NUMBER'].empty? ? '1' : ENV['TEST_ENV_NUMBER']
42+
command_name "RSpec-#{suffix}"
43+
use_merging true
44+
merge_timeout 3600
45+
end
3146
end
3247

3348
ENV['RAILS_ENV'] ||= 'test'

spec/support/capybara.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@
1515
end
1616

1717
Capybara.javascript_driver = :chrome
18+
Capybara.default_max_wait_time = 5
19+
20+
# Silence Capybara server output
21+
Capybara.server = :puma, { Silent: true }

spec/support/select_from_chosen.rb

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,19 @@ module SelectFromChosen
1010
def select_from_chosen(item_text, options)
1111
# Find the native <select>
1212
field = find_field(options[:from], :visible => false)
13+
field_id = field[:id]
1314

14-
# Open the Chosen dialog
15-
find("##{field[:id]}_chosen").click
15+
# Find the option value we need to select
16+
option = field.all('option', visible: false).find { |opt| opt.text == item_text }
17+
raise "Option '#{item_text}' not found in select '#{options[:from]}'" unless option
18+
option_value = option.value
1619

17-
# On the search input, type the string we're looking for and press Enter
18-
within field.sibling('.chosen-container') do
19-
input = find("input").native
20-
input.send_keys(item_text)
21-
input.send_key(:return)
22-
end
20+
# Use JavaScript to set the value and trigger Chosen update
21+
page.execute_script <<-JS
22+
$('##{field_id}').val('#{option_value}').trigger('chosen:updated').trigger('change');
23+
JS
24+
25+
# Verify it was set
26+
expect(page).to have_select(field_id, selected: item_text, visible: false)
2327
end
2428
end

0 commit comments

Comments
 (0)