Testing & Debugging (Unit, Integration, Selenium)

Chapter 4
Unit Testing and Testable Code

Bachelors Program in Full Stack Development
Testing & Debugging (Unit, Integration, Selenium)   •   Bachelor

Unit Testing and Testable Code

Introduction

Unit testing is the disciplined practice of verifying the smallest meaningful pieces of software—usually functions, methods, or classes—in isolation from external systems. In full stack development, unit tests provide rapid feedback about business rules, data transformation, validation, authorization decisions, and user-interface logic. They complement integration and end-to-end tests rather than replacing them.

A good unit test is isolated, deterministic, fast, readable, and focused on observable behaviour. A test should fail when the behaviour is wrong and pass when it is correct. It should not fail because of network availability, database state, clock differences, random values, execution order, or an unrelated implementation refactoring.

This chapter uses Python and pytest for examples, but the principles apply to JavaScript testing frameworks such as Jest or Vitest, Java with JUnit, and C# with xUnit or NUnit.

Learning Objectives

By the end of this chapter, you should be able to:

  • distinguish unit tests from integration and system tests;
  • design isolated and deterministic tests;
  • use assertions, fixtures, parameterization, and test doubles;
  • apply dependency injection to improve testability;
  • use mocks carefully without coupling tests to implementation details;
  • interpret coverage and mutation-testing results;
  • apply test-driven development (TDD); and
  • identify and repair brittle tests.

The Unit-Test Model

A unit test normally follows the Arrange–Act–Assert pattern:

  1. Arrange: construct inputs, collaborators, and required state.
  2. Act: invoke the unit under test.
  3. Assert: verify the observable result or interaction.

The unit may be a pure function, a domain object, or a service whose collaborators have been replaced by test doubles. The test boundary is a design decision. A test for a tax-calculation function can be completely isolated. A test for an order service may isolate payment and inventory gateways while retaining the service's business logic.

A useful quality model is often expressed as the five properties below:

PropertyMeaning
IsolatedExternal resources and unrelated units do not influence the result.
DeterministicThe same inputs produce the same result repeatedly.
FastIt can run frequently during development.
FocusedFailure identifies a small behavioural problem.
MaintainableThe test survives harmless implementation changes.

Unit tests generally verify a contract: given specified conditions, a unit produces a specified outcome or interaction. They should prefer public behaviour over private implementation details.

Assertions and Behavioural Contracts

An assertion compares an actual result with an expected condition. Examples in pytest include:

def test_discount_is_applied_to_eligible_customer():
    total = calculate_total(price=100, discount_rate=0.10)
    assert total == 90

Assertions should communicate intent. assert total == 90 is clearer than asserting several internal variables. For floating-point calculations, exact equality may be inappropriate because binary representation can introduce small errors. A framework comparison such as pytest.approx(90.0) is suitable when a tolerance is justified.

Exception behaviour is also part of a contract:

import pytest

def test_negative_price_is_rejected():
    with pytest.raises(ValueError, match="price"):
        calculate_total(price=-1, discount_rate=0)

A test should normally assert one coherent behaviour, not necessarily one line of code. Excessive assertions can make a test difficult to diagnose, while too few assertions can allow defects to pass unnoticed.

Pure Functions and Determinism

Pure functions are especially testable because their output depends only on explicit inputs and they have no observable side effects. For example:

def apply_discount(amount, rate):
    if amount < 0 or not 0 <= rate <= 1:
        raise ValueError("invalid amount or rate")
    return amount * (1 - rate)

The function's assumptions are explicit: amount is non-negative and rate lies between 0 and 1. A test suite should include normal, boundary, and invalid inputs.

Non-determinism commonly comes from time, randomness, environment variables, process state, concurrency, and external services. Instead of allowing a function to call the system clock directly, inject a clock:

from datetime import date

def is_expired(expiry_date, today):
    return expiry_date < today

Production code can pass the current date; tests can pass a fixed date. This is preferable to sleeping in a test or changing global time.

