Tldr

A unit test checks a small piece of software behaviour in a controlled setting. Test-driven development, usually shortened to TDD, is a development technique in which a programmer writes a failing test, writes enough production code to pass it, and then improves the design while keeping the tests passing. Unit tests and TDD provide useful evidence, but they cannot prove that an entire system is correct.

Why software needs tests

Software can produce the expected result for one example and still fail for another. A price calculation may work for an ordinary order but fail exactly at a discount threshold. A configuration parser may accept a normal interface name but crash on an empty line. A test makes an expectation explicit and checks it consistently.

A software test runs or examines part of a system to learn whether it behaves as expected. The expected result comes from a requirement, a business rule, a design decision, or a known property of the software.

Testing provides evidence about quality and risk. Passing tests show that the tested behaviours worked under the tested conditions. They do not establish that every possible input, interaction, failure, or attack has been covered.

What is a unit test?

A unit test checks a small unit of behaviour separately from most of the surrounding system. A unit might be a function, a method, a class, a module, or a small group of closely related objects. There is no universal rule that a unit must be one function.

The useful boundary is usually the smallest behaviour that can be checked quickly and clearly. If a test must contact a live database, call an external service, or start the complete application, it is testing a wider integration rather than an isolated unit.

Consider a function that calculates a shipping fee. A unit test could call the function with an order total of ₹500 and check that the result is ₹0. It does not need a payment gateway, browser, or delivery provider to verify that rule.

Unit tests and integration tests answer different questions

Test levelMain questionTypical dependencies
Unit testDoes this small behaviour produce the expected result by itself?In-memory values and controlled replacements
Integration testDo two or more real components exchange data and work together correctly?A database, file system, message broker, API, or subsystem
System testDoes the assembled application support an end-to-end scenario?Most or all of the deployed system

A dependency is something a unit relies on to do its work, such as a database, clock, file system, or another service. Integration and system tests are essential because isolated units can work correctly while their connections fail. Unit tests remain valuable because they usually run faster and identify the source of a problem more precisely.

Qualities of a useful unit test

Good unit tests tend to be:

  • Fast: Developers can run them frequently without interrupting their work.
  • Isolated: A failure in a database, network, or unrelated test does not change the result.
  • Repeatable: The same code and inputs produce the same result, regardless of test order or machine.
  • Self-checking: The test reports success or failure automatically. A person does not need to inspect the output manually.
  • Readable: Its name, setup, action, and expected result explain the rule being checked.
  • Focused: When it fails, there are few plausible causes to investigate.

A test whose result changes without a relevant code change is often called a flaky test. Flakiness may come from timing, shared data, random values, network access, or dependence on test order. Repeatedly rerunning a flaky test until it passes hides risk instead of resolving it.

The structure of a test

Many tests follow the Arrange-Act-Assert pattern:

  1. Arrange: Create the inputs and establish the starting conditions.
  2. Act: Run the behaviour being tested.
  3. Assert: Compare the actual result with the expected result.

This is a common way to make tests readable, not a requirement imposed by every language or testing framework.

def test_shipping_is_free_at_the_threshold():
    # Arrange
    order_total = 500
 
    # Act
    actual_fee = shipping_fee(order_total)
 
    # Assert
    assert actual_fee == 0

The example uses Python syntax. A function name beginning with test_ is a convention recognized by testing frameworks such as pytest. The assert statement checks a condition and reports a failure when the condition is false. Other languages and frameworks use different test and assertion syntax.

A single check like this is a test case: one set of starting conditions, actions, and expected results. Several test cases are usually needed for one rule.

Selecting useful test cases

It is rarely possible to test every input. A team selects cases that provide useful coverage of the behaviour and its risks:

  • a normal valid case
  • the smallest and largest allowed values
  • values immediately below, at, and above an important boundary
  • empty or missing values
  • invalid values and expected failures
  • a previously reported defect

For free shipping at ₹500, the values ₹499, ₹500, and ₹501 are more informative together than three arbitrary totals far from the threshold. This is boundary-value testing, which concentrates on points where behaviour changes.

An invalid input also needs a defined outcome. The function might reject a negative order total with a specific error. A test should check that contract rather than accept any crash as success.

Keeping a unit isolated

Suppose a function sends an email after calculating an invoice. Calling a real email provider in every unit test would make the test slow and dependent on a network service. The test can use a test double, a controlled replacement for a real dependency.

Common forms include:

  • A stub returns prepared data needed by the test. For example, a currency-rate stub always returns a known exchange rate.
  • A fake is a working but simplified implementation. An in-memory repository can replace a production database.
  • A mock records or checks an interaction. It can verify that an email service was asked to send one message to the correct address.

Terminology varies among tools, so the behaviour of the replacement matters more than its label. Excessive replacement can make a test pass even when real components cannot work together. Integration tests are still needed at important boundaries.

What test-driven development means

Test-driven development is a development technique that uses tests to guide small design and implementation decisions. It is commonly expressed as Red-Green-Refactor:

flowchart LR
    A["Red: write one test and see it fail"] --> B["Green: write the simplest code that passes"]
    B --> C["Refactor: improve the design while tests stay green"]
    C --> A

The colour names come from test tools that commonly show failures in red and passing tests in green.

1. Red: describe one missing behaviour

Write a small test for the next behaviour and run it. The test should fail for the expected reason. This step confirms that the test can detect the missing behaviour. A test that passes before the feature exists may be checking the wrong condition.

