Overview
Unit testing validates individual components of software in isolation from the rest of the system. A unit test exercises a single method, function, or class behavior with specific inputs and verifies the output matches expectations. Unlike integration tests that verify multiple components working together, unit tests focus on the smallest testable parts of an application.
The practice emerged in the 1970s as part of structured programming methodologies, gaining widespread adoption through Extreme Programming and Test-Driven Development in the late 1990s. Kent Beck's SUnit framework for Smalltalk established patterns that influenced xUnit frameworks across languages, including Ruby's Test::Unit and RSpec.
Unit tests serve multiple purposes in software development. They provide immediate feedback during development, catching regressions when code changes. Tests document expected behavior more reliably than comments because they execute against actual code. A comprehensive test suite enables confident refactoring because breaking changes surface immediately. Teams use test coverage metrics to identify untested code paths, though high coverage percentages don't guarantee quality tests.
The relationship between production code and test code follows a ratio that varies by project. Some codebases maintain 1:1 ratios where test code equals production code volume. Others reach 2:1 or 3:1 where test code exceeds production code. The ratio matters less than whether tests effectively verify behavior and catch defects.
# Basic unit test structure
class Calculator
def add(a, b)
a + b
end
end
# Test verifies single method behavior
require 'minitest/autorun'
class CalculatorTest < Minitest::Test
def test_add_returns_sum
calculator = Calculator.new
result = calculator.add(2, 3)
assert_equal 5, result
end
end
Key Principles
Unit testing operates on several core principles that distinguish effective tests from ineffective ones. These principles guide test design and help teams build reliable test suites.
Isolation requires each test to run independently without depending on other tests or external state. Tests create their own fixtures, execute operations, and clean up afterward. A test that fails should pinpoint a specific defect in a specific unit, not indicate cascading failures from shared state. Ruby's setup and teardown methods provide hooks for establishing and destroying test fixtures.
Repeatability means tests produce identical results given identical conditions. A test that passes sometimes and fails other times indicates environmental dependencies, race conditions, or state pollution. Tests should never depend on execution order, current time, random number generation, or external services. Repeatable tests allow developers to run the suite multiple times during debugging without worrying about flaky results.
Fast execution keeps the development feedback loop tight. Unit tests should complete in milliseconds, allowing developers to run hundreds or thousands of tests frequently. Tests that take seconds indicate integration with databases, file systems, or network resources. Slow tests discourage frequent execution, reducing their value. A well-designed test suite runs the entire suite in under a minute.
Clear assertions make test failures immediately understandable. Each test should verify one logical concept, even if that requires multiple assertion statements. Assertion messages should explain what failed and why. Generic assertions like assert result provide no context when they fail. Specific assertions like assert_equal expected_status, response.status, "API should return 200 for valid requests" communicate intent.
Comprehensive coverage targets all code paths including edge cases, error conditions, and boundary values. Testing only happy paths leaves defects undetected. Tests should exercise minimum values, maximum values, empty inputs, null inputs, and invalid inputs. Ruby's dynamic nature requires testing type coercion, method missing scenarios, and module inclusion effects.
Minimal setup reduces test complexity and maintenance burden. Tests requiring dozens of lines of fixture creation often indicate design problems in production code. Objects with many dependencies signal tight coupling. Tests should construct the minimum necessary state to verify specific behavior.
No external dependencies prevents test failures from environmental changes. Tests should not connect to databases, file systems, network services, or system clocks. Ruby provides stubbing and mocking facilities to replace external dependencies with controlled test doubles. The Time.stub pattern freezes time for tests verifying time-dependent behavior.
# Demonstrating isolation principle
class UserValidatorTest < Minitest::Test
def setup
@validator = UserValidator.new
end
def teardown
# Each test gets fresh validator instance
@validator = nil
end
def test_validates_email_format
# Isolated test - no dependency on other tests
assert @validator.valid_email?('user@example.com')
end
def test_rejects_invalid_email
# Runs independently regardless of previous test
refute @validator.valid_email?('invalid')
end
end
# Demonstrating repeatability with stubbing
class OrderProcessorTest < Minitest::Test
def test_processes_order_at_specific_time
# Freeze time for repeatable results
freeze_time = Time.new(2025, 1, 15, 10, 30, 0)
Time.stub :now, freeze_time do
processor = OrderProcessor.new
order = processor.create_order
assert_equal freeze_time, order.created_at
end
end
end
Ruby Implementation
Ruby provides multiple testing frameworks, each with different syntaxes and philosophies. Test::Unit ships with Ruby's standard library, offering a classic xUnit-style approach. Minitest succeeded Test::Unit as the default framework, providing both xUnit and spec-style interfaces with minimal dependencies. RSpec dominates Ruby testing with its behavior-driven development syntax and extensive matcher library.
Minitest follows a straightforward class-based structure. Test classes inherit from Minitest::Test, and methods starting with test_ define test cases. Assertions use methods like assert_equal, assert_nil, assert_raises, and refute. The spec syntax offers an alternative DSL using describe and it blocks.
# Minitest classic syntax
require 'minitest/autorun'
class StringProcessorTest < Minitest::Test
def setup
@processor = StringProcessor.new
end
def test_upcase_converts_to_uppercase
result = @processor.upcase('hello')
assert_equal 'HELLO', result
end
def test_reverse_reverses_string
result = @processor.reverse('hello')
assert_equal 'olleh', result
end
def test_raises_on_nil_input
assert_raises(ArgumentError) { @processor.upcase(nil) }
end
end
RSpec structures tests using describe, context, and it blocks. The framework encourages readable test descriptions that document behavior. Expectations use expect(value).to matcher syntax with matchers like eq, be_nil, raise_error, and include. RSpec's extensive matcher library covers common testing scenarios.
# RSpec syntax
require 'rspec'
RSpec.describe StringProcessor do
let(:processor) { StringProcessor.new }
describe '#upcase' do
it 'converts string to uppercase' do
expect(processor.upcase('hello')).to eq('HELLO')
end
context 'with nil input' do
it 'raises ArgumentError' do
expect { processor.upcase(nil) }.to raise_error(ArgumentError)
end
end
end
describe '#reverse' do
it 'reverses the string' do
expect(processor.reverse('hello')).to eq('olleh')
end
end
end
Test doubles replace real objects with controlled substitutes during testing. Ruby's testing frameworks provide mocking, stubbing, and spying capabilities. Mocks define expected method calls and verify they occurred. Stubs replace method implementations with fixed return values. Spies record method calls for later verification.
# Mocking with Minitest
require 'minitest/mock'
class NotificationServiceTest < Minitest::Test
def test_sends_email_on_order_completion
email_service = Minitest::Mock.new
email_service.expect :send, true, ['Order completed', 'user@example.com']
notifier = NotificationService.new(email_service)
notifier.notify_order_complete('user@example.com')
email_service.verify # Confirms expected call occurred
end
end
Fixtures and factories generate test data. Fixtures store static test data in YAML or JSON files, loaded before tests run. Factories create objects programmatically with default attributes, allowing customization per test. Factory Bot dominates Ruby factory patterns, replacing the older FactoryGirl.
# Factory Bot pattern
FactoryBot.define do
factory :user do
email { "user#{sequence(:email_sequence)}@example.com" }
name { 'Test User' }
role { :member }
trait :admin do
role { :admin }
end
end
end
# Usage in tests
class UserAuthorizationTest < Minitest::Test
def test_admin_can_delete_users
admin = FactoryBot.create(:user, :admin)
target_user = FactoryBot.create(:user)
service = UserService.new(admin)
assert service.can_delete?(target_user)
end
end
Practical Examples
Testing a data transformation class demonstrates validation logic, error handling, and edge case coverage. The example shows testing normal operation, boundary conditions, and exceptional scenarios.
class DataNormalizer
def normalize_price(value)
raise ArgumentError, 'Price cannot be nil' if value.nil?
cleaned = value.to_s.gsub(/[$,]/, '')
float_value = Float(cleaned)
raise ArgumentError, 'Price cannot be negative' if float_value < 0
(float_value * 100).round / 100.0
end
end
class DataNormalizerTest < Minitest::Test
def setup
@normalizer = DataNormalizer.new
end
def test_normalizes_simple_price
result = @normalizer.normalize_price('49.99')
assert_equal 49.99, result
end
def test_removes_dollar_sign
result = @normalizer.normalize_price('$49.99')
assert_equal 49.99, result
end
def test_removes_commas
result = @normalizer.normalize_price('1,234.56')
assert_equal 1234.56, result
end
def test_rounds_to_two_decimals
result = @normalizer.normalize_price('49.999')
assert_equal 50.0, result
end
def test_handles_integer_input
result = @normalizer.normalize_price(50)
assert_equal 50.0, result
end
def test_handles_zero
result = @normalizer.normalize_price(0)
assert_equal 0.0, result
end
def test_raises_on_nil
error = assert_raises(ArgumentError) do
@normalizer.normalize_price(nil)
end
assert_equal 'Price cannot be nil', error.message
end
def test_raises_on_negative
error = assert_raises(ArgumentError) do
@normalizer.normalize_price(-10)
end
assert_equal 'Price cannot be negative', error.message
end
def test_raises_on_invalid_string
assert_raises(ArgumentError) { @normalizer.normalize_price('invalid') }
end
end
Testing a caching layer requires stubbing time and verifying expiration logic. The example demonstrates testing stateful behavior across multiple method calls.
class SimpleCache
def initialize(ttl_seconds)
@ttl_seconds = ttl_seconds
@cache = {}
end
def get(key)
entry = @cache[key]
return nil unless entry
if Time.now - entry[:timestamp] > @ttl_seconds
@cache.delete(key)
return nil
end
entry[:value]
end
def set(key, value)
@cache[key] = { value: value, timestamp: Time.now }
end
def clear
@cache.clear
end
end
class SimpleCacheTest < Minitest::Test
def test_stores_and_retrieves_value
cache = SimpleCache.new(60)
cache.set('key1', 'value1')
assert_equal 'value1', cache.get('key1')
end
def test_returns_nil_for_missing_key
cache = SimpleCache.new(60)
assert_nil cache.get('nonexistent')
end
def test_expires_after_ttl
cache = SimpleCache.new(60)
Time.stub :now, Time.new(2025, 1, 1, 12, 0, 0) do
cache.set('key1', 'value1')
end
# 61 seconds later
Time.stub :now, Time.new(2025, 1, 1, 12, 1, 1) do
assert_nil cache.get('key1')
end
end
def test_does_not_expire_before_ttl
cache = SimpleCache.new(60)
Time.stub :now, Time.new(2025, 1, 1, 12, 0, 0) do
cache.set('key1', 'value1')
end
# 59 seconds later
Time.stub :now, Time.new(2025, 1, 1, 12, 0, 59) do
assert_equal 'value1', cache.get('key1')
end
end
def test_clear_removes_all_entries
cache = SimpleCache.new(60)
cache.set('key1', 'value1')
cache.set('key2', 'value2')
cache.clear
assert_nil cache.get('key1')
assert_nil cache.get('key2')
end
end
Testing an API client demonstrates mocking HTTP requests and handling various response scenarios.
class WeatherClient
def initialize(http_client)
@http_client = http_client
end
def current_temperature(city)
response = @http_client.get("/weather?city=#{city}")
raise 'API request failed' unless response.success?
data = JSON.parse(response.body)
data['temperature']
end
end
class WeatherClientTest < Minitest::Test
def test_returns_temperature_for_valid_city
http_mock = Minitest::Mock.new
response = OpenStruct.new(
success?: true,
body: '{"temperature": 72}'
)
http_mock.expect :get, response, ['/weather?city=Seattle']
client = WeatherClient.new(http_mock)
temperature = client.current_temperature('Seattle')
assert_equal 72, temperature
http_mock.verify
end
def test_raises_on_api_failure
http_mock = Minitest::Mock.new
response = OpenStruct.new(success?: false)
http_mock.expect :get, response, ['/weather?city=Seattle']
client = WeatherClient.new(http_mock)
assert_raises(RuntimeError, 'API request failed') do
client.current_temperature('Seattle')
end
end
end
Common Patterns
Arrange-Act-Assert structures each test into three distinct phases. The arrange phase sets up test fixtures and preconditions. The act phase executes the behavior under test. The assert phase verifies the outcome. This pattern makes tests readable and maintainable by clearly separating setup, execution, and verification.
def test_calculates_discount
# Arrange
cart = ShoppingCart.new
cart.add_item(Product.new(price: 100))
calculator = DiscountCalculator.new
# Act
discount = calculator.calculate(cart, coupon: '10PERCENT')
# Assert
assert_equal 10.0, discount
end
One assertion per test encourages focused tests that verify single behaviors. Multiple unrelated assertions in one test make failures ambiguous. When the first assertion fails, subsequent assertions never execute, hiding additional defects. Tests verifying multiple aspects of the same operation can include multiple assertions that all relate to the same behavior.
# Unfocused test - multiple unrelated assertions
def test_user_operations
user = User.create(email: 'test@example.com')
assert user.valid?
assert_equal 'member', user.role
assert user.can_login?
refute user.admin?
end
# Focused tests - one behavior each
def test_new_user_is_valid
user = User.create(email: 'test@example.com')
assert user.valid?
end
def test_new_user_has_member_role
user = User.create(email: 'test@example.com')
assert_equal 'member', user.role
end
def test_new_user_can_login
user = User.create(email: 'test@example.com')
assert user.can_login?
end
Test builders create complex objects with fluent interfaces that improve test readability. Builders provide default values and chainable methods for customization, reducing test setup verbosity.
class OrderBuilder
def initialize
@items = []
@shipping_address = default_address
@payment_method = default_payment
end
def with_item(product, quantity = 1)
@items << { product: product, quantity: quantity }
self
end
def to(address)
@shipping_address = address
self
end
def paid_with(payment_method)
@payment_method = payment_method
self
end
def build
Order.new(
items: @items,
shipping_address: @shipping_address,
payment_method: @payment_method
)
end
private
def default_address
Address.new(street: '123 Test St', city: 'Testville')
end
def default_payment
PaymentMethod.new(type: :credit_card)
end
end
# Usage
def test_applies_free_shipping_over_100
order = OrderBuilder.new
.with_item(Product.new(price: 120))
.to(Address.new(state: 'WA'))
.build
assert order.free_shipping?
end
Parameterized tests verify behavior across multiple input values without duplicating test code. Ruby supports parameterized testing through data-driven approaches.
class ValidationTest < Minitest::Test
def test_validates_email_formats
valid_emails = [
'user@example.com',
'user.name@example.com',
'user+tag@example.co.uk',
'user_123@example.com'
]
valid_emails.each do |email|
assert EmailValidator.valid?(email), "Expected #{email} to be valid"
end
end
def test_rejects_invalid_email_formats
invalid_emails = [
'invalid',
'@example.com',
'user@',
'user @example.com',
'user@.com'
]
invalid_emails.each do |email|
refute EmailValidator.valid?(email), "Expected #{email} to be invalid"
end
end
end
Custom assertions encapsulate complex verification logic into reusable methods. Custom assertions improve test readability and reduce duplication when the same verification pattern appears across multiple tests.
module CustomAssertions
def assert_valid_json(string, message = nil)
JSON.parse(string)
assert true
rescue JSON::ParserError
flunk message || "Expected valid JSON but got: #{string}"
end
def assert_includes_hash(collection, expected_hash, message = nil)
found = collection.any? { |item| expected_hash.all? { |k, v| item[k] == v } }
assert found, message || "Expected collection to include hash #{expected_hash}"
end
end
class ApiResponseTest < Minitest::Test
include CustomAssertions
def test_returns_valid_json_response
response = api_client.fetch_data
assert_valid_json response.body
end
def test_includes_expected_record
results = api_client.search(query: 'test')
assert_includes_hash results, { id: 123, name: 'Test Item' }
end
end
Tools & Ecosystem
Minitest ships with Ruby and requires no additional dependencies. The framework provides both classical xUnit-style tests and spec-style tests through minitest/spec. Minitest runs faster than alternatives due to its minimal feature set. The framework includes basic mocking through Minitest::Mock and stubbing through the stub method. Minitest's simplicity makes it ideal for projects avoiding external dependencies.
RSpec offers the most comprehensive testing framework in the Ruby ecosystem. The matcher library includes dozens of built-in matchers for common assertions. RSpec's let and subject helpers define memoized values that improve test performance and readability. The framework supports sophisticated mocking through the rspec-mocks library. RSpec's configuration system allows global test settings and metadata-driven test filtering.
# RSpec advanced features
RSpec.describe PaymentProcessor do
let(:processor) { described_class.new }
subject(:payment) { processor.charge(amount, card) }
describe '#charge' do
let(:amount) { 50.00 }
let(:card) { instance_double('CreditCard', valid?: true) }
it 'processes the payment' do
expect(payment).to be_successful
end
context 'with invalid card' do
let(:card) { instance_double('CreditCard', valid?: false) }
it 'raises an error' do
expect { payment }.to raise_error(PaymentError)
end
end
end
end
SimpleCov measures test coverage by tracking which lines execute during test runs. The gem generates HTML reports showing covered and uncovered code. Coverage reports identify untested code paths but don't guarantee test quality. High coverage percentages can exist alongside poor tests that execute code without meaningful assertions.
# SimpleCov configuration
require 'simplecov'
SimpleCov.start do
add_filter '/test/'
add_filter '/spec/'
add_group 'Models', 'app/models'
add_group 'Controllers', 'app/controllers'
add_group 'Services', 'app/services'
minimum_coverage 90
end
FactoryBot creates test objects with default attributes and customization options. Factories define associations, sequences, and traits for common variations. The gem reduces test setup code and maintains consistency across test data. FactoryBot strategies include build for unsaved objects, create for persisted objects, and build_stubbed for objects with IDs but no database interaction.
VCR records HTTP interactions during test runs and replays them in subsequent runs. The gem eliminates external service dependencies while maintaining realistic response data. VCR cassettes store request-response pairs in YAML files. Tests run faster with VCR because they avoid network latency. Configuration options control when cassettes record new interactions versus reusing existing ones.
# VCR configuration
require 'vcr'
VCR.configure do |config|
config.cassette_library_dir = 'spec/fixtures/vcr_cassettes'
config.hook_into :webmock
config.configure_rspec_metadata!
config.filter_sensitive_data('<API_KEY>') { ENV['API_KEY'] }
end
# Usage in tests
RSpec.describe WeatherService, vcr: true do
it 'fetches current conditions' do
weather = WeatherService.current('Seattle')
expect(weather.temperature).to be_a(Numeric)
end
end
WebMock stubs HTTP requests in tests without recording real responses. The gem blocks all external HTTP connections by default, forcing explicit stubs. WebMock supports request matching by URL patterns, headers, and body content. Stubbed responses define status codes, headers, and body content.
require 'webmock/minitest'
class ApiClientTest < Minitest::Test
def test_handles_rate_limiting
stub_request(:get, 'https://api.example.com/data')
.to_return(status: 429, body: 'Rate limit exceeded')
client = ApiClient.new
assert_raises(RateLimitError) { client.fetch_data }
end
end
Timecop controls time and date during tests by freezing, traveling, or scaling time. The gem prevents time-dependent test failures and makes temporal testing deterministic. Timecop methods include freeze for stopping time, travel for jumping to specific moments, and return for resetting to real time.
Common Pitfalls
Testing implementation instead of behavior couples tests to internal structure rather than external contracts. Tests that verify private method calls or internal state break when refactoring changes implementation without altering behavior. Tests should verify inputs and outputs, not the steps between them.
# Brittle test - tests implementation
def test_sends_notification_through_queue
notifier = Notifier.new
queue = notifier.instance_variable_get(:@queue)
notifier.send_alert('message')
assert_equal 1, queue.length # Assumes internal queue structure
end
# Better test - tests behavior
def test_delivers_notification
notifier = Notifier.new
email_spy = EmailSpy.new
notifier.send_alert('message', to: email_spy)
assert email_spy.received?('message')
end
Shared state between tests causes intermittent failures when test execution order changes. Tests that modify class variables, global state, or singleton objects affect subsequent tests. Ruby's class instance variables persist across test methods unless explicitly reset.
# Problem - shared state
class UserCounter
@count = 0
def self.increment
@count += 1
end
def self.total
@count
end
end
class UserCounterTest < Minitest::Test
def test_increments_count
UserCounter.increment
assert_equal 1, UserCounter.total # Fails if other tests ran first
end
end
# Solution - reset state in setup
class UserCounterTest < Minitest::Test
def setup
UserCounter.instance_variable_set(:@count, 0)
end
def test_increments_count
UserCounter.increment
assert_equal 1, UserCounter.total
end
end
Testing multiple concepts in one test makes failures ambiguous. When a test verifies unrelated behaviors and fails, developers cannot determine which behavior broke without reading the entire test. Focused tests communicate intent and simplify debugging.
Overusing mocks creates brittle tests that break from implementation changes. Every mock introduces coupling to internal structure. Tests with excessive mocking often indicate design problems in production code. Preferring real objects over mocks when possible produces more resilient tests.
Insufficient edge case coverage leaves defects undetected until production. Tests focusing on happy paths miss boundary conditions, null inputs, empty collections, and error scenarios. Ruby's dynamic typing increases the importance of testing type coercion and duck typing failures.
# Incomplete test coverage
def test_finds_user_by_email
user = User.create(email: 'test@example.com')
found = User.find_by_email('test@example.com')
assert_equal user, found
end
# Better coverage - includes edge cases
def test_finds_user_by_email_case_insensitive
user = User.create(email: 'test@example.com')
found = User.find_by_email('TEST@example.com')
assert_equal user, found
end
def test_returns_nil_when_user_not_found
found = User.find_by_email('nonexistent@example.com')
assert_nil found
end
def test_handles_nil_email
assert_nil User.find_by_email(nil)
end
def test_handles_empty_email
assert_nil User.find_by_email('')
end
Slow test suites discourage frequent test execution. Tests that interact with databases, file systems, or external services accumulate latency. Running tests before every commit becomes impractical when the suite takes minutes. Identifying and isolating slow tests preserves rapid feedback cycles.
Unclear test names obscure test intent and make failures harder to diagnose. Test names should describe the scenario and expected outcome. Names like test_user or test_validation provide no context. Descriptive names like test_rejects_duplicate_email_addresses communicate purpose.
# Poor test names
def test_user
# What aspect of user?
end
def test_validation
# Which validation?
end
# Better test names
def test_saves_user_with_valid_attributes
end
def test_rejects_user_without_email
end
def test_enforces_unique_email_addresses
end
Reference
Core Testing Concepts
| Concept | Definition | Application |
|---|---|---|
| Unit | Smallest testable component | Single method or class |
| Test Case | Individual test verifying specific behavior | One test method |
| Test Suite | Collection of related test cases | All tests for a class or module |
| Fixture | Test data or objects in known state | Setup data for tests |
| Assertion | Statement verifying expected outcome | assert_equal, expect().to eq() |
| Mock | Object simulating behavior with expectations | Verify method calls occurred |
| Stub | Object returning predetermined values | Replace dependencies |
| Spy | Object recording interactions for verification | Track method calls |
Minitest Assertions
| Assertion | Purpose | Example |
|---|---|---|
| assert | Verify truthy value | assert user.valid? |
| refute | Verify falsy value | refute user.admin? |
| assert_equal | Verify equality | assert_equal 5, result |
| assert_nil | Verify nil value | assert_nil user.deleted_at |
| assert_empty | Verify empty collection | assert_empty errors |
| assert_includes | Verify collection membership | assert_includes list, item |
| assert_raises | Verify exception raised | assert_raises(ArgumentError) { method } |
| assert_silent | Verify no output to stdout/stderr | assert_silent { method } |
| assert_in_delta | Verify float within tolerance | assert_in_delta 1.0, result, 0.01 |
| assert_match | Verify regex match | assert_match /pattern/, string |
RSpec Matchers
| Matcher | Purpose | Example |
|---|---|---|
| eq | Verify equality | expect(result).to eq(5) |
| be | Verify object identity | expect(obj).to be(same_obj) |
| be_nil | Verify nil | expect(value).to be_nil |
| be_empty | Verify empty collection | expect(list).to be_empty |
| include | Verify collection membership | expect(list).to include(item) |
| match | Verify regex match | expect(string).to match(/pattern/) |
| raise_error | Verify exception | expect { method }.to raise_error(ArgumentError) |
| change | Verify state change | expect { method }.to change { counter }.by(1) |
| be_between | Verify numeric range | expect(value).to be_between(1, 10) |
| respond_to | Verify method exists | expect(obj).to respond_to(:method_name) |
Test Organization Checklist
| Task | Implementation | Purpose |
|---|---|---|
| Arrange setup code | Use setup/before hooks | Initialize test fixtures |
| Execute one behavior | Single method call in act phase | Clear test focus |
| Verify outcomes | Assertions in assert phase | Confirm expected results |
| Clean up resources | Use teardown/after hooks | Prevent state pollution |
| Name tests descriptively | test_verb_noun_condition format | Document intent |
| Test edge cases | Boundary values, nil, empty | Comprehensive coverage |
| Isolate dependencies | Use mocks and stubs | Fast, reliable tests |
| Keep tests fast | Avoid I/O operations | Encourage frequent execution |
Common Test Smells
| Smell | Description | Solution |
|---|---|---|
| Long setup | Many lines of fixture creation | Simplify production code design |
| Multiple assertions | Testing unrelated behaviors | Split into focused tests |
| Conditional logic | If statements in tests | Create separate test cases |
| Test interdependence | Tests fail when run in isolation | Use setup/teardown properly |
| Slow execution | Tests take seconds | Remove external dependencies |
| Brittle tests | Break from refactoring | Test behavior not implementation |
| Mystery guest | Unclear fixture sources | Make test data explicit |
| Obscure test | Unclear what is being verified | Improve naming and structure |