What Is Unit Testing?

Unit testing is the practice of testing the smallest testable parts of a software application — individual functions, methods, or classes — in complete isolation from the rest of the system. When you unit test a function, you call it with specific inputs and verify that it produces the expected output, with no database calls, no network requests, no file system access, and no dependency on other components.

Consider a function that calculates shipping cost:

function calculateShipping(weight, distance, isExpress):
    if weight <= 0 or distance <= 0:
        throw InvalidArgumentError

    baseCost = weight * 0.5 + distance * 0.1

    if isExpress:
        return baseCost * 1.5

    return baseCost

A unit test for this function would call it with known inputs and check the output:

test "standard shipping for 2kg, 100km":
    result = calculateShipping(2, 100, false)
    assert result == 11.0    // (2 * 0.5) + (100 * 0.1) = 11.0

test "express shipping multiplier":
    result = calculateShipping(2, 100, true)
    assert result == 16.5    // 11.0 * 1.5 = 16.5

test "negative weight throws error":
    assertThrows InvalidArgumentError:
        calculateShipping(-1, 100, false)

These tests verify the function’s behavior without starting a web server, connecting to a database, or involving any other part of the application.

The FIRST Principles

Good unit tests follow the FIRST principles, a framework that defines what separates effective unit tests from problematic ones.

Fast

Unit tests must execute in milliseconds, not seconds. A developer should be able to run the entire unit test suite after every code change without hesitation. If your suite of 500 unit tests takes 10 minutes, developers will stop running them. If it takes 3 seconds, they will run it constantly.

What slows tests down: Database connections, file I/O, network calls, sleep/wait statements, complex setup procedures.

How to keep tests fast: Use test doubles (mocks, stubs) to eliminate external dependencies. Test pure logic in isolation.

Independent

Each test must be able to run on its own, in any order, without depending on the outcome of another test. Test A should not create data that Test B relies on. Test C should not clean up state that Test D needs.

Bad pattern:

test "create user":
    user = createUser("alice@test.com")
    globalUser = user    // saves state for next test

test "update user email":
    globalUser.email = "new@test.com"    // depends on previous test
    update(globalUser)

Good pattern:

test "create user":
    user = createUser("alice@test.com")
    assert user.email == "alice@test.com"

test "update user email":
    user = createUser("bob@test.com")    // creates its own data
    user.email = "new@test.com"
    update(user)
    assert user.email == "new@test.com"

Repeatable

Running the same test 100 times must produce the same result every time. No randomness, no time-dependency, no reliance on external systems.

If a test passes on Monday but fails on Tuesday because it checks today's date == "Monday", it violates repeatability. If a test sometimes fails because a network call times out, it violates repeatability.

Self-Validating

Each test must have a clear pass/fail outcome with no human interpretation required. The test asserts an expected result, and the framework reports pass or fail. A test that prints output to the console for a human to review is not self-validating.

Timely

Unit tests should be written at the right time — ideally before or alongside the code they test, not weeks or months later. In TDD (Test-Driven Development), tests are written first, then code is written to make them pass.

Writing tests late leads to untestable code — functions with too many dependencies, hidden side effects, and tight coupling.

Test Doubles: Mocks, Stubs, and Fakes

Real-world code rarely exists in isolation. A function might call a database, send an email, or query an external API. Unit tests cannot use these real dependencies (they would be slow, unreliable, and expensive), so we use test doubles — stand-in objects that replace real dependencies during testing.

Stubs

A stub provides predetermined responses to method calls. It does not verify how it was called — it just returns what you tell it to return.

// Real dependency: PaymentGateway.charge(amount) -> boolean
// Stub: always returns true (simulates successful payment)

stubPaymentGateway.charge = () => return true

test "order is confirmed when payment succeeds":
    order = new Order(items, stubPaymentGateway)
    order.checkout()
    assert order.status == "confirmed"

Use stubs when you need to control what a dependency returns but do not care how it was called.

Mocks

A mock verifies that specific interactions occurred. It records method calls and lets you assert that the code under test called specific methods with specific arguments.