For the shipping example, the first test could state that an order of ₹500 receives free shipping.

2. Green: make the test pass

Write the simplest production code that correctly satisfies the current behaviour. Production code means the code that implements the application, in contrast with test code.

def shipping_fee(order_total):
    if order_total >= 500:
        return 0
    return 50

The goal of Green is a correct, understandable step. “Simplest” does not justify insecure code, hard-coded secrets, or ignoring a known requirement. It discourages speculative complexity before the next behaviour requires it.

3. Refactor: improve the structure

Refactoring changes the internal structure of code without intentionally changing its observable behaviour. A developer might remove duplication, improve a name, split a long function, or introduce a clearer abstraction.

Run the tests while refactoring. If they remain green, they provide evidence that the behaviours they cover have been preserved. If a test fails, the change is small enough to investigate immediately.

The cycle then begins again with another behaviour, such as:

def test_shipping_costs_50_below_the_threshold():
    assert shipping_fee(499) == 50

The team could later add a test showing that a negative total raises an error, then extend the implementation to satisfy that rule.

Important

TDD requires observing the new test fail. Writing tests after the production code can still produce a strong test suite, but it is test-after development rather than the Red-Green-Refactor workflow.

What TDD influences

Writing the example before the implementation forces several questions to become concrete:

  • What should the code be responsible for?
  • What input does it accept?
  • What result or error should it produce?
  • Can the behaviour be used without starting the entire system?
  • Is the interface clear from a caller’s perspective?

An interface in this context is the way other code interacts with a unit, such as its function name, parameters, return value, and possible errors. Designing through examples can produce smaller responsibilities and looser dependencies because tightly coupled code is difficult to test in isolation.

TDD is often associated with Extreme Programming, or XP, an Agile software-development approach that emphasizes short feedback cycles and disciplined engineering practices. TDD can also be used outside XP and outside a formal Agile framework.

Benefits and limitations

TDD can help withTDD cannot establish by itself
Clarifying the next small behaviour before implementationThat the selected requirement solves the user’s real problem
Detecting regressions in covered behaviourThat all possible regressions have been tested
Giving rapid feedback during implementation and refactoringThat databases, services, networks, and user interfaces integrate properly
Encouraging small interfaces and dependencies that can be controlledThat the full system is secure, accessible, reliable, or fast enough
Producing executable examples that help explain established rulesThat the architecture will meet every future need

A regression is an unintended loss of behaviour that previously worked. Tests are particularly useful when a later change accidentally breaks an earlier rule.

TDD works best when behaviour can be expressed through quick, deterministic examples. Exploratory user-interface work, data science experiments, hardware interaction, and legacy systems with tightly coupled dependencies may require preliminary design or restructuring before a useful TDD cycle is practical.

Code coverage

Code coverage measures which parts of the program were executed by a test suite. Statement coverage, for example, reports the percentage of executable statements that ran.

Coverage can reveal untested areas, but a high percentage does not show that the assertions are meaningful or that important risks were considered. A test could execute a calculation without checking its result. Teams should treat coverage as a diagnostic signal and pair it with review of behaviours, boundaries, failures, and system-level risks.

Where these practices fit in the SDLC

Unit testing and TDD are usually most visible during development, but their influence extends across the lifecycle:

  • Requirements: Examples help turn an ambiguous rule into observable behaviour.
  • Design: Testability exposes unclear responsibilities and tightly coupled dependencies.
  • Development: Tests provide rapid feedback as code is added and refactored.
  • Verification: Unit tests contribute one layer of the wider testing strategy.
  • Deployment: The suite can run as an automated check before a change is released.
  • Maintenance: Regression tests preserve knowledge about defects and established behaviour.

In Agile development, short feedback cycles make fast automated tests especially useful. A Scrum Team may include appropriate tests in its Definition of Done, although Scrum itself does not prescribe TDD or any particular testing technique.

Common mistakes

  • Testing implementation details: A test tied to private steps may fail after a safe refactoring even though public behaviour is unchanged.
  • Putting many behaviours in one test: A broad test is harder to understand and diagnose.
  • Ignoring the reason for a red test: A syntax error or broken setup does not prove that the intended behaviour is missing.
  • Sharing mutable state between tests: One test can affect another and make results order-dependent.
  • Calling live infrastructure from every unit test: The suite becomes slower and less predictable.
  • Mocking every collaborator: The test may verify an invented network of interactions instead of useful behaviour.
  • Chasing a coverage percentage: Executed lines are not equivalent to checked requirements.
  • Using only unit tests: Integration, system, security, performance, accessibility, and user acceptance testing address risks that unit tests cannot.
  • Treating tests as permanent truth: Requirements and designs change. Tests must be reviewed and maintained with the production code.

Summary

  • A unit test checks a small behaviour in a controlled setting.
  • Useful unit tests are fast, isolated, repeatable, self-checking, readable, and focused.
  • Arrange-Act-Assert is a common structure for making a test easy to follow.
  • Boundary, invalid, and failure cases deserve deliberate attention.
  • Test doubles control dependencies, while integration tests check the real connections.
  • TDD repeats Red, Green, and Refactor in small steps.
  • Refactoring improves structure without intentionally changing observable behaviour.
  • Passing tests and high coverage provide evidence, not a guarantee of correct software.
  • Unit testing belongs within a broader testing and risk-management strategy.

Sources