Fixtures and Test Setup

A fixture is reusable setup or teardown supplied by a testing framework. In pytest:

import pytest

@pytest.fixture
def empty_cart():
    return Cart()

def test_new_cart_has_zero_items(empty_cart):
    assert empty_cart.item_count == 0

Fixtures reduce duplication, but excessive fixture nesting can hide important inputs. A reader should be able to understand the essential conditions of a test without tracing a large network of indirect setup. Use small, purpose-specific fixtures and keep mutable state isolated between tests.

Test data builders and factory functions are useful when objects require many fields:

def make_user(**overrides):
    user = {"id": 1, "role": "customer", "active": True}
    user.update(overrides)
    return user

A builder expresses meaningful variation better than repeated positional arguments. Avoid realistic-looking but unexplained data; tests should reveal which values matter.

Parameterized Tests and Boundary Analysis

Parameterized tests execute one test definition against several input sets:

import pytest

@pytest.mark.parametrize("quantity, expected", [
    (0, 0),
    (1, 10),
    (3, 30),
])
def test_line_total(quantity, expected):
    assert line_total(unit_price=10, quantity=quantity) == expected

Parameterization is effective for equivalence classes and boundary values. For a quantity constrained to 0 through 100, representative classes may include a valid middle value, 0, 100, -1, and 101. The goal is not to enumerate every value but to test distinct behavioural rules.

Boundary-value analysis is particularly important because errors often occur at transitions: inclusive versus exclusive comparisons, empty collections, minimum permissions, and maximum field lengths. Each parameter should have a clear reason for inclusion.

Test Doubles

A test double replaces a real collaborator. The principal types are:

DoublePurpose
DummyPassed only to satisfy an argument requirement.
StubReturns controlled data.
SpyRecords calls for later inspection.
MockVerifies expected interactions, often with configured behaviour.
FakeA working but simplified implementation, such as an in-memory repository.

These categories describe intent; frameworks may use the word “mock” broadly. A stub is appropriate when the test needs a controlled response. A mock or spy is appropriate when the interaction itself is part of the contract—for example, ensuring that a cancellation sends one notification.

Mocks should not be used merely to reproduce the internal call sequence. A test that asserts every private helper call may fail after a harmless refactoring. Prefer state and externally visible effects where possible.

Dependency Injection and Seams

Dependency injection means supplying a component's collaborators rather than constructing them internally. Consider:

class OrderService:
    def __init__(self, payment_gateway, order_repository):
        self.payment_gateway = payment_gateway
        self.order_repository = order_repository

    def place(self, order):
        self.payment_gateway.charge(order.total)
        self.order_repository.save(order)
        return order.id

A test can inject controlled collaborators:

from unittest.mock import Mock

def test_place_charges_and_saves_order():
    payment = Mock()
    repository = Mock()
    order = Mock(total=50, id=7)

    result = OrderService(payment, repository).place(order)

    assert result == 7
    payment.charge.assert_called_once_with(50)
    repository.save.assert_called_once_with(order)

This test is valuable because charging and saving are meaningful interactions. However, it exposes a design question: if saving fails after charging, the system may need compensation, an outbox, or a transaction boundary. Unit testing can reveal architectural risks, but it cannot prove that a distributed operation is atomic.

A seam is a place where behaviour can be replaced without changing the code under test. Constructor parameters, function arguments, interfaces, and adapters are common seams. Dependency injection should serve a clear design boundary, not produce excessive abstractions for trivial functions.

Mocking: Benefits and Risks

Mocking enables isolation from slow, expensive, unavailable, or destructive collaborators. It is useful for payment gateways, email services, message brokers, clocks, and random-number sources. Nevertheless, mocks can create false confidence:

  • a mock may return data that the real service never returns;
  • a mocked call may satisfy the test while the integration is misconfigured;
  • interaction assertions can couple tests to implementation details; and
  • over-mocking can test the test author's imagined design rather than actual behaviour.

