✦ Python Intermediate & OOP Code quality

L2 · Concept 05 · Code Quality & Testing

Testing Classes & Exceptions
Test Object Behavior and Handle Expected Errors.

Test how objects change over time, and learn when an expected exception is evidence that a rule is working.

Learning goalTest object state, use setUp() for fresh test data, and use assertRaises() to verify expected exceptions without hiding unexpected errors.
01 Learn02 Work locally03 Check your understanding

Learning as a guest

Learning as a guest? Save your progress!

Create a free account to learn with Sloby AI, check your understanding, and track your progress.

The entire platform is free throughout 2026. Foundations stays free forever.

Objects have state worth testing.

A function receives data and returns a result. An object also has state: its methods can change attributes over time. That means a useful test often checks the state before and after an operation, not only a return value.

class Brojac:
    def __init__(self):
        self.vrednost = 0

    def povecaj(self):
        self.vrednost += 1


import unittest

class TestBrojac(unittest.TestCase):
    def test_povecanje(self):
        brojac = Brojac()

        brojac.povecaj()

        self.assertEqual(brojac.vrednost, 1)

The test creates a Brojac, calls its method, then checks the changed attribute. The behavior we care about is the object state: vrednost should become 1.

For a class, ask three useful questions: What is the initial state? What state should follow one operation? What should happen after another operation? Each test should create or receive the state it needs instead of relying on a previous test.

↳

Test the rule, not just the method call: A method can run without an exception and still leave an object in the wrong state. Assertions make the expected rule visible.

Initial state and changed state
class Korpa:
    def __init__(self):
        self.proizvodi = []

    def dodaj(self, proizvod: str):
        self.proizvodi.append(proizvod)


class TestKorpa(unittest.TestCase):
    def test_pocetno_stanje(self):
        korpa = Korpa()
        self.assertEqual(korpa.proizvodi, [])

    def test_dodavanje_dva_proizvoda(self):
        korpa = Korpa()
        korpa.dodaj("Knjiga")
        korpa.dodaj("Olovka")
        self.assertEqual(korpa.proizvodi, ["Knjiga", "Olovka"])

These tests use separate objects. One checks an empty basket, while the other checks a changed basket. No test needs another test to run first.

setUp() gives each test a fresh start
import unittest

class TestKorpa(unittest.TestCase):
    def setUp(self):
        self.korpa = Korpa()

    def test_pocetno_stanje(self):
        self.assertEqual(self.korpa.proizvodi, [])

    def test_dodavanje(self):
        self.korpa.dodaj("Knjiga")
        self.assertEqual(self.korpa.proizvodi, ["Knjiga"])

setUp() runs automatically before every test method. It is a helper method on the test class, not the constructor of Korpa. Use it when several tests need similar preparation; each test still receives a fresh object, so test order does not decide correctness.

Expected exceptions are part of a rule
def postavi_godine(godine: int) -> int:
    if godine < 0:
        raise ValueError("Godine ne mogu biti negativne.")
    return godine


class TestGodine(unittest.TestCase):
    def test_negativne_godine(self):
        with self.assertRaises(ValueError):
            postavi_godine(-5)

raise ValueError rejects an invalid value deliberately. Inside with self.assertRaises(ValueError), that expected exception makes the assertion pass. If no exception appears, the test FAILs. If a different unexpected exception appears, the test can ERROR. Type hints describe expected types, but do not validate values during execution.

Check safe state after a rejected operation
class Novcanik:
    def __init__(self, stanje: float):
        self.stanje = stanje

    def potrosi(self, iznos: float):
        if iznos > self.stanje:
            raise ValueError("Nema dovoljno novca.")
        self.stanje -= iznos


def test_nedovoljno_sredstava(self):
    novcanik = Novcanik(100)

    with self.assertRaises(ValueError):
        novcanik.potrosi(150)

    self.assertEqual(novcanik.stanje, 100)

This test checks two separate facts: the operation raises ValueError, and the wallet keeps its old balance. assertRaises() verifies only the exception; the later assertEqual verifies state. A production wallet would also validate negative amounts, but this small example stays focused on insufficient funds.

Normal, edge, and invalid inputs
# Normal: deposit 100 RSD
racun.uplati(100)

# Edge: withdraw exactly the available balance
racun.isplati(racun.stanje)

# Invalid: withdraw more than the balance
with self.assertRaises(ValueError):
    racun.isplati(racun.stanje + 1)

