Testing and Code Review

Unit Tests Explained with Simple Examples

What a unit test is, how to structure one with arrange, act and assert, and how to choose cases to test, using small illustrative functions you can follow.

On this page
  1. What a unit test is
  2. A first example
  3. The structure of a test
  4. Choosing what to test
  5. Qualities of a good unit test
  6. Isolation
  7. Limits of unit tests
  8. Where to go next

What a unit test is

A unit test is a small automated check of one piece of code, called the unit, in isolation from the rest of the system. The unit is usually a single function or a small class. The test supplies an input, runs the unit, and compares the result with what you expected. If they match, the test passes; if not, it fails and reports the difference.

Unit tests are valued because they are fast, precise and repeatable. They typically run in a fraction of a second, so you can run hundreds on every change, and when one fails it points to a small area of code. They are the lowest level of testing, below integration and system tests.

A first example

Here is a small function, written purely as an illustration:

def celsius_to_fahrenheit(c):
    return c * 9 / 5 + 32

A test for it:

def test_freezing_point():
    assert celsius_to_fahrenheit(0) == 32

The test uses a value where you already know the answer: water freezes at 0 degrees Celsius, which is 32 degrees Fahrenheit. A second one:

def test_boiling_point():
    assert celsius_to_fahrenheit(100) == 212

Check by hand: 100 times 9 is 900, divided by 5 is 180, plus 32 is 212. Good. Both tests pass, and each has a name that says what it checks.

The expected values came from outside the code, not from running the function and copying its output. This matters. If you derive the expected result from the code itself, the test can only confirm that the code does what it does, which is not the same as doing what is right.

The structure of a test

Most unit tests follow three steps, often called arrange, act and assert.

  1. Arrange. Set up what the test needs: input values, objects, a prepared state.
  2. Act. Call the code being tested, once.
  3. Assert. Check that the result is what you expected.

Another example with a little more setup:

def total_price(prices, discount_percent):
    subtotal = sum(prices)
    return subtotal - subtotal * discount_percent / 100
def test_total_price_with_discount():
    # arrange
    prices = [10, 20, 30]
    # act
    result = total_price(prices, 10)
    # assert
    assert result == 54

Check: the subtotal is 10 + 20 + 30 = 60. Ten percent of 60 is 6. And 60 minus 6 is 54. The arithmetic confirms the expected value before the test is trusted. (With real money, floating-point rounding and currency rules need extra care, which this simplified example ignores.)

Keeping the three parts visibly separate makes tests easy to read, and a test that does several acts and many unrelated asserts is usually better split.

Choosing what to test

You cannot test every possible input. The skill is picking a small set of cases that would reveal likely mistakes. Think in categories:

  • Typical cases. Ordinary inputs the function will mostly see.
  • Boundaries. Values at the edges of what is valid: zero, one, the maximum, an empty list. Mistakes such as off-by-one errors tend to hide here.
  • Special cases. Values that change how the code behaves, such as negative numbers or a discount of exactly 100.
  • Invalid input. What should happen with missing, wrong-typed or nonsensical values? Perhaps an error should be raised; the test records that decision.
  • Past bugs. When you fix a defect, add a test that would have caught it.

For the total_price function, a thoughtful list might be: an empty list of prices (the total should be 0), a discount of 0 (no change), a discount of 100 (total should be 0), and a single price. Writing these makes you decide behaviour that the code may not yet handle, which is a real benefit.

Testing an error

import pytest

def average(values):
    if len(values) == 0:
        raise ValueError("no values")
    return sum(values) / len(values)

def test_average_of_empty_list_raises():
    with pytest.raises(ValueError):
        average([])

The test states that an empty list is an error and that the function should say so clearly instead of failing with a division by zero. The test tool here, pytest, is a widely used Python tool; other languages have their own equivalents, and the principle carries over.

Qualities of a good unit test

  • Fast. If tests are slow, people stop running them.
  • Independent. One test should not rely on another having run first.
  • Repeatable. The same code gives the same result every time, with no dependency on the clock, random numbers or the network unless those are controlled.
  • Focused. One behaviour per test, so a failure has one obvious meaning.
  • Readable. The name and body make clear what behaviour is expected. A good name reads like a sentence, such as test_total_price_is_zero_for_empty_list.
  • Tests behaviour, not internals. If the test breaks every time you restructure the code without changing what it does, it is too tightly bound to the implementation.

Isolation

The word “unit” implies the test should not depend on slow or unpredictable parts such as databases, files or network services. When the unit calls such things, the test often replaces them with simple stand-ins so the unit’s own logic is checked alone. Whether the real parts work together is a separate question, answered by integration testing.

Limits of unit tests

Passing unit tests show that the cases you thought of behave correctly. They do not show that the program as a whole is right, that the components fit together, or that you chose the right cases. A suite can run every line and still check little, which is the point made in the discussion of test coverage. Treat unit tests as a strong first layer rather than the only one.

Where to go next

Try writing three tests for a function you already have: one typical case, one boundary and one invalid input. If you find it hard to test, that is useful information about the design. Some people prefer to write tests first as they go, an approach known as test-driven development. The wider vocabulary of software testing helps place unit tests among the other kinds of checks.

The complete guide to testing and code review

Software Testing Concepts Every Engineer Should Know
Read the guide

Keep exploring

Guides from other topics

All topics →