Rails Systeemtests met Capybara: End-to-End Testing, CI-configuratie en Flaky Tests Elimineren
Rails systeemtests met Capybara: headless Chrome instellen, betrouwbare end-to-end specs schrijven, snel draaien in CI en flaky tests definitief elimineren.
De ontwikkelaar stuurde mij om 22:43 op een vrijdagavond een Slack-bericht. “Onze systeemtests slagen lokaal maar mislukken 30% van de tijd op CI. Kunnen we ze niet gewoon verwijderen?” Ik heb dit bericht, of een variant ervan, al minstens een dozijn keer ontvangen. Mijn antwoord is nooit veranderd: nee, verwijder ze niet, maar jullie Rails systeemtests zijn vrijwel zeker op zijn minst drie specifieke manieren verkeerd geconfigureerd, en voor elk van die manieren bestaat er een bekende oplossing.
Systeemtests kwamen in Rails 5.1 in 2017, gebouwd op Capybara en aangestuurd door een echte browser. Ze zijn de dichtstbijzijnde benadering van wat jouw gebruikers daadwerkelijk ervaren. Goed geconfigureerd vangen ze een hele klasse bugs die geen enkele unit-test kan zien: de JavaScript die alleen afvuurt bij een echte DOM-interactie, de Turbo Stream die het verkeerde frame bijwerkt, het formulier dat netjes wordt verzonden maar waarvan het succesmelding nooit verschijnt omdat een before_action de request al had omgeleid. Slecht geconfigureerd zijn het de tests die het vertrouwen van een team in de testsuite vernietigen en uiteindelijk worden verwijderd. Dit artikel is de setup-handleiding die ik in 2017 had willen hebben.
Wat Rails Systeemtests Zijn (en Wat Niet)
Een systeemtest subclasst ActionDispatch::SystemTestCase, dat Capybara omwikkelt, dat een echte Chrome-instantie — standaard headless — aanstuurt. De volledige stack rendert: routes, controllers, views, JavaScript, CSS, asset-pipeline. De test draait tegen een echte database. Vanuit het oogpunt van de browser is er geen verschil tussen een systeemtest en een echte gebruiker.
Die reikwijdte is het punt, maar ook de kostprijs. Een enkele system-spec die inlogt, een formulier met meerdere velden invult, het indient en de succesmelding controleert, duurt doorgaans 0,5 tot 2 seconden. Een suite van 300 tests, slecht geconfigureerd op CI, kan twaalf tot vijftien minuten aan een build toevoegen. De discipline is: gebruik systeemtests alleen voor dingen die alleen zij kunnen testen.
Een gezonde Rails-testsuite ziet er in de praktijk zo uit:
- Unit-tests en model-specs: validaties, callbacks, service-objects, POROs — snel, vaak in-memory, geen HTTP.
- Request-specs of controller-tests: de HTTP-laag. Maakt een
POST /invoiceseen record aan en leidt correct om? Antwoord in milliseconden, zonder browser. - Systeemtests: de kritieke gebruikerstrajecten die JavaScript, echte formulierverzending en zichtbare DOM-uitkomsten bevatten.
Projectconfiguratie: Gems en Driver
Als je Minitest gebruikt (de Rails-standaard), zijn systeemtests direct beschikbaar. De map test/system/ bestaat en test/application_system_test_case.rb is de configuratieplek. Als je RSpec gebruikt, voeg het volgende toe:
# Gemfile
group :test do
gem "capybara"
gem "selenium-webdriver"
gem "webdrivers", "~> 5.0"
end
Maak dan een support-bestand aan:
# spec/support/capybara.rb
require "capybara/rspec"
Capybara.configure do |config|
config.default_max_wait_time = 5
config.ignore_hidden_elements = true
config.default_normalize_ws = true
config.server = :puma, { Silent: true }
config.save_path = Rails.root.join("tmp/capybara")
end
RSpec.configure do |config|
config.before(:each, type: :system) do
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 900]
end
end
Voor Minitest stel je in in test/application_system_test_case.rb:
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 900]
end
Elke Capybara-optie verdient zijn plek. default_max_wait_time = 5 geeft trage CI-machines voldoende tijd om JavaScript te renderen voordat een assertion opgeeft. default_normalize_ws = true normaliseert witruimte zodat assert_text "Opslaan document" (dubbele spatie) overeenkomt met assert_text "Opslaan document". server = :puma draait de testserver op Puma — dezelfde server die je in productie gebruikt.
Headless Chrome en de CI-driverregistratie
Op GitHub Actions in 2026 is Chrome vooraf geïnstalleerd op ubuntu-latest. De webdrivers gem lost automatisch de bijpassende chromedriver-versie op, maar een expliciete registratie geeft je meer controle over Chrome-flags — wat je in gecontaineriseerde omgevingen nodig hebt:
# spec/support/capybara.rb
Capybara.register_driver :chrome_headless do |app|
options = Selenium::WebDriver::Chrome::Options.new
options.add_argument("--headless=new")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--disable-gpu")
options.add_argument("--window-size=1400,900")
options.add_argument("--disable-extensions")
Capybara::Selenium::Driver.new(app, browser: :chrome, options: options)
end
Capybara.default_driver = :rack_test
Capybara.javascript_driver = :chrome_headless
De flags --no-sandbox en --disable-dev-shm-usage zijn essentieel in Docker- en CI-containers. /dev/shm is Chrome’s gedeelde geheugenkanaal voor inter-processcommunicatie. Op GitHub Actions runners en de meeste Docker-configuraties is het beperkt tot 64MB. Chrome crasht stilzwijgend als het niet vanuit /dev/shm kan alloceren, wat de verwarrende fout “unknown error: session deleted because of page crash” veroorzaakt.
Je GitHub Actions-workflow:
# .github/workflows/test.yml
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Database aanmaken
env:
DATABASE_URL: postgres://postgres:postgres@localhost/myapp_test
RAILS_ENV: test
run: |
bin/rails db:create
bin/rails db:schema:load
- name: Systeemtests uitvoeren
env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres:postgres@localhost/myapp_test
run: bundle exec rspec spec/system
- name: Capybara-screenshots uploaden
if: failure()
uses: actions/upload-artifact@v4
with:
name: capybara-screenshots
path: tmp/capybara/
De screenshot-uploadstap is niet optioneel. Wanneer een Rails systeemtest mislukt op CI, is de screenshot het eerste wat je raadpleegt.
Database-isolatie: De Onderliggende Oorzaak van Flakiness
Dit is het onderdeel dat bijna elk team verrast bij intermitterende systeemtest-fouten. De reden is threading.
Capybara bestuurt een echte browser. Die browser maakt verbinding met je Rails-app via HTTP. Je Rails-app draait in een aparte thread van de thread die je testassertions uitvoert. Standaard omwikkelt Rails elke test in een databasetransactie en rolt die terug aan het einde — use_transactional_tests = true. Het probleem: de app-thread en de test-thread gebruiken verschillende databaseverbindingen, zodat de app-thread geen data ziet die in de open transactie van de test-thread is aangemaakt.
De oplossing is overstappen op truncatie in plaats van transacties voor systeemtests:
# spec/support/database_cleaner.rb
RSpec.configure do |config|
config.before(:suite) do
DatabaseCleaner.clean_with(:truncation)
end
config.before(:each) do |example|
strategy = example.metadata[:type] == :system ? :truncation : :transaction
DatabaseCleaner.strategy = strategy
DatabaseCleaner.start
end
config.after(:each) do
DatabaseCleaner.clean
end
end
Truncatie is trager dan transacties — doorgaans 100–300ms per test. Dat is de eerlijke kostprijs van cross-thread data-zichtbaarheid. Ik ben dieper ingegaan op testsuite-optimalisatie in de post over RSpec, Factory Bot en VCR, maar database-isolatie is het meest kritieke punt specifiek voor systeemtests.
Tests Schrijven Die Zes Maanden Ontwikkeling Overleven
Het verschil tussen een systeemtest die een jaar lang groen blijft en eentje die wekelijks aanpassing nodig heeft, zit vrijwel altijd in wat hij controleert.
Slecht:
it "slaat het formulier op" do
visit new_invoice_path
find("form#new_invoice .btn.btn-primary").click
expect(page).to have_css(".alert.alert-success")
end
Deze test breekt elke keer als een CSS-klasse wordt hernoemd, een form-ID verandert, of Bootstrap wordt bijgewerkt. Hij controleert implementatiedetails in plaats van gebruikszichtbare uitkomsten.
Goed:
it "maakt een factuur aan en bevestigt dit aan de gebruiker" do
user = create(:user)
login_as(user, scope: :user)
visit new_invoice_path
fill_in "Factuurnummer", with: "FAC-2026-001"
fill_in "Klantnaam", with: "Acme BV"
fill_in "Bedrag", with: "3500,00"
select "Nederland", from: "Land"
click_button "Factuur aanmaken"
expect(page).to have_text("Factuur FAC-2026-001 is succesvol aangemaakt")
expect(page).to have_current_path(%r{/invoices/\d+})
expect(Invoice.last.client_name).to eq("Acme BV")
end
Deze versie breekt alleen wanneer gebruikszichtbaar gedrag verandert — precies wanneer je wilt dat hij breekt.
Drie Capybara-methodes die ik in elk project gebruik, omdat ze allemaal het ingebouwde retry-mechanisme van Capybara gebruiken:
# Wachtende assertions — herprobeert totdat timeout
expect(page).to have_text("Succesvol opgeslagen")
expect(page).to have_selector("table tbody tr", count: 12)
expect(page).to have_current_path(%r{/orders/\d+/bevestiging})
expect(page).not_to have_text("Fout bij opslaan")
# Wachtende zoekers
click_link "Bewerken"
fill_in "E-mailadres", with: "roger@example.com"
check "Notificaties ontvangen"
select "Pro-abonnement", from: "Abonnementstype"
# Wachten op JavaScript-gestuurde toestand
expect(page).to have_css("[data-status='gereed']")
expect(page).to have_css(".modal", visible: true)
De kritieke inzicht: find(".some-class") herprobeert niet. have_selector(".some-class") wel. Als je find gebruikt voordat een element in de DOM staat, krijg je een race-conditie die timing-afhankelijk en flaky is.
Page Objects: Tests Leesbaar Houden Terwijl de Applicatie Groeit
Zodra een project meer dan 30 of 40 system-specs heeft, worden rauwe Capybara-selectors door heel de codebase een onderhoudsprobleem. De oplossing die ik gebruik is een minimale page-object-laag — geen zwaar DSL, gewoon Ruby-objecten met beschrijvende methodes:
# spec/support/pages/invoice_page.rb
module Pages
class NewInvoice
include Capybara::DSL
def visit_page
visit new_invoice_path
self
end
def fill_in_details(number:, client:, amount:, country: "Nederland")
fill_in "Factuurnummer", with: number
fill_in "Klantnaam", with: client
fill_in "Bedrag", with: amount
select country, from: "Land"
self
end
def submit
click_button "Factuur aanmaken"
self
end
def flash_message
find(".flash-notice").text
end
def current_invoice
Invoice.find(page.current_path.match(%r{/invoices/(\d+)})[1])
end
end
end
Gebruik:
it "maakt een factuur aan" do
login_as create(:user), scope: :user
invoice_page = Pages::NewInvoice.new.visit_page
invoice_page.fill_in_details(number: "FAC-001", client: "Acme", amount: "1500")
invoice_page.submit
expect(invoice_page.flash_message).to include("FAC-001 is aangemaakt")
expect(invoice_page.current_invoice.client_name).to eq("Acme")
end
Wanneer de knoplabel verandert van “Factuur aanmaken” naar “Factuur opslaan”, pas je het page-object op één plek aan — niet in vijftien specs.
Systeemtests Parallel Uitvoeren voor Snelle CI
Voor suites van meer dan 50 system-specs is parallellisatie de grootste verbetering. Voor Minitest biedt Rails parallelize ingebouwd:
# test/test_helper.rb
parallelize(workers: :number_of_processors, with: :processes)
parallelize_setup { |worker| ActiveRecord::Base.establish_connection }
Voor RSpec is parallel_tests de standaard:
# Gemfile
group :test do
gem "parallel_tests"
end
bundle exec parallel_rspec spec/system -n 4
Twee dingen die je moet configureren bij parallelle systeemtests. Ten eerste heeft elk process zijn eigen database nodig:
# config/database.yml
test:
database: myapp_test<%= ENV["TEST_ENV_NUMBER"] %>
Ten tweede heeft elk process zijn eigen Capybara-serverpoort nodig:
# spec/support/capybara.rb
Capybara.server_port = 4000 + (ENV["TEST_ENV_NUMBER"].to_i)
Zonder dit vechten processen over dezelfde poort en krijg je willekeurige verbindingsfouten op precies 30% van de runs — dat is precies het vrijdagavond-Slackbericht waar ik mee begon.
De Vier Meest Voorkomende Faalmodi Diagnosticeren
Wanneer een Rails systeemtest lokaal slaagt maar op CI mislukt, is de oorzaak vrijwel altijd een van de volgende vier:
Timing. De assertion vuurt voordat de browser een JavaScript-update heeft afgerond. Oplossing: gebruik have_text en have_selector (die herprobeert) in plaats van find (dat dat niet doet). Verhoog default_max_wait_time op CI naar 8 als de omgeving echt traag is. Voeg nooit sleep toe — dat is een tijdelijke pleister.
Database-isolatie. Data van een vorige test lekt in de huidige omdat je transacties in plaats van truncatie gebruikt voor system-specs. Oplossing: de DatabaseCleaner-configuratie hierboven.
Viewport en zichtbaarheid. Een knop staat buiten het viewport op de CI-resolutie en Selenium kan er niet op klikken. Oplossing: stel --window-size=1400,900 in bij de Chrome-opties. Scroll naar het element als het onder een sticky header zit: find("button", text: "Verzenden").scroll_into_view.
JavaScript-controller nog niet gekoppeld. In CI kan asset-levering iets trager zijn en een Stimulus-controller is mogelijk nog niet opgestart voordat de test met het element interacteert. Oplossing: wacht op het controller-attribuut:
expect(page).to have_css("[data-controller='factuur-formulier'][data-factuur-formulier-geladen-value='true']")
find("button", text: "Regel toevoegen").click
Screenshots en HTML Vastleggen bij Fouten
Voor RSpec:
# spec/support/capybara.rb
RSpec.configure do |config|
config.after(:each, type: :system) do |example|
if example.exception
screenshot_name = "#{Time.now.to_i}-#{example.description.parameterize(separator: '-')}"
save_screenshot("#{Capybara.save_path}/#{screenshot_name}.png")
File.write("#{Capybara.save_path}/#{screenshot_name}.html", page.html)
end
end
end
Voor Minitest, voeg toe aan ApplicationSystemTestCase:
teardown do
if self.class.current_test_failed?
name = method_name.parameterize(separator: "-")
save_screenshot("#{Rails.root}/tmp/capybara/#{name}.png")
end
end
Na negentien jaar testfouten debuggen is de screenshot het eerste wat ik bekijk, niet het Ruby-foutbericht. In negen van de tien gevallen vertelt de screenshot het hele verhaal.
Veelgestelde Vragen
Wat is het verschil tussen Rails systeemtests en request-specs?
Request-specs testen de HTTP-laag: ze controleren of POST /invoices een record aanmaakt en correct omleidt. Ze draaien zonder browser — snel en deterministisch. Rails systeemtests besturen een echte headless Chrome-browser, wat betekent dat ze JavaScript, Stimulus-controllers, Turbo Streams en zichtbare DOM-uitkomsten testen. Gebruik request-specs om het API-contract te testen; gebruik systeemtests om te testen wat een gebruiker daadwerkelijk ziet en doet.
Hoe log ik een gebruiker in zonder elke keer het loginformulier te doorlopen?
Met Devise, voeg Warden::Test::Helpers toe en gebruik login_as:
# spec/support/devise.rb
RSpec.configure do |config|
config.include Warden::Test::Helpers
config.after(:each, type: :system) { Warden.test_reset! }
end
# In een system-spec
it "toont het dashboard" do
user = create(:user)
login_as(user, scope: :user)
visit dashboard_path
expect(page).to have_text("Welkom terug, #{user.voornaam}")
end
login_as stelt de Warden-sessie direct in zonder het loginformulier te doorlopen. Dit isoleert je geauthenticeerde specs van loginformulier-bugs en bespaart de 0,5–1 seconde die de formulierinteractie zou toevoegen aan elke spec.
Moet ik Cuprite gebruiken in plaats van Selenium?
Cuprite bestuurt Chrome via het Chrome DevTools Protocol in plaats van WebDriver, wat doorgaans 20–40% sneller is en betere JavaScript-debugging biedt. Het is een goede keuze voor nieuwe projecten. Het nadeel is een iets ander Capybara-API en minder volwassen iframe-ondersteuning. Selenium met ChromeDriver is beter beproefd en sluit aan bij de Rails-documentatie. Ik begin elk project met Selenium en migreer naar Cuprite alleen als de suite echt traag is en parallellisatie alleen niet voldoende heeft geholpen.
Hoeveel systeemtests heeft een Rails-applicatie nodig?
Dek de kritieke gebruikerstrajecten: de flows die, als ze in productie breken, direct leiden tot omzetverlies, dataverlies of directe klachten van klanten. Voor een typische SaaS-applicatie zijn dat: aanmelden, inloggen, het kerntrajekt van de hoofdfunctie, facturering/checkout. Daarna vraag je jezelf af of een request-spec of unit-test dezelfde bug zou opvangen. Ik heb nog nooit een Rails-applicatie gezien waar meer dan 60–80 systeemtests gerechtvaardigd waren; de meeste hebben er 20–40 nodig.
Als jouw Rails systeemtests zichzelf aan het verwijderen zijn of in een permanent-rode staat leven, kan TTB Software de setup controleren en je CI betrouwbaar groen krijgen. We bouwen en repareren Rails-testinfrastructuur al negentien jaar, en we hebben elke variant van flaky-test-hell gezien.
Related Articles
Rails LLM Structured Output: Betrouwbare JSON van Claude en OpenAI met Schema Validatie
Rails LLM structured output gids: betrouwbare JSON van Claude en OpenAI met schema mode, response validatie en retrie...
Rails Postgres partitioning: miljardenrij-tabellen beheren met pg_partman en native Postgres-partities
Rails Postgres partitioning gids: beheer miljardenrij-tabellen met native Postgres declarative partitioning, pg_partm...
Rails ActiveRecord Encryption: PII versleutelen at rest met deterministische en niet-deterministische modes
Rails ActiveRecord encryption gids: versleutel PII at rest met deterministische en niet-deterministische modes, key m...