Normal inputs show ordinary behavior. Edge cases check a boundary, such as an amount exactly equal to a balance. Invalid inputs check that the class follows a clear rule when an operation must be rejected.

Independent tests avoid shared state
# Avoid one shared object changed by earlier tests
class TestRacun(unittest.TestCase):
    def setUp(self):
        self.racun = BankovniRacun(100)

    def test_uplata(self):
        self.racun.uplati(50)
        self.assertEqual(self.racun.stanje, 150)

A test should be able to run alone. Do not use one shared instance whose state an earlier test changes. Fresh setup makes a failing test easier to understand and prevents hidden dependencies on execution order.

Connect the testing tools you know

  1. Describe object rules clearlyOOP gives us classes, objects, methods, and state. Type hints and docstrings help explain what a method expects and promises.
  2. Use unittest to protect those rulesAssertions check expected state and results. Expected exceptions test deliberate safeguards instead of treating every raised exception as an ERROR.
  3. Investigate a failing test carefullyDebugging helps find a cause after a test reports a problem. Together, clear code and focused tests make future maintenance safer.

Common class-testing mistakes

  • Sharing one mutable object between testsUse a local object or setUp() so one test cannot accidentally change the starting state for another.
  • Treating every exception as an ERRORAn expected ValueError can be correct behavior when a rule rejects invalid input. assertRaises() must name the specific exception you expect.
  • Checking only that an exception happenedAfter a rejected operation, add a separate assertion for the object state when that state must remain safe.
  • Assuming setUp() is the tested class constructorsetUp() belongs to the test class and prepares each test. It does not replace __init__ on the class you are testing.

Test Your Bank Account

Local VS Code practice

Download a small bank-account class, run its starter tests, then add cases for safe state changes and rejected operations.

Download project ZIP
Project files
testing_classes_exceptions_bankovni_racun/
├── bankovni_racun.py
├── test_bankovni_racun.py
├── README_SR.md
└── README_EN.md

The starter project has a BankovniRacun class and four passing tests. Add your own normal, edge, and invalid cases without sharing state between tests.

Test the account locally

  1. Open the extracted folder in VS CodeRead the matching README file, then run the starter tests in the integrated terminal.
    python3 -m unittest -v
  2. Add one rule at a timeFor an invalid operation, use one assertion for ValueError and a second assertion to verify that the balance is unchanged.
↳

Run again after a focused change: python3 -m unittest -v On systems where Python is called <code>python</code>, use <code>python -m unittest -v</code>. Keep the tests after each addition so they can protect the account rules.

A safe testing habit

  • Do not hide an unexpected errorUse assertRaises() for a specific expected exception, then investigate any different exception or ERROR result.
↳

This completes the code-quality and testing module: object design, clear types and documentation, debugging, and automated tests work together to make Python code easier to understand, safer to change, and easier to maintain.

What will be the result of this unittest test?

Assume unittest discovers and runs this test method.

import unittest


def proveri_iznos(iznos):
    if iznos <= 0:
        raise ValueError("Amount must be positive.")


class TestIznos(unittest.TestCase):
    def test_negativan_iznos(self):
        with self.assertRaises(ValueError):
            proveri_iznos(-10)
Choose the output

Build confidence independently

Plan three tests for a temperature object

Focus on the behavior of <code>postavi()</code>. The simple constructor in this example does not validate the initial temperature.

Your goalPropose three tests: initial state, a successful temperature change, and a value below absolute zero that raises ValueError without changing the earlier state.
class Temperatura:
    def __init__(self, stepeni: float):
        self.stepeni = stepeni

    def postavi(self, nova_vrednost: float):
        if nova_vrednost < -273.15:
            raise ValueError("Temperatura ispod apsolutne nule.")
        self.stepeni = nova_vrednost

Work in your local VS Code folder

Progressive hints
  • Start with a fresh Temperatura object and name its expected starting value.
  • After a valid call to postavi(), compare stepeni with the new value.
  • After assertRaises(ValueError), add a separate assertion to check that stepeni still has its earlier value.

Ask Sloby for one guiding question about state, setUp(), or an expected exception; do not ask it to write the three finished tests for you.

Sloby, the Python AI Tutor mascot

Your learning mentor

Sign in to ask Sloby

Create a free account or sign in to get AI guidance while you learn.

Sign in to check your understanding

Create a free account or sign in to receive AI feedback and save your learning progress.

Python AI Tutor · Learn by understanding, not just by running code.