top of page

Tutorial 2: Write Your First Unit Test

  • Contributor
  • Mar 22
  • 3 min read

Time to test real code. This tutorial walks through writing a unit test that catches a real bug in a real function.

What You'll Build

A unit test for a small function in your codebase. You'll use the Arrange-Act-Assert structure.

Step 1: Pick a Function (5 min)

Look in your codebase for a function with:

  • Clear input → output behavior

  • Minimal side effects (no DB calls, no HTTP)

  • Some logic (validation, calculation, transformation)

Good candidates:

  • A discount calculator

  • A validator

  • A date formatter

  • A string parser

Bad candidates for your first test:

  • Functions that hit the database (those are integration tests)

  • Functions that make API calls

  • UI components (different patterns)

Step 2: Read the Function (5 min)

Understand what it does. What are the inputs? What are the outputs? What edge cases might exist?

Sample function:

def calculate_discount(price, customer_tier):
    """Calculate discount based on customer tier."""
    if customer_tier == "premium":
        return price * 0.10
    elif customer_tier == "gold":
        return price * 0.20
    return 0

Step 3: Write the First Test (5 min)

Start with the happy path. One test for the most common case.

def test_premium_customer_gets_10_percent_discount():
    # Arrange
    price = 100
    tier = "premium"
    
    # Act
    discount = calculate_discount(price, tier)
    
    # Assert
    assert discount == 10

Notice the three sections. The test name describes the behavior.

Step 4: Run It (1 min)

pytest tests/test_discount.py

Should pass. If not, either the test is wrong or the function is wrong. Both teach you something.

Step 5: Add More Cases (10 min)

def test_gold_customer_gets_20_percent_discount():
    discount = calculate_discount(100, "gold")
    assert discount == 20

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

def test_unknown_tier_gets_no_discount():
    discount = calculate_discount(100, "platinum")
    assert discount == 0

Each test covers one specific behavior.

Step 6: Look for Edge Cases (10 min)

Ask "what if?":

  • What if price is 0?

  • What if price is negative?

  • What if tier is None?

  • What if price is huge?

Write tests for each:

def test_zero_price():
    discount = calculate_discount(0, "premium")
    assert discount == 0

def test_negative_price():
    discount = calculate_discount(-100, "premium")
    # What's the expected behavior? 
    # Check what the function does. If it's wrong, fix the function.

The negative price case might reveal a bug. If the function returns -10 for a negative price, that's probably wrong — discounts shouldn't be negative.

This is the moment when tests catch real bugs.

Step 7: Fix Any Bug You Find (varies)

If your test failed against the existing code (or revealed unexpected behavior), update the function:

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

Re-run tests. They should all pass now.

Step 8: Refactor With Confidence (5 min)

Now the tests are in place, you can refactor the function safely:

DISCOUNT_RATES = {
    "premium": 0.10,
    "gold": 0.20,
}

def calculate_discount(price, customer_tier):
    if price <= 0:
        return 0
    rate = DISCOUNT_RATES.get(customer_tier, 0)
    return price * rate

Run tests. All still pass. You changed the implementation; behavior is preserved. That's the magic of tests.

What You Just Did

You wrote tests that:

  • Documented the function's expected behavior

  • Caught a real bug (the negative-price edge case)

  • Enabled a refactor with confidence

This is what unit tests buy you. The investment per test is small. The compound value is large.

Common Failure Modes

Testing the implementation, not the behavior. Tests that break when you refactor the function legitimately. Test what the function should do, not how it does it.

Vague assertions. assert discount > 0 passes for many wrong values. Be specific.

One-test-per-function thinking. A function with branches needs multiple tests. Cover the branches.

Skipping edge cases. Happy path only. Most bugs live in edges.

Tests that test the test. Setting up a mock and asserting the mock was called as expected. You're testing the mock, not the code.

Next Tutorial

Unit tests are powerful but miss integration bugs. Next: Tutorial 3: Write a Useful Integration Test.

Related reading

Keep learning. This article is part of the Software Testing Foundations path in the ShiftQuality Learning Center. Learn to design tests that catch real bugs.

bottom of page