Use contract tests or integration tests to verify boundaries with real protocols. For repositories, a small in-memory fake may be more expressive than a complex mock. For external APIs, validate request and response schemas and maintain a limited number of integration tests.

Test-Driven Development

TDD is a development cycle:

  1. Red: write a failing test for a small desired behaviour.
  2. Green: implement the simplest code that passes it.
  3. Refactor: improve design while preserving passing behaviour.

For a password-strength rule, a developer might first write tests for empty, short, and acceptable passwords. The initial failure clarifies the expected contract. The implementation is then improved without changing the tests' behavioural meaning.

TDD is not synonymous with “write tests after every line,” nor does it guarantee good architecture. Its value lies in short feedback loops, explicit examples, and design pressure toward small interfaces. Tests written after implementation can be equally valuable when they are based on requirements rather than current code structure.

Coverage Analysis

Coverage measures which parts of a program were executed by a test suite. Common forms include:

  • Statement coverage: proportion of executable statements run;
  • Branch coverage: proportion of decision outcomes exercised;
  • Function or method coverage: proportion of functions entered; and
  • Condition coverage: whether Boolean subconditions take relevant values.

A simple statement-coverage percentage can be represented as:

Cs = (Ne ÷ Nt) × 100%

where Cs is statement coverage, Ne is the number of executable statements executed, and Nt is the total number of executable statements measured. This metric is a descriptive ratio, not a probability of correctness. It assumes the tool consistently defines and counts executable statements.

For example, if 90 of 100 measured statements execute, coverage is 90%. The remaining 10 statements may be harmless defensive code—or critical error handling. High coverage can coexist with weak assertions, and 100% coverage does not prove that all input combinations or requirements are correct.

Coverage should therefore guide investigation. Examine uncovered risk, branch outcomes, error paths, and business-critical rules. Do not set a target that encourages meaningless tests simply to increase a percentage.

Mutation Testing

Mutation testing evaluates test effectiveness by making small deliberate changes, called mutants, to production code. Examples include replacing > with >=, changing a return value, or removing a condition. A strong test suite should fail when a meaningful mutant changes behaviour.

The mutation score is commonly calculated as:

M = K ÷ (K + S) × 100%

where M is the mutation score, K is the number of killed mutants, and S is the number of surviving, non-equivalent mutants. Equivalent mutants—changes that do not alter observable behaviour—must be excluded from the denominator. Mutation tools may also report timed-out or excluded mutants separately.

Mutation testing is more expensive than ordinary coverage analysis, so it is best applied to critical modules or periodically in continuous integration. A surviving mutant may indicate a missing assertion, an untested boundary, or a requirement that is not clearly specified.

Avoiding Brittle Tests

A brittle test fails for reasons unrelated to a behavioural defect. Frequent causes include:

  • asserting exact timestamps, generated identifiers, or random values;
  • depending on test execution order;
  • sharing mutable global state;
  • using real networks or databases in unit tests;
  • asserting private method names or call order unnecessarily;
  • comparing large serialized responses when only two fields matter; and
  • using arbitrary sleeps to wait for an event.

Replace sleeps with controllable clocks, explicit synchronization, or a higher-level integration test. Compare normalized data when ordering is not part of the contract. Reset state through fixtures, and make tests independent so they can run alone or in parallel.

A practical test review asks: “What legitimate refactoring would make this fail?” If the answer is “renaming a private helper,” the test is probably too coupled. If the answer is “violating the user-visible rule,” the test is likely well targeted.

Applied Case: Testing an Online Order Service

Background

An illustrative e-commerce service calculates an order total, checks stock, charges a payment method, saves the order, and emits a confirmation event.

Problem

The first implementation directly constructs a database client, calls the system clock, and sends email inside one large method. Tests are slow and fail intermittently when the database or email provider is unavailable.

Analysis

