Skip to main content

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: "[email protected]",
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("[email protected]")
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: