Skip to content
Documentation

Testing with RSpec

Testing with RSpec documentation for Miru.

Insights
Miru reporting screen with utilization and revenue reports

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:

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:

  1. Write tests for all new features: Aim for >85% coverage
  2. Test edge cases: Not just the happy path
  3. Use descriptive test names: Make intent clear
  4. Keep tests fast: Mock external dependencies
  5. Follow existing patterns: Maintain consistency
  6. Update tests when refactoring: Keep them in sync with code

For more information on testing best practices, see:

Try Miru

Read it once. Run it for real.

The docs explain the workflow. Miru gives you the actual timer, invoice, reports, and payment flow without another pile of tools.

Insights
Miru reporting screen with utilization and revenue reports
Reports Miru
Start Tracking Free