mockEmailService = new Mock(EmailService)

test "order confirmation sends email":
    order = new Order(items, mockEmailService)
    order.confirm()

    // Verify the mock was called correctly
    mockEmailService.verify("sendEmail")
        .wasCalledOnce()
        .withArguments("user@test.com", "Order Confirmed")

Use mocks when verifying behavior — that the code interacted with a dependency in the expected way.

Fakes

A fake is a working implementation that takes shortcuts. An in-memory database is a fake — it implements the same interface as the real database but stores data in memory instead of on disk.

// Real: PostgresUserRepository (connects to PostgreSQL)
// Fake: InMemoryUserRepository (stores in a HashMap)

fakeRepo = new InMemoryUserRepository()

test "find user by email":
    fakeRepo.save(new User("alice@test.com"))
    found = fakeRepo.findByEmail("alice@test.com")
    assert found != null
    assert found.email == "alice@test.com"

Use fakes when you need realistic behavior from a dependency but cannot use the real thing in tests.

When to Use Each

Test DoubleUse WhenVerifies
StubYou need to control return valuesNothing (just provides data)
MockYou need to verify interactionsMethod calls, arguments, call count
FakeYou need realistic behavior in memoryNothing (provides real behavior)

Code Coverage Basics

Code coverage measures how much of your source code is executed when the test suite runs. It is expressed as a percentage:

  • Line coverage: What percentage of lines were executed?
  • Branch coverage: What percentage of if/else branches were taken?
  • Function coverage: What percentage of functions were called?

A function with 10 lines and tests that execute 8 of those lines has 80% line coverage.

The 80% trap: Many teams set a coverage target (often 80%) and treat it as a quality gate. But coverage only measures whether code was executed, not whether it was tested correctly. You can achieve 100% coverage with tests that assert nothing:

test "bad test with full coverage":
    calculateShipping(2, 100, false)    // runs the code, asserts nothing

This test achieves coverage but catches zero bugs.

Useful coverage guidance:

  • Use coverage to find untested code, not to prove code is well-tested
  • Branch coverage is more valuable than line coverage
  • Focus on covering critical business logic, not utility code
  • 80-90% is a reasonable target; 100% is usually not worth the effort

Who Writes Unit Tests?

Developers write unit tests. This is not a QA activity. The developer who writes the function is best positioned to write its unit tests because they understand the intended behavior, edge cases, and implementation details.

QA engineers should:

  • Review unit test quality during code reviews
  • Identify missing test scenarios that developers might overlook
  • Advocate for adequate coverage of critical business logic
  • Understand unit test reports to know where quality risk exists

Exercise: Write Unit Test Scenarios for a Calculator

Consider this calculator module with four functions:

function add(a, b):
    return a + b

function divide(a, b):
    if b == 0:
        throw DivisionByZeroError
    return a / b

function percentage(value, percent):
    if percent < 0 or percent > 100:
        throw InvalidPercentageError
    return value * (percent / 100)

function compound(principal, rate, years):
    if principal < 0 or rate < 0 or years < 0:
        throw InvalidArgumentError
    return principal * (1 + rate) ^ years

Write test scenarios (name, input, expected output) for each function. Cover:

  • Happy path (normal operation)
  • Edge cases (zero, boundary values)
  • Error cases (invalid input)
  • Special values (very large numbers, decimals)
HintFor each function, think about: What is the simplest valid input? What happens at boundaries (0, negative numbers, very large numbers)? What inputs should cause errors? Are there precision issues with decimal arithmetic?
Solution

add(a, b) tests:

  1. add(2, 3)5 — basic positive numbers
  2. add(0, 0)0 — zero values
  3. add(-3, 5)2 — negative and positive
  4. add(-3, -7)-10 — both negative
  5. add(0.1, 0.2)0.3 — decimal precision (watch for floating-point issues!)
  6. add(999999999, 1)1000000000 — large numbers
  7. add(MAX_INT, 1) → check for overflow behavior

