Testing Guide
Miru uses a comprehensive testing strategy to ensure code quality and reliability. This guide covers our testing setup, best practices, and how to run and write tests effectively.
Testing Stack
Our testing infrastructure includes:
- RSpec: Primary testing framework for Ruby/Rails
- FactoryBot: Test data generation
- Capybara: Integration and system testing
- Selenium WebDriver: Browser automation for system tests
- VCR & WebMock: HTTP request stubbing
- Database Cleaner: Test isolation
- SimpleCov: Code coverage reporting
- Shoulda Matchers: Rails-specific test matchers
Initial Setup
1. Prepare Test Database
# Create test database
bin/rails db:create RAILS_ENV=test
# Run migrations
bin/rails db:migrate RAILS_ENV=test
# Verify setup
RAILS_ENV=test bin/rails db:version
2. Install System Dependencies
For system tests (browser automation):
# macOS
brew install chromedriver
# Linux
sudo apt-get install chromium-chromedriver
# Verify installation
chromedriver --version
Running Tests
Basic Test Commands
# Run all tests (excludes system tests by default for speed)
bundle exec rspec
# Include system tests
bundle exec rspec --tag ~skip_ci
# Run specific test file
bundle exec rspec spec/models/user_spec.rb
# Run specific test by line number
bundle exec rspec spec/models/user_spec.rb:42
# Run tests matching a pattern
bundle exec rspec --grep "authentication"
Test Categories
Unit Tests
# Model tests
bundle exec rspec spec/models/
# Service tests
bundle exec rspec spec/services/
# Policy tests (Pundit authorization)
bundle exec rspec spec/policies/
# Job tests (Solid Queue)
bundle exec rspec spec/jobs/
Integration Tests
# API endpoint tests
bundle exec rspec spec/requests/
# Controller tests (if any legacy ones exist)
bundle exec rspec spec/controllers/
System Tests (End-to-End)
# Full browser tests
bundle exec rspec spec/system/
# Run in headed mode (visible browser)
HEADLESS=false bundle exec rspec spec/system/
# Run with specific browser
bundle exec rspec spec/system/ --tag chrome
Performance & Debugging Options
# Fail fast (stop on first failure)
bundle exec rspec --fail-fast
# Run tests in random order
bundle exec rspec --order random
# Run tests with detailed output
bundle exec rspec --format documentation
# Profile slowest tests
bundle exec rspec --profile 10
# Run only failed tests from last run
bundle exec rspec --only-failures
# Run tests in parallel (faster)
bundle exec rspec --require parallel_tests/rspec/runner
Code Coverage
Generate Coverage Report
# Run tests with coverage
COVERAGE=true bundle exec rspec
# View HTML coverage report
# macOS
open coverage/index.html
# Linux
xdg-open coverage/index.html
# View coverage summary in terminal
cat coverage/.last_run.json
Coverage Targets
We aim for:
- Models: 95%+ coverage
- Services: 90%+ coverage
- Controllers/Requests: 85%+ coverage
- Overall: 85%+ coverage
Writing Tests
Test Structure
We follow RSpec best practices with clear, descriptive tests:
# spec/models/user_spec.rb
RSpec.describe User, type: :model do
describe "validations" do
subject { build(:user) }
it { is_expected.to validate_presence_of(:email) }
it { is_expected.to validate_uniqueness_of(:email).case_insensitive }
it { is_expected.to validate_length_of(:password).is_at_least(8) }
end
describe "associations" do
it { is_expected.to have_many(:timesheet_entries) }
it { is_expected.to belong_to(:company) }
end
describe "#full_name" do
let(:user) { build(:user, first_name: "John", last_name: "Doe") }
it "returns the concatenated first and last name" do
expect(user.full_name).to eq("John Doe")
end
context "when last name is missing" do
let(:user) { build(:user, first_name: "John", last_name: nil) }
it "returns only the first name" do
expect(user.full_name).to eq("John")
end
end
end
end
Factory Examples
# spec/factories/users.rb
FactoryBot.define do
factory :user do
sequence(:email) { |n| "user#{n}@example.com" }
password { "password123" }
first_name { "John" }
last_name { "Doe" }
association :company
trait :admin do
role { :admin }
end
trait :with_timesheet_entries do
after(:create) do |user|
create_list(:timesheet_entry, 3, user: user)
end
end
factory :admin_user, traits: [:admin]
end
end
Service Test Example
# spec/services/invoice_generator_service_spec.rb
RSpec.describe InvoiceGeneratorService do
describe "#process" do
let(:company) { create(:company) }
let(:client) { create(:client, company: company) }
let(:project) { create(:project, client: client) }
let!(:timesheet_entries) do
create_list(:timesheet_entry, 3,
project: project,
duration: 2.hours,
work_date: 1.week.ago
)
end
let(:service) { described_class.new(project.id, Date.current) }
it "creates an invoice with correct total amount" do
expect { service.process }.to change(Invoice, :count).by(1)
invoice = Invoice.last
expect(invoice.amount).to eq(6 * project.hourly_rate) # 6 hours total
expect(invoice.client).to eq(client)
end
it "marks timesheet entries as invoiced" do
service.process
timesheet_entries.each(&:reload)
expect(timesheet_entries).to all(be_invoiced)
end
context "when no unbilled timesheet entries exist" do
before { timesheet_entries.update_all(invoiced: true) }
it "raises an error" do
expect { service.process }.to raise_error(
InvoiceGeneratorService::NoUnbilledEntriesError
)
end
end
end
end
Request Test Example
# spec/requests/api/v1/users_spec.rb
RSpec.describe "API::V1::Users", type: :request do
let(:company) { create(:company) }
let(:user) { create(:user, company: company) }
let(:headers) do
{
"Authorization" => "Bearer #{generate_jwt_token(user)}",
"Content-Type" => "application/json"
}
end
describe "GET /api/v1/users" do
let!(:users) { create_list(:user, 3, company: company) }
it "returns all users for the company" do
get "/api/v1/users", headers: headers
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json["users"]).to have(4) # 3 created + 1 authenticated user
end
it "includes user details in response" do
get "/api/v1/users", headers: headers
json = JSON.parse(response.body)
user_data = json["users"].first
expect(user_data).to include(
"id",
"email",
"full_name",
"role"
)
expect(user_data).not_to include("password_digest")
end
end
describe "POST /api/v1/users" do
let(:valid_attributes) do
{
user: {
email: "newuser@example.com",
first_name: "Jane",
last_name: "Smith",
password: "password123",
role: "employee"
}
}
end
it "creates a new user" do
expect do
post "/api/v1/users",
params: valid_attributes.to_json,
headers: headers
end.to change(User, :count).by(1)
expect(response).to have_http_status(:created)
json = JSON.parse(response.body)
expect(json["user"]["email"]).to eq("newuser@example.com")
end
context "with invalid attributes" do
let(:invalid_attributes) do
{ user: { email: "", first_name: "" } }
end
it "returns validation errors" do
post "/api/v1/users",
params: invalid_attributes.to_json,
headers: headers
expect(response).to have_http_status(:unprocessable_entity)
json = JSON.parse(response.body)
expect(json["errors"]).to include("email", "first_name")
end
end
end
end
System Test Example
# spec/system/time_tracking_spec.rb
RSpec.describe "Time Tracking", type: :system do
let(:company) { create(:company) }
let(:user) { create(:user, company: company) }
let(:client) { create(:client, company: company) }
let(:project) { create(:project, client: client, company: company) }
before do
login_as(user)
visit root_path
end
describe "creating a time entry" do
it "allows user to track time for a project" do
click_on "Start Timer"
select project.name, from: "Project"
fill_in "Description", with: "Working on homepage design"
click_on "Start"
expect(page).to have_content("Timer started")
expect(page).to have_css(".timer-running", text: /00:00:\d{2}/)
# Simulate some time passing
sleep 2
click_on "Stop Timer"
expect(page).to have_content("Time entry saved")
expect(TimesheetEntry.last.description).to eq("Working on homepage design")
expect(TimesheetEntry.last.project).to eq(project)
end
it "validates required fields" do
click_on "Start Timer"
click_on "Start" # Without selecting project
expect(page).to have_content("Project is required")
expect(page).not_to have_css(".timer-running")
end
end
describe "editing time entries" do
let!(:time_entry) do
create(:timesheet_entry,
user: user,
project: project,
description: "Original description",
duration: 2.hours
)
end
it "allows editing existing entries" do
visit timesheet_entries_path
within "#time-entry-#{time_entry.id}" do
click_on "Edit"
end
fill_in "Description", with: "Updated description"
fill_in "Duration", with: "3.5"
click_on "Update Time Entry"
expect(page).to have_content("Time entry updated")
expect(page).to have_content("Updated description")
expect(page).to have_content("3.5 hours")
end
end
# Test with JavaScript interactions
describe "timer functionality", js: true do
it "updates timer display in real-time" do
click_on "Start Timer"
select project.name, from: "Project"
fill_in "Description", with: "Testing timer"
click_on "Start"
# Wait for timer to update
expect(page).to have_css(".timer-display", text: /00:00:0[1-9]/, wait: 3)
end
end
end
Test Helpers and Support
Authentication Helper
# spec/support/auth_helper.rb
module AuthHelper
def login_as(user)
# For system tests
if respond_to?(:visit)
visit new_user_session_path
fill_in "Email", with: user.email
fill_in "Password", with: user.password || "password123"
click_on "Sign In"
end
end
def generate_jwt_token(user)
# For API tests
JsonWebToken.encode(user_id: user.id)
end
end
RSpec.configure do |config|
config.include AuthHelper
end
Database Cleaner Configuration
# spec/support/database_cleaner.rb
RSpec.configure do |config|
config.before(:suite) do
DatabaseCleaner.clean_with(:truncation)
end
config.before(:each) do
DatabaseCleaner.strategy = :transaction
end
config.before(:each, :js => true) do
DatabaseCleaner.strategy = :truncation
end
config.before(:each) do
DatabaseCleaner.start
end
config.after(:each) do
DatabaseCleaner.clean
end
end
Continuous Integration
GitHub Actions Configuration
Our CI pipeline runs:
# .github/workflows/test.yml (excerpt)
- name: Run RSpec
run: |
bundle exec rspec --format progress \
--format RspecJunitFormatter \
--out tmp/rspec.xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage/coverage.xml
Test Categories in CI
- Fast Tests: Models, services, policies (< 30 seconds)
- Integration Tests: Requests, features (< 2 minutes)
- System Tests: Full browser tests (< 5 minutes)
- Linting: Rubocop, ESLint, type checking
Best Practices
1. Test Organization
# Use descriptive test names
describe "#calculate_total_hours" do
context "when entries span multiple days" do
it "sums hours correctly across all entries" do
# Test implementation
end
end
end
2. Data Setup
# Use factories for object creation
let(:user) { create(:user) }
# Use build for objects that don't need persistence
let(:unsaved_user) { build(:user) }
# Use let! when you need the object created immediately
let!(:existing_entry) { create(:timesheet_entry, user: user) }
3. Mocking External Services
# Mock external API calls
before do
allow(StripeService).to receive(:create_invoice)
.and_return(double(id: "inv_123", status: "open"))
end
4. Testing Edge Cases
# Test boundary conditions
context "when duration is exactly 24 hours" do
it "handles full day entries correctly" do
entry = create(:timesheet_entry, duration: 24.hours)
expect(entry.full_day?).to be_truthy
end
end
Troubleshooting Tests
Common Issues
Database State Issues
# Reset test database if tests are interfering
RAILS_ENV=test bin/rails db:drop db:create db:migrate
# Clear test database
bundle exec rake db:test:prepare
System Test Issues
# Install missing browser dependencies
# macOS
brew install --cask google-chrome
# Linux
sudo apt-get install google-chrome-stable
# Update chromedriver
brew upgrade chromedriver
Flaky Tests
# Run flaky test multiple times to confirm
bundle exec rspec spec/system/flaky_spec.rb --count 10
# Run with seed to reproduce failures
bundle exec rspec --seed 12345
Performance Issues
# Profile slow tests
bundle exec rspec --profile
# Check for N+1 queries
gem install bullet # Add to Gemfile in test group
Useful Commands
# Generate test coverage badge
COVERAGE=true bundle exec rspec
badge coverage/coverage.svg
# Run specific test types
bundle exec rspec --tag type:model
bundle exec rspec --tag type:request
bundle exec rspec --tag type:system
# Run tests by tag
bundle exec rspec --tag focus
bundle exec rspec --tag ~slow
# Generate test documentation
bundle exec rspec --format html --out tmp/rspec.html
Contributing Test Guidelines
When contributing tests:
- Write tests for all new features: Aim for >85% coverage
- Test edge cases: Not just the happy path
- Use descriptive test names: Make intent clear
- Keep tests fast: Mock external dependencies
- Follow existing patterns: Maintain consistency
- Update tests when refactoring: Keep them in sync with code
For more information on testing best practices, see: