Innovate | Connect | Transform
ContactService statusUSD $

GICT knowledge base

Unit Tests Explained: Write Code You Can Actually Trust

Published

Programming
photo_6147878739858100381_y

You write a billing function that calculates monthly server costs. You run it a few times, it works, you deploy it. Three months later someone inputs a negative number and the billing report outputs a negative invoice. No error. No crash. Just wrong data that already went to accounting. That is the kind of bug manual testing misses and unit tests catch in seconds. Here is how they work and how to write them in Python.

What a Unit Test Is

A unit test is a small piece of code that checks whether another piece of code behaves correctly. You write it, you run it, it either passes or fails. If it passes, the function does what the test expects. If it fails, something in the function changed and the test caught it.

The “unit” part means you test one thing in isolation. You do not bring in a real database. You do not call a live API. You mock those things so the test only touches the code you actually care about verifying.

One important thing a test does not do: prove your code is correct. A passing test proves the behaviors you thought to test are intact. There can always be behaviors you did not think to test. That is not a flaw in testing, that is just reality. Tests are a safety net, not a proof.

Why Manual Testing Breaks Down

Manual testing works fine when you are writing a script you run once. It falls apart when you start dealing with actual applications. Here is why.

First, edge cases. When you test manually, you test the happy path. You run it with normal inputs and it works. You do not think to try an empty list, a negative number, or a string where a number should be. Those edge cases are exactly where bugs live.

Second, regressions. You ship something, it works, you come back three months later and make a change. You test the new requirement and it works. You ship it. The old requirement is now broken. You had no way to know. A test suite catches that instantly.

Third, speed. If you have to manually run through a checklist every time you deploy, you will stop running the checklist. Tests that run automatically do not have this problem.

A Function That Will Break

Here is a Python function that calculates monthly server cost. It takes a list of services with their hourly rates and returns the total plus a 10% platform fee.

def calculate_monthly_cost(services):
    subtotal = sum(s['hourly_cost'] * 730 for s in services)
    platform_fee = subtotal * 0.10
    return subtotal + platform_fee

This function looks reasonable. It works fine when you call it with normal data. It breaks in three ways that are not obvious from reading the code.

It returns zero for an empty list instead of raising an error. It crashes with a KeyError if a service dictionary is missing the ‘hourly_cost’ key. And if someone passes a negative cost, it silently accepts it and generates a negative invoice. None of these produce an error message. They produce wrong output.

Manual testing would not catch all of these because you would not think to try all three cases. A unit test covers all of them every time you run it, forever.

Writing Tests with pytest

pytest is the standard Python testing library. You probably already have it or can install it in seconds.

pip install pytest

Create a file named test_billing.py next to your billing.py file. This is the convention pytest follows, and it makes it easy to find tests later.

# test_billing.py
import pytest
from billing import calculate_monthly_cost

def test_monthly_total_is_correct():
    services = [
        {'name': 'VPS Basic', 'hourly_cost': 0.05},
        {'name': 'VPS Standard', 'hourly_cost': 0.10},
    ]
    # (0.05 + 0.10) * 730 * 1.10 = 120.45
    assert calculate_monthly_cost(services) == pytest.approx(120.45)

def test_empty_list_returns_zero():
    assert calculate_monthly_cost([]) == 0

def test_missing_cost_key_raises_error():
    with pytest.raises(KeyError):
        calculate_monthly_cost([{'name': 'VPS Basic'}])

def test_negative_cost_raises_error():
    with pytest.raises(ValueError):
        calculate_monthly_cost([{'name': 'VPS Basic', 'hourly_cost': -0.05}])

Run them:

pytest test_billing.py -v

Here is what happens when you run this against the original function. The first test passes. The second test passes. The third test passes. The fourth test fails because the function does not check for negative costs. It just accepts the bad input and returns a negative number.

This is actually good. The test is telling you exactly what needs to be fixed. You add a simple check at the top of the function.

def calculate_monthly_cost(services):
    for s in services:
        if s['hourly_cost'] < 0:
            raise ValueError("Hourly cost cannot be negative")
    subtotal = sum(s['hourly_cost'] * 730 for s in services)
    platform_fee = subtotal * 0.10
    return subtotal + platform_fee

Run the tests again. All four pass now. The function is more robust than it was an hour ago.

A couple of pytest patterns worth knowing. pytest.approx handles floating point comparison so you do not have to match decimals exactly. pytest.raises checks that a function throws a specific exception. These two cover most of what you need for everyday testing.

Test-Driven Development

There is a workflow called TDD, test-driven development, that says you should write the test before you write the code. The idea sounds strange but it has a real benefit: it forces you to think about what the function should do before you write it. You end up with a clearer mental model of the inputs, outputs, and error cases.

The TDD cycle is simple. Write a test, watch it fail because the function does not exist yet, write just enough code to make it pass, run the test again to confirm. Refactor if needed and keep the tests green.

You do not have to follow TDD strictly to benefit from it. Even writing tests after the code, as we just did, catches things you would not catch otherwise. The key is writing them at all.

What Makes a Test Worth Writing

Some tests are worth the maintenance cost. Some are not. A good unit test is fast. If your test suite takes five minutes to run, developers stop running it before they push code. Fix this by mocking everything outside the function you are testing. No real database calls, no real HTTP requests.

A good unit test does not depend on other tests and gives the same result every time. Each test sets up its own data independently. If test A must run before test B for B to pass, that is a problem that grows. If a test passes on your machine and fails on CI, that is a flaky test. Flaky tests get ignored, and ignored tests provide no safety value. Find the root cause or delete the test.

Running Tests Automatically

If you are deploying to a VPS manually, running pytest before you push code is a habit worth building. When you have multiple projects or multiple people involved, automate it with GitHub Actions. Every push runs the full test suite. If a commit introduces a bug, the pipeline fails and you get a notification before the broken code reaches production.

name: Tests
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install pytest
      - run: pytest test_billing.py -v

That is a complete CI pipeline. Every time someone pushes code, it runs the full test suite. If a commit introduces a bug, the pipeline fails and you get a notification before the broken code reaches production.

Closing Thoughts

I have gone back and forth on testing over the years. I have worked on codebases with no tests and codebases with extensive test coverage. The codebases with tests are the ones where I was not afraid to make changes. The ones without tests are the ones where refactoring felt like archaeology, carefully excavating behavior that was never written down anywhere except in the code itself.

You do not need a perfect test suite on day one. Start with the function you are about to ship, write a couple of tests for the cases you care about, and run them before you deploy. That catches the kind of mistake that shows up in production on a Friday afternoon.

Need a reliable VPS for your Python projects? InterServer offers root access, SSD storage, and gigabit uplink starting at $5/month. Browse VPS plans →

Related guides

Account services are unavailable until the production backend is configured.