divide(a, b) tests:

  1. divide(10, 2)5 — basic division
  2. divide(10, 3)3.333... — non-integer result
  3. divide(0, 5)0 — zero numerator
  4. divide(10, 0) → throws DivisionByZeroError — division by zero
  5. divide(-10, 2)-5 — negative numerator
  6. divide(10, -2)-5 — negative denominator
  7. divide(-10, -2)5 — both negative
  8. divide(1, 3)0.333... — verify decimal precision

percentage(value, percent) tests:

  1. percentage(200, 50)100 — basic percentage
  2. percentage(100, 0)0 — zero percent (boundary)
  3. percentage(100, 100)100 — 100 percent (boundary)
  4. percentage(100, -1) → throws InvalidPercentageError — below range
  5. percentage(100, 101) → throws InvalidPercentageError — above range
  6. percentage(0, 50)0 — zero value
  7. percentage(99.99, 33.33)33.326667 — decimal precision

compound(principal, rate, years) tests:

  1. compound(1000, 0.05, 10)1628.89 — basic compound interest
  2. compound(1000, 0, 10)1000 — zero rate
  3. compound(1000, 0.05, 0)1000 — zero years
  4. compound(0, 0.05, 10)0 — zero principal
  5. compound(-1, 0.05, 10) → throws InvalidArgumentError — negative principal
  6. compound(1000, -0.01, 10) → throws InvalidArgumentError — negative rate
  7. compound(1000, 0.05, -1) → throws InvalidArgumentError — negative years
  8. compound(1000, 1.0, 30) → very large number — verify no overflow

Advanced Unit Testing Patterns

Arrange-Act-Assert (AAA)

Structure every unit test in three clear phases:

test "expired coupon is rejected":
    // Arrange — set up test data and dependencies
    coupon = new Coupon("SAVE10", expiryDate: "2024-01-01")
    validator = new CouponValidator(clock: fixedClock("2024-06-15"))

    // Act — execute the behavior being tested
    result = validator.validate(coupon)

    // Assert — verify the outcome
    assert result.isValid == false
    assert result.reason == "Coupon has expired"

This pattern makes tests readable and maintainable. Anyone can look at a test and immediately understand what is being set up, what action is taken, and what outcome is expected.

Parameterized Tests

When multiple test cases share the same logic but differ only in input/output, use parameterized tests to avoid duplication:

@parameterized([
    (2, 3, 5),
    (0, 0, 0),
    (-1, 1, 0),
    (100, 200, 300),
])
test "add returns sum of two numbers"(a, b, expected):
    assert add(a, b) == expected

One test definition, multiple data sets. Much cleaner than writing four separate test functions.

Test Naming Conventions

Good test names describe the scenario and expected outcome:

  • test_divide_by_zero_throws_error — clear and specific
  • test_expired_coupon_returns_invalid — describes behavior
  • test_new_user_gets_welcome_email — reads like a requirement

Bad test names say nothing useful:

  • test1 — meaningless
  • testDivide — what about divide?
  • testIt — test what?

Pro Tips

Tip 1: One assertion per test (usually). A test that asserts five different things is really five tests crammed into one. When it fails, you do not know which aspect broke. Exceptions exist for closely related assertions (e.g., checking both properties of a returned object).

Tip 2: Do not test implementation details. If you refactor a function without changing its behavior, zero tests should break. If your tests break because you renamed an internal variable, they are testing the wrong thing.

Tip 3: Use test doubles sparingly. Every mock is a lie about the real system. Too many mocks and your tests verify your mocking configuration, not your code. If a function is hard to test without many mocks, the function might need refactoring.

Key Takeaways

  • Unit tests verify individual functions in complete isolation
  • The FIRST principles (Fast, Independent, Repeatable, Self-validating, Timely) define quality
  • Test doubles (stubs, mocks, fakes) replace real dependencies in unit tests
  • Code coverage measures execution, not correctness — use it to find gaps, not prove quality
  • Developers write unit tests; QA reviews and identifies missing scenarios
  • The AAA pattern (Arrange-Act-Assert) keeps tests clean and readable