Search

Software Engineer's Notes

Tag

Test Driven Development

Unit Testing: The What, Why, and How (with Practical Examples)

What is unit test?

What is a Unit Test?

A unit test verifies the smallest testable part of your software—usually a single function, method, or class—in isolation. Its goal is to prove that, for a given input, the unit produces the expected output and handles edge cases correctly.

Key characteristics

  • Small & fast: millisecond execution, in-memory.
  • Isolated: no real network, disk, or database calls.
  • Repeatable & deterministic: same input → same result.
  • Self-documenting: communicates intended behavior.

A Brief History (How We Got Here)

  • 1960s–1980s: Early testing practices emerged with procedural languages, but were largely ad-hoc and manual.
  • 1990s: Object-oriented programming popularized more modular designs. Kent Beck introduced SUnit for Smalltalk; the “xUnit” family was born.
  • Late 1990s–2000s: JUnit (Java) and NUnit (.NET) pushed unit testing mainstream. Test-Driven Development (TDD) formalized “Red → Green → Refactor.”
  • 2010s–today: Rich ecosystems (pytest, Jest, JUnit 5, RSpec, Go’s testing pkg). CI/CD and DevOps turned unit tests into a daily, automated safety net.

How Unit Tests Work (The Mechanics)

Arrange → Act → Assert (AAA)

  1. Arrange: set up inputs, collaborators (often fakes/mocks).
  2. Act: call the method under test.
  3. Assert: verify outputs, state changes, or interactions.

Test Doubles (isolate the unit)

  • Dummy: unused placeholders to satisfy signatures.
  • Stub: returns fixed data (no behavior verification).
  • Fake: lightweight implementation (e.g., in-memory repo).
  • Mock: verifies interactions (e.g., method X called once).
  • Spy: records calls for later assertions.

Good Test Qualities (FIRST)

  • Fast, Isolated, Repeatable, Self-Validating, Timely.

Naming & Structure

  • Name: methodName_condition_expectedResult
  • One assertion concept per test (clarity > cleverness).
  • Avoid coupling to implementation details (test behavior).

When Should We Write Unit Tests?

  • New code: ideally before or while coding (TDD).
  • Bug fixes: add a unit test that reproduces the bug first.
  • Refactors: guard existing behavior before changing code.
  • Critical modules: domain logic, calculations, validation.

What not to unit test

  • Auto-generated code, trivial getters/setters, framework wiring (unless it encodes business logic).

Advantages (Why Unit Test?)

  • Confidence & speed: safer refactors, fewer regressions.
  • Executable documentation: shows intended behavior.
  • Design feedback: forces smaller, decoupled units.
  • Lower cost of defects: catch issues early and cheaply.
  • Developer velocity: faster iteration with guardrails.

Practical Examples

Java (JUnit 5 + Mockito)

// src/test/java/com/example/PriceServiceTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

class PriceServiceTest {
    @Test
    void applyDiscount_whenVIP_shouldReduceBy10Percent() {
        DiscountPolicy policy = mock(DiscountPolicy.class);
        when(policy.discountFor("VIP")).thenReturn(0.10);

        PriceService service = new PriceService(policy);
        double result = service.applyDiscount(200.0, "VIP");

        assertEquals(180.0, result, 0.0001);
        verify(policy, times(1)).discountFor("VIP");
    }
}

// Production code (for context)
class PriceService {
    private final DiscountPolicy policy;
    PriceService(DiscountPolicy policy) { this.policy = policy; }
    double applyDiscount(double price, String tier) {
        return price * (1 - policy.discountFor(tier));
    }
}
interface DiscountPolicy { double discountFor(String tier); }

Python (pytest)

# app/discount.py
def apply_discount(price: float, tier: str, policy) -> float:
    return price * (1 - policy.discount_for(tier))

# tests/test_discount.py
class FakePolicy:
    def discount_for(self, tier):
        return {"VIP": 0.10, "STD": 0.0}.get(tier, 0.0)

def test_apply_discount_vip():
    from app.discount import apply_discount
    result = apply_discount(200.0, "VIP", FakePolicy())
    assert result == 180.0

In-Memory Fakes Beat Slow Dependencies

// In-memory repository for fast unit tests
class InMemoryUserRepo implements UserRepo {
    private final Map<String, User> store = new HashMap<>();
    public void save(User u){ store.put(u.id(), u); }
    public Optional<User> find(String id){ return Optional.ofNullable(store.get(id)); }
}

Integrating Unit Tests into Your Current Process

1) Organize Your Project

/src
  /main
    /java (or /python, /ts, etc.)
  /test
    /java ...

  • Mirror package/module structure under /test.
  • Name tests after the unit: PriceServiceTest, test_discount.py, etc.

2) Make Tests First-Class in CI

GitHub Actions (Java example)

name: build-and-test
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '21' }
      - run: ./gradlew test --no-daemon

GitHub Actions (Python example)

name: pytest
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.12' }
      - run: pip install -r requirements.txt
      - run: pytest -q

3) Define “Done” with Tests

  • Pull requests must include unit tests for new/changed logic.
  • Code review checklist: readability, edge cases, negative paths.
  • Coverage gate (sensible threshold; don’t chase 100%).
    Example (Gradle + JaCoCo):
