The Testing Academy · Class Notes Saturday, 22 August (IST)
Live class · study guide

Finishing Python: polymorphism, abstraction, statics and exceptions

The last Python session before the agents work begins. Python has no method overloading, and the way it silently keeps only the last definition is the trap. Then abstraction with ABC, static versus instance state, and the try/except/else/finally shape that is not the one Java taught you.

By Pramod Dutta, The Testing Academy. Study notes from the live AI Tester Blueprint 3x class, rebuilt from the session recording. Every Python snippet on this page was executed on CPython 3.12 before publishing, and the stated output is the output the interpreter produced. Hackathon project ideas are described by theme rather than attributed, since the winners and the full report are being published separately.

01

Where the batch is

The hackathon is closed. 58 submissions arrived, 42 of them on time, and five will be picked as winners. Everyone who submitted gets a certificate, and those certificates will carry a public link you can share. The winners, the full report and a write-up of the strongest ideas are being published separately, with conversations with the winners to follow.

The themes people built around, without attributing any of them: browser-driving agents, API testing agents, requirement-to-test-case pipelines, locator helpers, defect leakage analysis, ATS resume tooling, RAG explorers, Jira-to-test-case generators, release risk analysis, and flaky-test diagnosis with root-cause suggestions.

This session closes Python. Next comes deep eval, LangChain, Crew.ai and MCP, and the reason for spending this long on the language was stated plainly: when an LLM writes your evaluation code, you are the one who has to read it and say whether it is right. That is not possible without knowing what a class, a decorator and an exception actually do.

02

Polymorphism, in two forms

One name, more than one behaviour. The class analogy was behaving differently in front of different people, which is the right intuition: same person, different form depending on context.

It arrives two ways, and Python only really has one of them.

Overloading, inside one class def add(self, a, b) silently discarded, no error raised def add(self, a, b, c) the last definition is the only one that survives Python has no traditional overloading. The name is just a variable, and the second def rebinds it. Overriding, across classes class BaseTest def run() inherits class LoginTest def run() whichever class the object came from wins Inheritance is required. This one behaves as you expect.
Same word, two mechanisms. Only the right-hand one exists in Python.

Overloading: Python does not have it

Write two methods with the same name and Python does not complain. It also does not overload. The second def simply rebinds the name, and the first one is gone.

Python
class Calculator:
    def add(self, a, b):
        return a + b

    def add(self, a, b, c):     # this one replaces the first
        return a + b + c

calc = Calculator()
print(calc.add(2, 4, 6))        # 12
print(calc.add(2, 4))           # TypeError: Calculator.add() missing 1 required positional argument: 'c'

The two-argument call fails, because as far as Python is concerned only the three-argument version was ever defined. There is no warning, no error at definition time, and nothing in the editor to tell you the first method is dead.

The reason is worth knowing, because it explains a whole family of Python behaviour. def is not a declaration, it is an assignment. def add(...) creates a function object and binds it to the name add in the class body. A second def add rebinds the same name, exactly as x = 1 followed by x = 2 leaves you with 2. Nothing is overloaded because nothing was ever a signature in the first place.

What Python offers instead

Default parameters give one function a flexible arity, which covers most of what overloading was for:

Python
class Calculator:
    def add(self, a, b, c=0):
        return a + b + c

calc = Calculator()
print(calc.add(2, 4))        # 6      c falls back to 0
print(calc.add(2, 4, 6))     # 12     c is supplied

The class demonstrated the same idea with a request builder, where the auth type and user default sensibly and callers override only what they care about.

Two more tools exist beyond what was covered, worth knowing they are there: *args and **kwargs accept any number of positional and keyword arguments, and functools.singledispatch gives genuine type-based dispatch, choosing an implementation from the type of the first argument. That last one is the closest Python gets to real overloading, and it lives in the standard library.

Overriding: this one works normally

Python
class BaseTest:
    def run(self):
        print("Running the base test")

class LoginTest(BaseTest):
    def run(self):
        print("Running the login test")