The method combines domain calculation, persistence, payment, time, and notification. These are separate responsibilities and external dependencies. The team identifies the business contract: an order with valid items and available stock may be placed; payment must be charged once; the order records the supplied creation time; and confirmation is emitted only after successful payment and persistence.

Approach

The team extracts a pure calculate_total function and injects interfaces for inventory, payment, repository, clock, and event publishing. Unit tests cover zero and multiple quantities, invalid prices, stock rejection, payment failure, and successful placement. Fakes are used for the repository and inventory; a mock verifies the single payment charge and event publication. The clock is supplied as a fixed object.

A separate integration test uses a real database adapter to verify schema mapping and transaction behaviour. A small contract test validates the payment gateway adapter against the provider's request and response rules. Selenium or another browser-level tool is reserved for the critical checkout journey rather than every pricing rule.

Outcome

Tests run quickly and consistently. A mutation that changes the stock comparison from > to >= is killed by a boundary test. Coverage reveals that payment-failure handling was initially untested. The team also discovers that the unit tests cannot prove payment and persistence are atomic; this becomes an architectural requirement for integration testing.

Learning Points

Testability is primarily a design property. Isolation improves feedback, but realistic boundary verification remains necessary. Unit tests establish business-rule confidence, whereas integration tests establish collaboration confidence and browser tests establish a limited amount of journey confidence.

Professional, Ethical, and Contemporary Considerations

Testing is part of software quality governance. Tests should protect users from incorrect charges, unauthorized access, data loss, and discriminatory decisions. Sensitive production data should not be copied into test environments without authorization and suitable masking. Test logs must not expose passwords, payment credentials, health data, or access tokens.

Automated test generation and AI coding assistants can propose cases, fixtures, and mutation targets. Their output requires human review: generated tests may merely repeat the implementation, encode incorrect assumptions, or omit security and boundary cases. Teams should record requirements, review generated code, and avoid uploading confidential source or customer data to unauthorized services.

Modern continuous-integration systems run fast unit tests on every change, broader integration tests at appropriate stages, and mutation or performance checks periodically. Parallel execution increases speed but makes hidden shared state more visible. Flaky tests should be treated as defects in the delivery process, not routinely retried until they pass; retries can conceal real failures.

Best-Practice Checklist

  • Name tests by behaviour and expected outcome.
  • Keep each test independent and repeatable.
  • Prefer pure functions and explicit dependencies.
  • Use fixtures to clarify, not conceal, setup.
  • Parameterize meaningful equivalence classes and boundaries.
  • Mock external effects selectively.
  • Test public contracts rather than private implementation details.
  • Use coverage to find risk, not as a sole quality target.
  • Use mutation testing to assess assertion strength.
  • Combine unit, integration, and end-to-end tests strategically.
  • Protect confidential data in test code, logs, and environments.

Chapter Summary

Unit testing verifies small software units in isolation and provides fast feedback. Reliable tests follow Arrange–Act–Assert, use explicit assertions, control time and randomness, and avoid shared state. Fixtures and parameterization reduce duplication while exposing boundaries and equivalence classes. Test doubles—including stubs, spies, mocks, and fakes—help isolate dependencies, but excessive mocking creates implementation-coupled tests and false confidence. Dependency injection creates useful seams and improves design.

TDD uses red, green, and refactor cycles to clarify behaviour and encourage cohesive interfaces. Coverage describes execution, not correctness; mutation testing more directly examines whether tests detect meaningful changes. Professional teams combine unit tests with integration and end-to-end tests, manage flaky tests as defects, and protect sensitive data. Effective unit testing is therefore both a coding technique and an architectural discipline.

Key Terms

Unit test: A test of a small software unit in isolation.

Deterministic test: A test that produces the same result under the same conditions.

Assertion: A statement verifying an expected condition.

Fixture: Reusable test setup or teardown.

Parameterization: Running one test definition with multiple input sets.

Test double: A replacement for a real collaborator in a test.

Stub: A double that supplies controlled responses.

Spy: A double that records interactions.

Mock: A double used to configure or verify interactions.

