DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

How to test your code effectively — a practical testing tutorial

How to test your code effectively — a practical testing tutorial

Software Testing Strategies: A Practical Guide

The Testing Pyramid: What to Test at Each Level

A balanced testing strategy follows the 70/20/10 rule: 70% unit tests, 20% integration tests, and 10% end-to-end tests. This distribution maximizes feedback speed while catching the right types of bugs.

Unit Testing: Isolated Functions and Methods

What to test:

  • Individual functions, methods, and classes in isolation
  • Business logic and edge cases (boundaries, null inputs, empty collections)
  • Error conditions and exception handling

Key principles:

  • Tests must be fast, deterministic, and easy to understand
  • Each test should be small and focus on one behavior
  • Test both happy paths and unusual inputs

Real example:

### Test a discount calculator
def test_discount_50_percent_off():
    assert calculate_discount(100, 0.5) == 50

def test_discount_minimum_charge():
    assert calculate_discount(10, 0.5) == 5  # Edge case: small amount

def test_discount_invalid_percentage():
    with pytest.raises(ValueError):
        calculate_discount(100, 1.5)  # 150% is invalid
Enter fullscreen mode Exit fullscreen mode

Integration Testing: How Parts Work Together

What to test:

  • Data flow between components
  • API contract mismatches and data interaction problems
  • Database queries and external service interactions
  • Misconfigured dependencies

Key principles:

  • Test the interfaces between modules, not internal implementation
  • Use real dependencies where practical (real database, real API client)
  • Keep tests focused on specific integration points

Real example:

### Test user service with real database
def test_create_user_saves_to_database():
    user = user_service.create("john@example.com", "password123")
    assert user.id is not None
    assert db.query(User).filter_by(email="john@example.com").first() is not None

### Test API integration
def test_payment_gateway_integration():
    result = payment_client.charge(card_token="tok_123", amount=5000)
    assert result.status == "succeeded"
    assert result.transaction_id is not None
Enter fullscreen mode Exit fullscreen mode

End-to-End (E2E) Testing: Complete User Workflows

What to test:

  • Critical user paths: login flows, checkout processes, revenue-impacting workflows
  • Complete application behavior from a user perspective
  • Scenarios where failure directly impacts user trust

What NOT to test with E2E:

  • Every edge case (leads to slow, flaky suites)
  • Implementation details (let unit/integration tests handle these)
  • Non-critical features

Key principles:

  • Focus on 50 reliable tests rather than 500 flaky ones
  • Use stable selectors that don't break with UI changes
  • Implement explicit waits instead of hard-coded sleeps

Real example:

// Critical checkout flow
it('completes purchase end-to-end', async () => {
  await page.goto('/products');
  await page.click('#add-to-cart');
  await page.click('#checkout');
  await page.fill('#email', 'user@example.com');
  await page.fill('#card-number', '4242424242424242');
  await page.click('#submit-payment');

  await expect(page).toDisplayText('Order confirmed');
  await expect(page).toHaveURL('/order-confirmation');
});
Enter fullscreen mode Exit fullscreen mode

Writing Meaningful Tests, Not Just Coverage

High code coverage doesn't equal good tests. Prioritize value over numbers.

What Makes a Test Meaningful:

Quality Meaningful Test Empty Coverage
Purpose Tests business logic and user value Just hits code lines
Failure signal Clearly indicates what broke Vague error messages
Maintenance Easy to understand and update Fragile, complex setup
Coverage type Tests edge cases and errors Only happy paths
Independence Self-contained, no dependencies on other tests Relies on test order

Practical approach:

  • Test critical business logic first
  • Focus on user-facing functionality
  • Test error conditions thoroughly
  • Remove duplicate coverage and ineffective tests

Mocking Strategies: When and How

Mocks allow you to isolate logic units and test without external factors.

When to Mock:

Scenario Mock? Why
External APIs (Stripe, SendGrid) ✅ Yes Uncontrollable, slow, costs money
Database in unit tests ✅ Yes Keep tests fast and isolated
Database in integration tests ❌ No Test real interactions
Time/date functions ✅ Yes Ensure deterministic tests
Internal module functions ❌ No Test real behavior