class ApiTest(BaseTest):
    def run(self):
        print("Running the API test")

BaseTest().run()     # Running the base test
LoginTest().run()    # Running the login test
ApiTest().run()      # Running the API test

The method that runs is decided by which class the object came from, not by the type of the variable holding it. Same shape as Java, and the same shape your test framework's base class uses.

03

Abstraction

Encapsulation, covered earlier in the course, is about keeping variables private inside a class and reaching them only through methods. Abstraction is a different thing: hiding the machinery entirely and exposing only what the caller needs.

The car analogy: engine, gearbox and brakes are all there, and you are handed a steering wheel. The class added a second framing that lands harder for inheritance: an abstract method is an unpaid debt. The parent declares it and does not implement it. Any child that inherits has to settle it.

Python does this with the abc module:

Python
from abc import ABC, abstractmethod

class Animal(ABC):
    def __init__(self, name):
        self.name = name

    @abstractmethod
    def sound(self):
        pass                      # deliberately incomplete

class Dog(Animal):
    def sound(self):
        print("Bark")

Dog("Rex").sound()                # Bark
Animal("Generic")                 # TypeError: Can't instantiate abstract class Animal
                                  # without an implementation for abstract method 'sound'

Three parts, all required:

  • ABC is the base class, imported from the abc package.
  • @abstractmethod is a decorator. It attaches to the method below it and marks it incomplete.
  • pass is the placeholder body, because Python has no empty blocks.

Leave the method out of the subclass and the error arrives when you try to instantiate, not when you define the class. That is the safety net: an incomplete implementation cannot be constructed.

abstract, incomplete class Engine(ABC) start() stop() class GearBox(ABC) set_gear() no bodies, just pass class Car(Engine, GearBox) start() implemented stop() implemented set_gear() implemented drive() What the caller sees Car().drive() no Engine, no GearBox, no start() Show a reader only the right-hand box and they cannot tell the left-hand classes exist. That is the whole of abstraction.
The debt is declared on the left, settled in the middle, and invisible on the right.

The automation shape it maps onto: a BrowserManager with start() abstract and stop() concrete, a Chrome subclass implementing start(), and a test that calls neither directly.

04

Static versus instance

A class attribute is shared by every object of the class. Change it through one, and every other sees the change. The blackboard analogy from the session is exact: one board, many people reading it.

Python
class TestCounter:
    count = 0                      # class attribute, shared

    def __init__(self):
        TestCounter.count += 1

TestCounter(); TestCounter()
print(TestCounter.count)           # 2

An attribute assigned on self inside __init__ is the opposite: it belongs to that one object, and no other object knows it exists.

Python has no static keyword. A variable declared in the class body simply is shared, and a method is made static with a decorator:

Python
class MathUtils:
    @staticmethod
    def add(a, b):
        return a + b

    def divide(self, a, b):        # ordinary instance method
        return a / b

print(MathUtils.add(2, 3))         # 5, no object needed
print(MathUtils().divide(10, 2))   # 5.0, object required

The practical difference is the one to remember: a static method is callable straight off the class name. For utility code (an Excel reader, a formatter, a helper) that is exactly what you want.

Calling a static method through an instance also works, and the class advised against it. MathUtils().add(2, 3) runs fine, and it misleads every reader into thinking there is object state involved. Reach for the class name.

The access modifiers from the earlier session still apply, and all three are conventions plus one piece of real machinery:

Written as Means Enforced?
name public n/a
_name protected, internal use convention only, nothing stops you
__name private name-mangled to _ClassName__name, so it is genuinely awkward to reach
05

Exceptions

An exception is an event that interrupts the normal flow. Python's hierarchy runs BaseException, then Exception, then the specific errors underneath.

The ones you will actually meet:

Error Cause
NameError using a variable that was never assigned
TypeError an operation between incompatible types, such as 1 + "a"
ValueError right type, impossible value, such as int("abc")
IndexError an index past the end of a sequence
ZeroDivisionError division by zero
FileNotFoundError opening a file that is not there
SyntaxError malformed code, raised before anything runs

