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

Unit, integration, and end-to-end (E2E) testing form a pyramid of verification: fast, cheap unit tests guard business logic; slower, more integration-focused tests verify interactions between components; and a smaller set of fragile but user-visible E2E tests ensure real-world flows work.

Unit tests: what to test and how

  • Focus on pure logic and edge cases. Test a function’s input-output behavior with representative inputs, including boundary and error conditions.
  • Isolate units: test them independently from dependencies via mocks or stubs. This keeps tests fast and deterministic.
  • Typical targets: validation rules, business logic gates, pure functions, small utilities, and domain rules.
  • What not to overdo: coverage for trivial getters/setters or trivial data transformations that have no risk.

Integration tests: what to verify between parts

  • Test interactions between modules, services, and external systems (databases, message queues, APIs) to ensure they work together as expected.
  • Validate data contracts: how data passes between layers, serialization/deserialization, and error handling when a downstream component misbehaves.
  • Use real or near-real dependencies when the test’s value justifies the cost; replace slow or flaky dependencies with stable test doubles when appropriate.
  • Focus on critical integration points rather than every combination; aim to cover the most business-critical flows and data paths.

E2E tests: user-centric and high-value

  • Exercise full user journeys from start to finish, ideally via public interfaces (APIs or UI) that mirror real usage.
  • Prioritize the most important user workflows that, if broken, would hurt the product’s value or user experience.
  • Keep tests resilient to UI changes: drive through APIs when possible, and use UI tests for stability-critical paths only.
  • Balance breadth and speed: a small set of high-signal journeys beats a large suite of brittle end-to-end tests.

Meaningful tests, not just coverage

  • Coverage is a byproduct, not a goal; measure value by how well tests prevent real defects and catch regressions in risk-prone areas.
  • Write tests that reveal real failures: cover business rules, edge cases, and consent/permission flows, not only “happy path” scenarios.
  • Align tests with requirements and user goals. If a failure would confuse users or break compliance, give that test priority.

Mocking strategies that pay off

  • Use mocks to isolate the unit under test, ensuring you verify interactions and inputs/outputs without hitting real dependencies.
  • Prefer precise, minimal mocks that reflect actual contract behavior; avoid over-mocking that hides integration issues.
  • Employ spies or fake implementations to observe calls and validate side effects without external effects.
  • Update mocks in tandem with API changes to prevent brittle tests when interfaces evolve.

Handling flaky tests

  • Identify flakiness sources: timing, uninitialized state, external services, or non-deterministic environments.
  • Stabilize tests: remove shared state, avoid time-based assertions, and synchronize on explicit events.
  • Quarantine flaky tests: run suspect tests in isolation or with extended retries, without blocking the main CI signal.
  • Increase resilience: add retries in CI for truly flaky tests after root-cause fixes; but ensure the root cause is addressed to prevent masking issues.

Testing in CI/CD

  • Automate test execution in a predictable, isolated environment that mirrors production constraints as much as possible.
  • Run a fast, parallelizable unit test suite first; gate longer integration and E2E runs behind a stable unit base.
  • Use feature flags and environment parity to avoid drift between local and CI environments.
  • Integrate flaky-test handling into CI: quarantine, retries, and dashboards highlighting flaky tests for triage.

Test-driven development (TDD/BDD)

  • TDD can help drive design and prevent over-engineering: write a failing test, implement just enough to pass, refactor.
  • Use BDD when you want test scenarios to reflect business language; map features to acceptance criteria and tests to user stories.
  • TDD/BDD value depends on team discipline and project complexity; it’s most beneficial when requirements are evolving or shared across teams.

Real-world examples

  • Example 1: A user registration flow
    • Unit: validate password strength logic, email format, and password-confirm matching.
    • Integration: ensure user service writes to the database, and email service queues a welcome message.
    • E2E: simulate a new user signing up and verifying email, then completing a profile, ensuring the UI reflects success and user state updates correctly.
  • Example 2: Order processing in an e-commerce system
    • Unit: discount calculation, inventory checks (pure logic), payment token validation.
    • Integration: order service communicates with payment gateway mocks, inventory service, and shipping API contracts.
    • E2E: end-to-end checkout from cart to order confirmation, including payment, inventory reservation, and notification.

What to test at each level (quick checklist)

  • Unit
    • Core business rules
    • Boundary conditions
    • Error paths and exceptions
    • Interaction contracts with dependencies via mocks
  • Integration
    • Data flow between layers (API, service, DB)
    • Collaboration of modules (service-to-service)
    • External system contracts (APIs, queues)
  • E2E
    • Critical user journeys
    • End-to-end data integrity across services
    • Performance under realistic loads (smaller scope, when feasible)

Test design patterns and practical tips

  • Test pyramid: emphasize unit tests, with a smaller but meaningful set of integration tests and a lean E2E suite.
  • Fixtures and data management: keep test data minimal, isolated, and reproducible; use factory patterns to generate consistent objects.
  • Idempotence: tests should be repeatable without side effects; reset state between runs.
  • Parallelization: run tests in parallel where possible but guard against shared mutable state that causes flakiness.
  • Observability: ensure tests emit clear, actionable failure messages and logs to diagnose issues quickly.

If you want, I can tailor a starter test plan for your project’s tech stack and provide example test cases in your preferred language/framework. Would you like a stack-specific plan (e.g., Node.js with Jest and Playwright, or Python with Pytest and Playwright), plus a sample 2-week rollout of unit/integration/E2E tests?

-

Rizwan Saleem | https://rizwansaleem.co

Top comments (0)