top of page

Write Your First Unit Test — Hands-On Software Testing, Part 2

Shawn West
Mar 22
7 min read

Updated: 2 days ago

Here is the promise for the next twenty minutes: you will write a handful of tests against one small function, watch them pass, and feel like nothing happened. Then you will ask a single question — what if the price is negative? — write one more test, and it will fail. Not because you made a mistake. Because the function has a real bug, sitting in code that looked fine, that your test just caught.


That failing test is the whole point of testing. Everything before it is setup.


This is Tutorial 2 in the Software Testing Foundations series. Tutorial 1, 'Testing Fundamentals: Why We Test', covered why tests earn their keep. This one is hands-on: you write real tests, run them, and let one of them catch a defect. If you have Python and a terminal, you can follow every step.


(Developed example — composite scenario. The calculate_discount function below is representative of pricing logic teams ship every week, not a specific company's code.)



What you need


  • Python 3 installed (python --version should print 3.something).

  • pytest: pip install pytest.

  • A folder to work in. That's it.


We use pytest because it's the standard first tool: you write a plain function whose name starts with test_, put a normal assert in it, and pytest finds and runs it. No classes, no boilerplate.


You now have: a working test runner and a place to put code.


Step 1 — Pick the right function to test first


The fastest way to bounce off testing is to pick a hard function first. A good first-test candidate has a clear input, a clear output, real logic worth checking, and no side effects — nothing it reaches out and touches, like a database, the network, or the screen.


Function

Good first test?

Why

calculate_discount(price, tier)

Yes

Input → output, real branching logic, no side effects

format_currency(amount)

Yes

Pure function; edge cases are easy to reason about

save_order_to_db(order)

No

Touches a database; needs fixtures/mocks you haven't met yet

render_dashboard()

No

Returns no value to assert on; the output is UI

send_email(user)

No

Side effect over the network; hard to verify, easy to spam


We'll use calculate_discount. Create a file called discount.py:


def calculate_discount(price, customer_tier):
    if customer_tier == "premium":
        return price * 0.20
    elif customer_tier == "gold":
        return price * 0.10
    else:
        return 0

You now have: a function with clear inputs (price, customer_tier) and a clear output (a discount amount) — the easiest kind of thing to test.


Step 2 — Read the function like a tester


Before writing a test, say the behavior out loud in plain language. That sentence is your test list:


  • A premium customer gets 20% off.

  • A gold customer gets 10% off.

  • Anyone else (standard, unknown) gets nothing.


Notice what you're doing: turning code into a list of input → expected-output pairs. Each bullet is one test. If you can't state the expected output for an input, you don't understand the function yet — and that confusion is worth catching now, not in production.


You now have: a written list of the behaviors you're about to pin down.


Step 3 — Write the first test with Arrange-Act-Assert


Every unit test has the same three-beat shape. Learn it once and every test you ever write reads the same way.


Beat

What it does

In our test

Arrange

Set up the inputs

price = 100, tier is "premium"

Act

Call the thing under test, once

calculate_discount(100, "premium")

Assert

State what must be true

discount == 20


Create test_discount.py in the same folder:


from discount import calculate_discount


def test_premium_customer_gets_20_percent():
    # Arrange
    price = 100
    # Act
    discount = calculate_discount(price, "premium")
    # Assert
    assert discount == 20

The name is doing real work. test_premium_customer_gets_20_percent tells you exactly what broke when it fails — you never have to open the body. Name tests after the behavior, not the function.


You now have: one real, runnable test that encodes one expected behavior.


Step 4 — Run it and see green


From the folder, run:


pytest test_discount.py -v

============================= test session starts ==============================
platform linux -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
collected 1 item

test_discount.py::test_premium_customer_gets_20_percent PASSED           [100%]

============================== 1 passed in 0.01s ===============================

PASSED. The -v flag (verbose) prints each test by name, which is why the naming discipline in Step 3 pays off immediately.


You now have: proof the runner works and your first behavior is locked in. If anyone later changes the premium rate to 15%, this test goes red.


Step 5 — Add the rest of the happy path


One passing test isn't coverage; it's a start. Add the other behaviors from your Step 2 list. Same AAA shape each time:


def test_gold_customer_gets_10_percent():
    discount = calculate_discount(100, "gold")
    assert discount == 10


def test_standard_customer_gets_nothing():
    discount = calculate_discount(100, "standard")
    assert discount == 0


def test_unknown_tier_gets_nothing():
    discount = calculate_discount(100, "banana")
    assert discount == 0

The "banana" case isn't a joke — it checks that an unexpected tier falls through to the else instead of blowing up. Run again:


test_discount.py::test_premium_customer_gets_20_percent PASSED           [ 25%]
test_discount.py::test_gold_customer_gets_10_percent PASSED              [ 50%]
test_discount.py::test_standard_customer_gets_nothing PASSED             [ 75%]
test_discount.py::test_unknown_tier_gets_nothing PASSED                  [100%]

============================== 4 passed in 0.02s ===============================

Four green tests. And here's the trap: this is the moment most people stop. Four passing tests feels like the function is safe. It isn't. Every one of those tests asked "does the code do what I already expected?" None of them asked "what did I forget to expect?"


You now have: the full expected behavior pinned — and a false sense of safety we're about to break on purpose.


Step 6 — The discovery move: hunt for the "what if?"


This is the step that turns a passing test suite into a bug-catching one, and it's the same instinct that separates a real quality engineer from a code-checker: don't only test the inputs you designed for — hunt the inputs the code will actually meet.


Run down a short, reusable list against every input. For a number like price, ask:


  • Zero — what if price is 0?

  • Negative — what if price is -100? (A refund line, a credit, a bad import.)

  • None / missing — what if price is None?

  • Huge — what if price is 9_999_999?


You don't need domain access to ask these; they come from the shape of the input, not the business. But the negative-price case is exactly the kind of thing a five-minute discovery conversation surfaces and code review misses: "Can price ever arrive below zero?" On a real pricing system the answer is almost always yes — refunds, adjustments, promo credits. Let's write that one as a test and state what should happen: a discount should never be negative, because a negative discount silently raises the customer's price.


def test_negative_price_is_never_discounted():
    # Arrange
    price = -100
    # Act
    discount = calculate_discount(price, "premium")
    # Assert
    assert discount == 0

You now have: a test that encodes a rule the original author never wrote down — and, in a second, proof they got it wrong.


Step 7 — Watch it fail (this is the good part)


Run the suite:


test_discount.py::test_premium_customer_gets_20_percent PASSED           [ 20%]
test_discount.py::test_gold_customer_gets_10_percent PASSED              [ 40%]
test_discount.py::test_standard_customer_gets_nothing PASSED             [ 60%]
test_discount.py::test_unknown_tier_gets_nothing PASSED                  [ 80%]
test_discount.py::test_negative_price_is_never_discounted FAILED         [100%]

=================================== FAILURES ===================================
___________________ test_negative_price_is_never_discounted ____________________

    def test_negative_price_is_never_discounted():
        # Arrange
        price = -100
        # Act
        discount = calculate_discount(price, "premium")
        # Assert
>       assert discount == 0
E       assert -20.0 == 0

test_discount.py:28: AssertionError
=========================== short test summary info ============================
FAILED test_discount.py::test_negative_price_is_never_discounted - assert -20.0 == 0
========================= 1 failed, 4 passed in 0.03s ==========================

Read the failure line by line, because reading failures is a skill in itself. pytest points at the exact assert with >, then shows assert -20.0 == 0: the function returned -20.0 when you required 0. For a -100 premium price, calculate_discount computed -100 * 0.20 = -20.0. A discount of negative twenty means the system would charge that customer $20 more, not less.


That is a real bug. It was in the code the whole time in Step 1, passing four tests, looking fine. Your test just caught it before a customer did.


You now have: a failing test that names a genuine defect and shows you exactly what value proves it.


Step 8 — Fix the code, get back to green


A failing test tells you what to fix; now make the smallest change that satisfies the rule. Guard the input: a non-positive price gets no discount. Edit discount.py:


def calculate_discount(price, customer_tier):
    if price <= 0:
        return 0
    if customer_tier == "premium":
        return price * 0.20
    elif customer_tier == "gold":
        return price * 0.10
    else:
        return 0

Run the suite one more time:


test_discount.py::test_premium_customer_gets_20_percent PASSED           [ 20%]
test_discount.py::test_gold_customer_gets_10_percent PASSED              [ 40%]
test_discount.py::test_standard_customer_gets_nothing PASSED             [ 60%]
test_discount.py::test_unknown_tier_gets_nothing PASSED                  [ 80%]
test_discount.py::test_negative_price_is_never_discounted PASSED         [100%]

============================== 5 passed in 0.03s ===============================

All green — and this green means something the earlier green didn't. The four original tests still pass, so your fix didn't break the behavior you already had (that's regression protection, free, forever). And the fifth test will now fail the instant anyone reintroduces the bug.


You now have: corrected code, and a permanent guard that keeps it corrected.


What you actually learned (beyond the syntax)


The pytest mechanics — test_ functions, assert, -v — you'll have memorized by next week. The durable skill is the loop you just ran:


  1. Pick a function with clear input → output and no side effects.

  2. Write the behavior down as a list; test each item with Arrange-Act-Assert.

  3. Get to green on the happy path.

  4. Then run the "what if?" hunt — zero, negative, None, huge — and let one of them fail.

  5. Fix the code; keep the test as a guard.


Steps 1–3 verify what you meant. Step 4 finds what you missed — and it's where the bugs actually live. When you find yourself generating those "what if?" inputs by hand every time, that's the manual version of a technique you can automate; 'Property-Based Testing: A Beginner's Tour' shows a tool that generates hundreds of edge-case inputs and hunts for the failing one for you.


Where to go next



Your next action: take one real function in your own codebase, run Steps 1 through 6 on it, and get to a failing test. If nothing fails, your "what if?" list wasn't mean enough yet — add None, add negative, add the input a five-minute discovery chat would flag. The first test that fails on your own code is where testing stops being a chore and starts being a superpower.


Sources


This tutorial uses a developed composite example (calculate_discount) rather than a specific company's code; the pricing bug is representative of defects teams ship routinely, not a documented incident. The tooling references are to pytest, the widely used Python testing framework. No statistics are cited.


Keep learning. This article is part of the Software Testing Foundations path in the ShiftQuality Learning Center.



Part of the Software Quality Engineering guide — ShiftQuality's complete map to building quality in.

bottom of page