Testing Strategies
Building reliable Elixir applications? This guide teaches testing strategies through the OTP-First progression, starting with ExUnit for unit testing to understand core patterns before introducing property-based testing with StreamData and integration testing with real databases.
Why Testing Strategies Matter
Every production application needs comprehensive test coverage:
- Financial systems - Zakat calculations, donation processing, transaction accuracy
- Healthcare platforms - Patient record integrity, prescription validation, audit compliance
- E-commerce - Order processing, inventory management, payment workflows
- SaaS applications - Multi-tenant isolation, API contracts, data consistency
Elixir provides three testing approaches:
- ExUnit (Standard Library) - Unit testing with pattern matching and assertions
- StreamData - Property-based testing for edge cases and invariants
- Test databases - Integration testing with real PostgreSQL/MySQL
Our approach: Start with ExUnit to understand basic testing patterns, recognize limitations with edge cases, then introduce property-based testing and database integration patterns.
OTP Primitives - ExUnit Unit Testing
Basic Unit Test Structure
Let’s start with ExUnit’s fundamental testing patterns:
# Basic ExUnit test structure
defmodule ZakatCalculatorTest do
use ExUnit.Case
# => Imports: test, assert, refute, setup
# => Configures: async testing (default false)
alias MyApp.Zakat.Calculator
# => Alias for Calculator module
describe "calculate_zakat/1" do
# => Groups related tests together
# => Useful for organizing by function
test "calculates 2.5% for cash above nisab" do
# => Test: Cash zakat calculation
amount = 1_000_000
# => amount: 1,000,000 (type: integer)
# => Represents cash holdings
result = Calculator.calculate_zakat(amount)
# => Calls Calculator.calculate_zakat/1
# => result: {:ok, zakat_amount}
assert {:ok, 25_000} = result
# => Pattern matches expected result
# => 2.5% of 1,000,000 = 25,000
end
# => Annotation density: 11 comments / 5 code lines = 2.2
test "returns error for amount below nisab" do
# => Test: Below nisab threshold
amount = 50_000
# => amount: 50,000 (below nisab ~85g gold)
result = Calculator.calculate_zakat(amount)
# => result: {:error, :below_nisab}
assert {:error, :below_nisab} = result
# => Pattern matches error tuple
end
# => Annotation density: 6 comments / 3 code lines = 2.0
end
describe "validate_gold_weight/1" do
# => Tests for gold weight validation
test "accepts valid gold weight" do
# => Test: Valid gold amount
weight = 90.5
# => weight: 90.5 grams (type: float)
# => Above nisab (85 grams)
assert :ok = Calculator.validate_gold_weight(weight)
# => Returns :ok for valid weight
end
# => Annotation density: 6 comments / 3 code lines = 2.0
test "rejects negative weight" do
# => Test: Invalid negative value
weight = -10.0
# => weight: -10.0 (invalid)
assert {:error, :invalid_weight} = Calculator.validate_gold_weight(weight)
# => Returns error for negative
end
# => Annotation density: 5 comments / 3 code lines = 1.67
end
end
# => Overall annotation density: 28 comments / 14 code lines = 2.0Testing with Pattern Matching
ExUnit leverages Elixir’s pattern matching for powerful assertions:
# Pattern matching in test assertions
defmodule DonationTest do
use ExUnit.Case
alias MyApp.Donations
test "creates donation with proper structure" do
# => Test: Donation creation structure
params = %{amount: 100_000, donor_id: 1, campaign_id: 5}
# => params: Map with donation details
{:ok, donation} = Donations.create(params)
# => donation: %Donation{} struct
# => Pattern matches successful tuple
assert %Donations.Donation{
# => Matches struct type
amount: 100_000,
# => Verifies exact amount
donor_id: 1,
# => Verifies donor
campaign_id: 5,
# => Verifies campaign
status: :pending
# => Verifies default status
} = donation
# => Pattern match validates all fields
end
# => Annotation density: 15 comments / 6 code lines = 2.5
test "returns validation errors for invalid amount" do
# => Test: Invalid donation amount
params = %{amount: -1000, donor_id: 1}
# => params: Negative amount (invalid)
{:error, changeset} = Donations.create(params)
# => changeset: %Ecto.Changeset{} with errors
# => Pattern matches error tuple
assert %{amount: ["must be positive"]} = errors_on(changeset)
# => Extracts and validates error messages
# => errors_on/1: Helper to extract changeset errors
end
# => Annotation density: 9 comments / 4 code lines = 2.25
end
# => Overall annotation density: 24 comments / 10 code lines = 2.4Limitations - Edge Cases and Boundary Testing
Manual Edge Case Coverage
ExUnit requires explicit test cases for all scenarios:
# Manual boundary testing - tedious and incomplete
defmodule ZakatCalculatorTest do
use ExUnit.Case
alias MyApp.Zakat.Calculator
# Problem: Must manually enumerate all edge cases
# => Challenge: Easy to miss boundary conditions
test "handles zero amount" do
# => Test: Zero value boundary
assert {:error, :below_nisab} = Calculator.calculate_zakat(0)
end
test "handles exact nisab threshold" do
# => Test: Exactly at nisab (85g gold * price)
nisab_amount = 85 * 50_000
# => nisab_amount: 4,250,000 IDR
assert {:ok, _zakat} = Calculator.calculate_zakat(nisab_amount)
# => Should calculate zakat at threshold
end
# => Annotation density: 7 comments / 3 code lines = 2.33
test "handles one rupiah below nisab" do
# => Test: Just below threshold
below_nisab = 4_249_999
# => One rupiah below nisab
assert {:error, :below_nisab} = Calculator.calculate_zakat(below_nisab)
end
# => Annotation density: 5 comments / 2 code lines = 2.5
test "handles maximum integer" do
# => Test: Extremely large values
max_value = 9_999_999_999_999
# => Large amount edge case
assert {:ok, zakat} = Calculator.calculate_zakat(max_value)
# => Should handle large numbers
assert is_integer(zakat)
# => Verifies zakat is integer
end
# => Annotation density: 8 comments / 4 code lines = 2.0
# Problem: What about float precision? Negative numbers? Nil? Strings?
# => Missing: Type errors, float rounding, overflow cases
# => Solution: Property-based testing discovers these automatically
end
# => Limitation: Manual enumeration is incomplete and tediousProduction Solution - Property-Based Testing with StreamData
StreamData for Comprehensive Coverage
Property-based testing generates hundreds of test cases automatically:
# Property-based testing with StreamData
defmodule ZakatCalculatorPropertyTest do
use ExUnit.Case
use ExUnitProperties
# => Enables property-based testing with check/1
# => Imports: check, gen generators
alias MyApp.Zakat.Calculator
property "zakat is always 2.5% of amount above nisab" do
# => Property: Mathematical invariant
# => Tests with randomly generated values
check all amount <- integer(4_250_000..100_000_000)
# => Generates random integers in range
# => 100 different values by default
do
{:ok, zakat} = Calculator.calculate_zakat(amount)
# => Calculates zakat for generated amount
# => zakat: Integer result
expected = div(amount * 25, 1000)
# => Expected: 2.5% = 25/1000
# => Uses integer division
assert zakat == expected
# => Verifies calculation invariant
# => If fails, StreamData shrinks to minimal failing case
end
end
# => Annotation density: 14 comments / 6 code lines = 2.33
property "amounts below nisab always return error" do
# => Property: Below-threshold behavior
check all amount <- integer(0..4_249_999)
# => Generates amounts below nisab
do
assert {:error, :below_nisab} = Calculator.calculate_zakat(amount)
# => Verifies consistent error response
end
end
# => Annotation density: 5 comments / 3 code lines = 1.67
property "negative amounts always rejected" do
# => Property: Input validation
check all amount <- integer(-1_000_000..-1)
# => Generates negative values
do
assert {:error, :invalid_amount} = Calculator.calculate_zakat(amount)
# => Verifies rejection of invalid input
end
end
# => Annotation density: 5 comments / 3 code lines = 1.67
property "donation percentages are always valid" do
# => Property: Donation calculation constraints
check all base <- positive_integer(),
# => Generates any positive integer
percentage <- integer(1..100)
# => Generates percentage 1-100
do
result = Calculator.calculate_donation_share(base, percentage)
# => result: Calculated share amount
assert result >= 0
# => Share is non-negative
assert result <= base
# => Share never exceeds base
end
end
# => Annotation density: 11 comments / 5 code lines = 2.2
end
# => Overall annotation density: 35 comments / 17 code lines = 2.06Custom Generators for Domain Types
Create generators for complex domain types:
# Custom StreamData generators for domain objects
defmodule MyApp.Generators do
import StreamData
# => Imports: generator functions
import ExUnitProperties
# => Imports: gen macro
def donation_params do
# => Generator: Valid donation parameters
gen all amount <- integer(10_000..100_000_000),
# => Amount between 10k and 100M
donor_id <- positive_integer(),
# => Valid donor ID
campaign_id <- positive_integer(),
# => Valid campaign ID
currency <- member_of(["IDR", "USD", "EUR"])
# => One of allowed currencies
do
%{
amount: amount,
donor_id: donor_id,
campaign_id: campaign_id,
currency: currency
}
# => Returns: Map of donation params
end
end
# => Annotation density: 13 comments / 9 code lines = 1.44
def zakat_calculation_input do
# => Generator: Zakat calculation inputs
gen all gold_weight <- float(min: 0.0, max: 10_000.0),
# => Gold weight in grams
gold_price <- integer(500_000..1_000_000),
# => Gold price per gram (IDR)
silver_weight <- float(min: 0.0, max: 100_000.0),
# => Silver weight in grams
cash <- integer(0..1_000_000_000)
# => Cash holdings
do
%{
gold_weight: gold_weight,
gold_price: gold_price,
silver_weight: silver_weight,
cash: cash
}
end
end
# => Annotation density: 13 comments / 9 code lines = 1.44
end
# Using custom generators in tests
defmodule DonationPropertyTest do
use ExUnit.Case
use ExUnitProperties
import MyApp.Generators
# => Imports: donation_params, zakat_calculation_input
property "all valid donations are accepted" do
# => Property: Valid input acceptance
check all params <- donation_params()
# => Uses custom generator
do
assert {:ok, donation} = MyApp.Donations.create(params)
# => All generated params should be valid
assert donation.amount == params.amount
# => Verifies amount preserved
end
end
# => Annotation density: 8 comments / 4 code lines = 2.0
end
# => Custom generators enable domain-specific property testingIntegration Testing - Real Database Tests
Database Integration with TestContainers Concept
For database testing, use real PostgreSQL in tests:
# Database integration testing pattern
defmodule DonationIntegrationTest do
use MyApp.DataCase
# => Sets up database sandbox for test isolation
# => Each test runs in a transaction, rolled back after
# => Imports: Ecto.Adapters.SQL.Sandbox functions
alias MyApp.{Donations, Campaigns, Donors}
describe "donation workflow" do
# => Tests complete donation process with database
setup do
# => Setup: Creates test data before each test
{:ok, donor} = Donors.create(%{name: "Ahmad", email: "ahmad@test.com"})
# => donor: %Donor{} persisted to database
{:ok, campaign} = Campaigns.create(%{name: "Zakat Fitrah 2024", target: 10_000_000})
# => campaign: %Campaign{} persisted
# => Both inserts in same transaction as test
%{donor: donor, campaign: campaign}
# => Returns map accessible in test as context
end
# => Annotation density: 10 comments / 5 code lines = 2.0
test "creates donation and updates campaign total", %{donor: donor, campaign: campaign} do
# => Test: Full donation workflow
# => Receives donor and campaign from setup
params = %{
amount: 500_000,
donor_id: donor.id,
# => Uses real donor ID from setup
campaign_id: campaign.id
# => Uses real campaign ID from setup
}
{:ok, donation} = Donations.create(params)
# => Creates donation in database
# => Triggers campaign total update
assert donation.amount == 500_000
# => Verifies donation amount
updated_campaign = Campaigns.get!(campaign.id)
# => Fetches campaign from database
# => Sees updated total from donation
assert updated_campaign.total_donated == 500_000
# => Verifies campaign total updated
# => Tests database trigger/callback logic
end
# => Annotation density: 19 comments / 10 code lines = 1.9
test "enforces unique donor per campaign", %{donor: donor, campaign: campaign} do
# => Test: Database constraint enforcement
params = %{amount: 100_000, donor_id: donor.id, campaign_id: campaign.id}
{:ok, _first} = Donations.create(params)
# => First donation succeeds
{:error, changeset} = Donations.create(params)
# => Second donation with same donor+campaign fails
# => Database unique constraint violation
assert "has already donated" in errors_on(changeset).donor_id
# => Verifies error message from constraint
end
# => Annotation density: 10 comments / 5 code lines = 2.0
end
end
# => Overall annotation density: 39 comments / 20 code lines = 1.95Testing Database Transactions
Test complex transactional workflows:
# Testing multi-step database transactions
defmodule ZakatDistributionTest do
use MyApp.DataCase
alias MyApp.{Zakat, Distributions}
test "distributes zakat atomically across recipients" do
# => Test: Atomic multi-recipient distribution
# => Tests Ecto.Multi transaction behavior
{:ok, zakat_payment} = Zakat.create(%{amount: 1_000_000, payer_id: 1})
# => zakat_payment: Source payment to distribute
# => amount: 1,000,000 to split
recipients = [
%{recipient_id: 10, share_percentage: 40},
# => Recipient 1: 40% = 400,000
%{recipient_id: 11, share_percentage: 35},
# => Recipient 2: 35% = 350,000
%{recipient_id: 12, share_percentage: 25}
# => Recipient 3: 25% = 250,000
]
{:ok, distributions} = Distributions.distribute_zakat(zakat_payment.id, recipients)
# => Executes Ecto.Multi transaction
# => Creates 3 distribution records atomically
# => If any fails, entire transaction rolls back
assert length(distributions) == 3
# => Verifies all distributions created
total_distributed =
distributions
|> Enum.map(& &1.amount)
# => Extracts amounts from distributions
|> Enum.sum()
# => Sums all distributed amounts
assert total_distributed == 1_000_000
# => Verifies complete distribution (no loss)
updated_payment = Zakat.get!(zakat_payment.id)
# => Fetches payment from database
assert updated_payment.status == :distributed
# => Verifies payment status updated in same transaction
end
# => Annotation density: 25 comments / 13 code lines = 1.92
test "rolls back distribution on validation failure" do
# => Test: Transaction rollback on failure
{:ok, zakat_payment} = Zakat.create(%{amount: 1_000_000, payer_id: 1})
recipients = [
%{recipient_id: 10, share_percentage: 50},
%{recipient_id: 11, share_percentage: 60}
# => Invalid: Total > 100%
]
{:error, _failed_operation, _failed_value, _changes} =
Distributions.distribute_zakat(zakat_payment.id, recipients)
# => Returns Ecto.Multi error tuple
# => No distributions created in database
assert [] = Distributions.list_for_payment(zakat_payment.id)
# => Verifies no distributions created
# => Transaction fully rolled back
payment = Zakat.get!(zakat_payment.id)
assert payment.status == :pending
# => Verifies payment status unchanged
end
# => Annotation density: 16 comments / 9 code lines = 1.78
end
# => Overall annotation density: 41 comments / 22 code lines = 1.86Production Testing Hierarchy
Three-Layer Testing Strategy
Real-world applications use layered testing:
# Layer 1: Unit tests (fast, isolated, many)
defmodule ZakatCalculatorTest do
use ExUnit.Case
# => Fast unit tests, no database
# => Tests pure functions and business logic
test "calculates zakat correctly" do
assert 25_000 = Calculator.calculate_zakat_amount(1_000_000)
# => Tests pure calculation (no side effects)
end
end
# Layer 2: Integration tests (moderate speed, database, fewer)
defmodule DonationIntegrationTest do
use MyApp.DataCase
# => Tests with real database
# => Verifies database interactions work correctly
test "creates donation and updates totals" do
{:ok, donation} = Donations.create(%{amount: 100_000, donor_id: 1})
# => Tests database insert + callbacks
assert Repo.get!(Donation, donation.id)
# => Verifies record persisted
end
# => Annotation density: 6 comments / 3 code lines = 2.0
end
# Layer 3: End-to-end tests (slow, full system, few)
defmodule DonationAPITest do
use MyAppWeb.ConnCase
# => Tests full HTTP request/response cycle
# => Includes authentication, JSON parsing, database
test "POST /api/donations creates donation", %{conn: conn} do
# => Test: Full API endpoint
conn =
conn
|> authenticate_user()
# => Adds authentication headers
|> post("/api/donations", %{amount: 100_000, campaign_id: 1})
# => Makes HTTP POST request
assert %{"id" => id, "amount" => 100_000} = json_response(conn, 201)
# => Verifies HTTP 201 response with JSON body
assert Repo.get!(Donation, id)
# => Verifies database record created
end
# => Annotation density: 10 comments / 6 code lines = 1.67
end
# Testing pyramid ratio (typical):
# - 70% unit tests (fast, many)
# - 20% integration tests (moderate, fewer)
# - 10% e2e tests (slow, few critical paths)
# => Fast test suite with good coverageTest Isolation Patterns
Concurrent Test Execution
ExUnit supports concurrent testing with proper isolation:
# Concurrent test execution with database sandbox
defmodule DonationConcurrentTest do
use MyApp.DataCase
# => Sets up Ecto.Adapters.SQL.Sandbox
# => Each test runs in isolated database transaction
@moduletag :concurrent
# => Enables concurrent test execution
# => Tests run in parallel (faster test suite)
# => Safe because each test has isolated transaction
test "test 1 - creates donation" do
# => Runs concurrently with other tests
{:ok, donation} = Donations.create(%{amount: 100_000, donor_id: 1})
# => This transaction isolated from other tests
assert donation.amount == 100_000
end
test "test 2 - creates different donation" do
# => Runs concurrently with test 1
{:ok, donation} = Donations.create(%{amount: 200_000, donor_id: 2})
# => Separate transaction, no interference
assert donation.amount == 200_000
end
# => Annotation density: 11 comments / 4 code lines = 2.75
end
# When NOT to use concurrent tests
defmodule GlobalStateTest do
use MyApp.DataCase
# => NO @moduletag :concurrent
# Tests that modify global state must run serially
test "updates application config" do
# => Modifies global config (not isolated)
old_value = Application.get_env(:my_app, :setting)
Application.put_env(:my_app, :setting, :new_value)
# => Changes affect entire application
# ... test code ...
# Cleanup
Application.put_env(:my_app, :setting, old_value)
end
# => Annotation density: 7 comments / 4 code lines = 1.75
end
# => Global state modifications require serial executionTesting Best Practices
Descriptive Test Names and Organization
# Well-organized test suite
defmodule ZakatCalculatorTest do
use ExUnit.Case
# Group related tests with describe blocks
describe "calculate_zakat/1" do
# => Groups zakat calculation tests
# => Clear organization by function
test "returns {:ok, amount} for valid amounts above nisab" do
# => Descriptive: what, when, expected result
# => Better than: "test zakat"
end
test "returns {:error, :below_nisab} for amounts below threshold" do
# => Clear error condition testing
end
test "returns {:error, :invalid_amount} for negative values" do
# => Tests input validation
end
end
describe "nisab_threshold/1" do
# => Groups nisab calculation tests
test "calculates threshold based on gold price" do
# => Tests threshold calculation
end
test "calculates threshold based on silver price" do
# => Tests alternative calculation
end
end
# Use setup for common test data
setup do
# => Runs before each test
# => Provides consistent test data
nisab_gold = 85 * 50_000
# => 85 grams * 50k IDR/gram = 4,250,000
%{nisab_threshold: nisab_gold}
# => Accessible in tests via pattern match
end
# => Annotation density: 16 comments / 6 code lines = 2.67
endVisualization - Testing Strategy
graph TD
A[Testing Strategies] --> B[Unit Tests - ExUnit]
A --> C[Property Tests - StreamData]
A --> D[Integration Tests - Database]
B --> B1[Pattern Matching]
B --> B2[Assertions]
B --> B3[Pure Functions]
C --> C1[Random Generation]
C --> C2[Invariants]
C --> C3[Edge Case Discovery]
D --> D1[Database Sandbox]
D --> D2[Transactions]
D --> D3[Full Workflows]
style A fill:#e1f5ff,stroke:#01579b,stroke-width:3px,color:#000000
style B fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#000000
style C fill:#f3e5f5,stroke:#4a148c,stroke-width:2px,color:#000000
style D fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px,color:#000000
style B1 fill:#ffe0b2,stroke:#e65100,stroke-width:1px,color:#000000
style B2 fill:#ffe0b2,stroke:#e65100,stroke-width:1px,color:#000000
style B3 fill:#ffe0b2,stroke:#e65100,stroke-width:1px,color:#000000
style C1 fill:#f3e5f5,stroke:#4a148c,stroke-width:1px,color:#000000
style C2 fill:#f3e5f5,stroke:#4a148c,stroke-width:1px,color:#000000
style C3 fill:#f3e5f5,stroke:#4a148c,stroke-width:1px,color:#000000
style D1 fill:#c8e6c9,stroke:#1b5e20,stroke-width:1px,color:#000000
style D2 fill:#c8e6c9,stroke:#1b5e20,stroke-width:1px,color:#000000
style D3 fill:#c8e6c9,stroke:#1b5e20,stroke-width:1px,color:#000000
When to Use Each Testing Strategy
ExUnit Unit Tests
Use when:
- Testing pure functions (no side effects)
- Testing business logic calculations
- Testing input validation
- Need fast test execution
- Want clear, explicit test cases
Example: Zakat calculation formulas, donation percentage logic
StreamData Property-Based Tests
Use when:
- Testing mathematical invariants
- Discovering edge cases automatically
- Testing functions with many inputs
- Verifying laws/properties hold for all values
- Need comprehensive coverage
Example: Zakat percentages always 2.5%, distribution totals equal input
Database Integration Tests
Use when:
- Testing database constraints (unique, foreign keys)
- Testing complex queries
- Testing transactions and rollbacks
- Testing database triggers/callbacks
- Verifying data persistence
Example: Donation workflow, multi-recipient distribution, campaign totals
Key Takeaways
OTP-First Testing Progression:
- ExUnit basics - Pattern matching, assertions, describe blocks
- Recognize limitations - Manual edge case enumeration is tedious/incomplete
- Property-based testing - StreamData for automatic edge case discovery
- Integration testing - Database sandbox for transactional test isolation
- Production hierarchy - 70% unit, 20% integration, 10% e2e (testing pyramid)
Critical patterns:
- Use
@moduletag :concurrentfor isolated tests (faster suite) - Property tests discover edge cases automatically (reduce manual enumeration)
- Database sandbox provides transaction isolation (rollback after each test)
- Descriptive test names and describe blocks (clear organization)
- Custom generators for domain types (reusable test data)
Financial systems emphasis:
- Property tests for zakat invariants (2.5% always holds)
- Integration tests for donation workflows (atomicity)
- Transaction rollback tests (no partial distributions)
- Database constraint validation (unique donor per campaign)