jacocoTestCoverageVerification {
    violationRules {
        rule { limit { counter = 'INSTRUCTION'; minimum = 0.75 } }
    }
}
test.finalizedBy jacocoTestReport, jacocoTestCoverageVerification

4) Keep Tests Fast and Reliable

  • Avoid real I/O; prefer fakes/mocks.
  • Keep each test < 100ms; whole suite in seconds.
  • Eliminate flakiness (random time, real threads, sleeps).

5) Use the Test Pyramid Wisely

  • Unit (broad base): thousands, fast, isolated.
  • Integration (middle): fewer, verify boundaries.
  • UI/E2E (tip): very few, critical user flows only.

A Simple TDD Loop You Can Adopt Tomorrow

  1. Red: write a failing unit test that expresses the requirement.
  2. Green: implement the minimum to pass.
  3. Refactor: clean design safely, keeping tests green.
  4. Repeat; keep commits small and frequent.

Common Pitfalls (and Fixes)

  • Mock-heavy tests that break on refactor → mock only at boundaries; prefer fakes for domain logic.
  • Testing private methods → test through public behavior; refactor if testing is too hard.
  • Slow suites → remove I/O, shrink fixtures, parallelize.
  • Over-asserting → one behavioral concern per test.

Rollout Plan (4 Weeks)

  • Week 1: Set up test frameworks, sample tests, CI pipeline, coverage reporting.
  • Week 2: Add tests for critical modules & recent bug fixes. Create a PR template requiring tests.
  • Week 3: Refactor hot spots guided by tests. Introduce an in-memory fake layer.
  • Week 4: Add coverage gates, stabilize the suite, document conventions in CONTRIBUTING.md.

Team Conventions

  • Folder structure mirrors production code.
  • Names: ClassNameTest or test_function_behavior.
  • AAA layout, one behavior per test.
  • No network/disk/DB in unit tests.
  • PRs must include tests for changed logic.

Final Thoughts

Unit tests pay dividends by accelerating safe change. Start small, keep them fast and focused, and wire them into your daily workflow (pre-commit, CI, PR reviews). Over time, they become living documentation and your best shield against regressions.

Test Driven Development (TDD): A Complete Guide

Learning Test Driven Evelopment

What is Test Driven Development?

Test Driven Development (TDD) is a software development practice where tests are written before the actual code. The main idea is simple: first, you write a failing test that defines what the software should do, then you write just enough code to make the test pass, and finally, you improve the code through refactoring.

TDD encourages developers to focus on requirements and expected behavior rather than jumping directly into implementation details.

A Brief History of TDD

TDD is closely tied to Extreme Programming (XP), introduced in the late 1990s by Kent Beck. Beck emphasized automated testing as a way to improve software quality and developer confidence. While unit testing existed earlier, TDD formalized the cycle of writing tests before writing code and popularized it as a disciplined methodology.

How Does TDD Work?

TDD typically follows a simple cycle, often called Red-Green-Refactor:

  1. Red – Write a small test that fails because the functionality does not exist yet.
  2. Green – Write the minimum code required to pass the test.
  3. Refactor – Improve the code structure without changing its behavior, while keeping all tests passing.

This cycle is repeated for each new piece of functionality until the feature is fully developed.

Important Steps in TDD

  • Understand requirements clearly before starting.
  • Write a failing test case for the expected behavior.
  • Implement code to make the test pass.
  • Run all tests to ensure nothing else is broken.
  • Refactor code for clarity, performance, and maintainability.
  • Repeat for each new requirement or functionality.

Advantages of TDD

  • Ensures better code quality and fewer bugs.
  • Encourages modular and clean code design.
  • Provides a safety net for refactoring and adding new features.
  • Reduces debugging time since most errors are caught early.
  • Improves developer confidence and project maintainability.

Disadvantages of TDD

  • Initial learning curve can be steep for teams new to the practice.
  • Writing tests first may feel slower at the beginning.
  • Requires discipline and consistency; skipping steps reduces its effectiveness.
  • Not always practical for UI-heavy applications or experimental projects.

Should We Use TDD in Our Projects?

The decision depends on your project type, deadlines, and team maturity. TDD works best in:

  • Long-term projects that need high maintainability.
  • Systems requiring reliability and accuracy (e.g., finance, healthcare, safety systems).
  • Teams practicing Agile or XP methodologies.

For quick prototypes or proof-of-concepts, TDD might not always be the best choice.

Integrating TDD into the Software Development Cycle

  • Combine TDD with Agile or Scrum for iterative development.
  • Use Continuous Integration (CI) pipelines to automatically run tests on every commit.
  • Pair TDD with code review practices for stronger quality control.
  • Start with unit tests, then expand to integration and system tests.
  • Train your team with small exercises, such as Kata challenges, to build TDD discipline.

Conclusion

Test Driven Development is more than just writing tests; it’s a mindset that prioritizes quality, clarity, and confidence in your code. While it requires discipline and may feel slow at first, TDD pays off in the long run by reducing bugs, improving maintainability, and making your development process more predictable.

If your project values stability, collaboration, and scalability, then TDD is a powerful practice to adopt.

Blog at WordPress.com.

Up ↑