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
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
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');
});
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:
- Control side effects - verify your code handles mock responses correctly
- Test chaos scenarios - simulate failures (timeouts, errors) to ensure robustness
- Use mocking libraries - simplify mock creation and verification
- Verify mocks are configured properly - prevent unexpected behavior
- 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
Handling Flaky Tests
Flaky tests (passing sometimes, failing others) destroy team trust in CI/CD.
The Reproduce-Diagnose-Fix Loop:
- Reproduce: Run the test many times in CI until you observe failure frequency
- Diagnose: Use record/replay debugging and gather evidence over time
- 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:
- Run tests on every commit - Tests must be automated and mandatory
- Parallelize test suites - Split unit, integration, and E2E across workers
- Fail fast - Run unit tests first (fastest feedback), then integration, then E2E
- Block merges on failures - No merging if tests fail
- 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
Test-Driven Development (TDD)
TDD follows the red-green-refactor cycle:
- Write a failing test first (red)
- Write minimal code to pass (green)
- 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)