Do not carry over checked and unchecked exceptions from Java. Python has no such distinction. Nothing is declared, nothing is forced, and no method signature tells you what might be raised. The trade is flexibility for a compiler that will not catch an unhandled case for you.

The four blocks

Python
try:
    a = int(input("Enter a number: "))
    b = int(input("Enter another: "))
    c = a / b
except ZeroDivisionError:
    print("Cannot divide by zero")
except ValueError:
    print("That was not a number")
else:
    print(f"Result: {c}")          # only if try finished cleanly
finally:
    print("Done")                  # always, either way

else is the part that catches Java developers out, and it is genuinely useful once it clicks: else runs only when try completed without raising. It is where the success path goes, which keeps the try block down to just the lines that can actually fail. The narrower the try, the less chance of catching an error you did not mean to.

finally runs in every case, exception or not, which is where cleanup belongs.

Multiple handlers are allowed, and a single handler can cover several types:

Python
except (ValueError, TypeError, ZeroDivisionError) as e:
    print(f"Failed: {e}")

Raising your own

Python
def login(user):
    if user != "admin":
        raise Exception("Access denied")
    return "Welcome admin"

print(login("admin"))              # Welcome admin

Custom exception classes are possible by subclassing Exception, and the honest note attached in class was that in practice you will rarely define your own. Built-in types cover almost everything.

06

Modules and packages

A module is one .py file. A package is a directory of them, and the __init__.py file is what makes it a regular package.

Python
from mypackage import util_module      # package, a folder with __init__.py
import mymodule                        # module, a single file

mymodule.greet("Pramod")
util_module.blah()

That is exactly the shape of the abc import at the top of this page: abc is a package, and ABC and abstractmethod are things inside it.

One correction worth carrying, because the old rule is repeated everywhere and stopped being strictly true in Python 3.3. A directory without __init__.py still imports: it becomes a namespace package, and from nopkg import m works fine. Verified on 3.12. So __init__.py is not what makes a directory importable any more. It is still what you want, for three reasons: it runs package initialisation code, it controls what from package import * exposes, and it stops two same-named directories on the path from silently merging into one package. Write it. Just know that its absence is not the reason your import failed.

Three kinds of module, and the difference matters when something will not import: built-in (ships with Python), user-defined (yours), and external (installed with pip).

The os module is the one that will come up most in QA work, for log directories, environment variables and test data paths:

Python
import os

print(os.name)                 # 'posix' on macOS and Linux, 'nt' on Windows
print(os.getcwd())             # current working directory
os.makedirs("ai", exist_ok=True)
print(os.listdir("."))

pip is the package manager that installs the external ones. Requests, Flask, Django, NumPy, Pandas, Transformers, LangChain and Selenium are all packages in exactly this sense, and so is deep eval, which is where the course goes next. It is a folder of classes you install and import, and by this point that sentence should be completely unmysterious.

07

Tasks and announcements

  • Today's task: find the built-in Python modules, work out roughly how many there are, and post what you find in the comments with the ones a tester would actually use.
  • Finish the exercises. The running set is past 170.
  • Post at least one doubt in the doubt thread.
  • Tomorrow: the last of Python, collections included, and then Crew.ai and the first AI agents.
  • Then: deep eval, LangChain and MCP, with one to two weeks budgeted for the remainder.
  • Tuesday 25 August and Thursday 27 August, 8:00 PM IST: AI Fluency certification, parts one and two.
  • Claude 101 should be finished if it is not already. Claude Code 101 is targeted the week after next, and it needs a paid Claude plan.
  • Hackathon results in about a week, with winners, the strongest ideas, and shareable certificates for everyone who submitted.

Interview forms of today's material: what happens when a Python class defines two methods with the same name, and why? Name the difference between encapsulation and abstraction in one sentence each. When does an else block on a try run, and when does finally? What makes a directory a package rather than a folder? Why can a static method be called without an object?