Fake: A simplified but functioning implementation.

Dependency injection: Supplying collaborators from outside a component.

Seam: A replaceable boundary in software design.

TDD: Test-driven development using red, green, and refactor.

Statement coverage: The proportion of executable statements run by tests.

Branch coverage: The proportion of decision outcomes exercised.

Mutation testing: Testing tests by introducing deliberate code changes.

Brittle test: A test that fails because of irrelevant changes or conditions.

Contract test: A test that verifies an interaction or interface agreement.

Flaky test: A test whose result changes without a relevant code change.

Review Questions

Short Answer Questions

  1. What are the three stages of Arrange–Act–Assert?
  2. Why is a pure function usually easy to unit test?
  3. Distinguish a stub from a fake.
  4. What does dependency injection achieve?
  5. Why does high statement coverage not prove correctness?

Long Answer Questions

  1. Explain how fixtures and parameterized tests can improve a pytest test suite while also introducing maintenance risks.
  2. Compare mocks, spies, stubs, and fakes, giving an appropriate use case for each.
  3. Explain TDD and evaluate its advantages and limitations in a full stack project.
  4. Discuss how coverage analysis and mutation testing provide different evidence about test quality.
  5. Describe the design changes needed to make a service that directly uses a database, clock, and email provider unit-testable.

Analytical / Application Questions

  1. A test passes locally but fails intermittently in continuous integration. Identify at least four likely causes and propose a diagnostic approach.
  2. An order service test verifies twenty internal method calls but never checks the final order state. Analyze why this test may be brittle and redesign its testing strategy.
  3. A module reports 98% statement coverage, yet a mutant changing an inclusive stock boundary survives. What does this reveal, and which tests should be added?

Multiple Choice Questions with Answers

Question 1

Which property means that a unit test gives the same result when run repeatedly with the same conditions?

A. Determinism

B. Parameterization

C. Mutation

D. Serialization

Correct Answer: A. Determinism

Question 2

In Arrange–Act–Assert, which stage invokes the unit under test?

A. Arrange

B. Act

C. Assert

D. Teardown

Correct Answer: B. Act

Question 3

Which test double provides controlled responses without primarily verifying interactions?

A. Stub

B. Spy

C. Mock

D. Browser driver

Correct Answer: A. Stub

Question 4

What is the principal benefit of dependency injection for unit testing?

A. It removes the need for assertions

B. It supplies replaceable collaborators

C. It guarantees complete coverage

D. It makes all tests end-to-end

Correct Answer: B. It supplies replaceable collaborators

Question 5

A suite executes 90 of 100 measured statements. Using the chapter’s coverage definition, what is its statement coverage?

A. 9%

B. 10%

C. 90%

D. 190%

Correct Answer: C. 90%

Question 6

What does mutation testing primarily evaluate?

A. How quickly a database responds

B. Whether tests detect meaningful code changes

C. Whether all functions have documentation

D. Whether a browser supports JavaScript

Correct Answer: B. Whether tests detect meaningful code changes

Question 7

Which practice most directly reduces dependence on the current system clock?

A. Adding a sleep call

B. Injecting a clock or date value

C. Increasing test retries

D. Using a larger fixture

Correct Answer: B. Injecting a clock or date value

Question 8

Which statement best describes a fake?

A. A value passed only to satisfy an argument

B. A simplified working implementation

C. A test that always fails

D. A private method assertion

Correct Answer: B. A simplified working implementation

Question 9

What is a common risk of excessive mocking?

A. Tests become coupled to implementation details

B. Tests cannot contain assertions

C. Production code becomes automatically secure

D. Coverage tools stop measuring statements

Correct Answer: A. Tests become coupled to implementation details

Question 10

What is the first step in the TDD cycle?

A. Refactor production code

B. Deploy to production

C. Write a test that fails

D. Create a browser test

Correct Answer: C. Write a test that fails

Testing & Debugging (Unit, Integration, Selenium)
Chapter 4 of 14