Mocking Best Practices:

  1. Control side effects - verify your code handles mock responses correctly
  2. Test chaos scenarios - simulate failures (timeouts, errors) to ensure robustness
  3. Use mocking libraries - simplify mock creation and verification
  4. Verify mocks are configured properly - prevent unexpected behavior
  5. Update mocks when dependencies change - keep tests accurate

Real example:

from unittest.mock import Mock, patch

def test_send_welcome_email_handles_failure():
    # Mock the email service
    mock_email = Mock()
    mock_email.send.side_effect = ConnectionError("Service down")
    # Test error handling
    with patch('services.email', mock_email):
        result = user_service.register("user@test.com")
        assert result.email_sent == False
        assert result.notification_queued == True  # Falls back to queue
Enter fullscreen mode Exit fullscreen mode

Handling Flaky Tests

Flaky tests (passing sometimes, failing others) destroy team trust in CI/CD.

The Reproduce-Diagnose-Fix Loop:

  1. Reproduce: Run the test many times in CI until you observe failure frequency
  2. Diagnose: Use record/replay debugging and gather evidence over time
  3. Fix: Address root cause (timing, race conditions, shared state)

Common Causes and Fixes:

Cause Solution
Timing issues Use explicit waits, not sleep()
Shared state Each test sets up/cleans its own data
Race conditions Add proper synchronization, test deterministically
Network latency Mock external services, add retries for transient failures
Environment issues Containerize test environments for consistency

Quarantine strategy:

  • When a test fails intermittently, quarantine it immediately
  • Either fix it or delete it-don't leave it in the main suite
  • Track confirmed flaky tests and prioritize fixes

Testing in CI/CD

Tests that don't run automatically get ignored.

CI/CD Integration Best Practices:

  1. Run tests on every commit - Tests must be automated and mandatory
  2. Parallelize test suites - Split unit, integration, and E2E across workers
  3. Fail fast - Run unit tests first (fastest feedback), then integration, then E2E
  4. Block merges on failures - No merging if tests fail
  5. Report failures clearly - Make it easy to identify what broke

Pipeline Structure:

Commit → Lint → Unit Tests (2 min) → Build → Integration Tests (5 min) → Deploy to Staging → E2E Tests (10 min) → Production
Enter fullscreen mode Exit fullscreen mode

Test-Driven Development (TDD)

TDD follows the red-green-refactor cycle:

  1. Write a failing test first (red)
  2. Write minimal code to pass (green)
  3. Refactor while keeping tests green

When TDD is worth it:

  • Complex business logic with clear rules
  • Algorithms where correctness is critical
  • Public APIs where contract stability matters
  • Features you fully understand upfront

When TDD is NOT worth it:

  • Exploratory prototyping
  • UI-heavy features with uncertain requirements
  • One-off scripts or throwaway code
  • When you're still discovering what to build

When Tests Are Worth It

Not all code needs equal testing investment. Focus on:

High Testing Investment Low Testing Investment
Core business logic Simple getters/setters
Payment/checkout flows Third-party library wrappers
Security-critical code Prototypes/POCs
Public APIs One-time migration scripts
Complex algorithms UI layout (test critical paths only)
Features affecting revenue Deprecated/legacy code

Rule of thumb: Invest testing effort proportional to risk and frequency of change.

Key Takeaways

  • Follow the 70/20/10 pyramid: mostly unit tests, fewer integration tests, fewest E2E tests
  • Focus E2E on critical user paths (login, checkout) - not every edge case
  • Test for value, not coverage numbers - prioritize business logic
  • Mock external services in unit tests; use real dependencies in integration tests
  • Quarantine flaky tests immediately - fix or delete, don't tolerate
  • Automate everything in CI/CD - tests must run on every commit
  • Use TDD for complex logic, skip it for exploration

The goal isn't maximum coverage-it's confidence that critical features work while maintaining development speed.


Rizwan Saleem — https://rizwansaleem.co

Top comments (0)