Designing Classes
Motivation: "nouns" in the real world (versus "verbs" which are functions)
Classes encapsulate data and code. They achieve abstraction by masking details of implementation. (e.g., like how we push a button/turn a key to start a car without knowing how exactly it works)
Another way to think about a class is a way to create a new type.
Here are some classes that are built in to Python (types that we already use):
Class (data type) | Object (an instance of a class) |
---|---|
str | word: str = "hello" |
list | items: List[int] = [1, 2, 3] |
How to make your own class: attributes, methods, and constructor
- Class header
- Define using class
- Name starts with capital letter
- Parts of a class
- Attributes
- Named using
self.
- Named using
- Methods
- Functions inside a class
- First parameter is always
self
- Constructor
- Special method that is called when the object is "instantiated"
- To initialize the attributes
- Signature:
def __init__(self):
- Attributes
Let's walk through this class definition:
class Pet:
"""Represents a household pet"""
def __init__(self, pet_name: str, owner_name: str, animal: str):
self.name: str = pet_name
self.owner: str = owner_name
if animal == 'cat':
self.sound: str = 'meow'
elif animal == 'dog':
self.sound = 'bark'
else:
self.sound = 'hello'
def make_sound(self) -> str:
"""Returns the pet's sound"""
return self.sound
Now that we have created this new type called Pet
, we can use it for a variable called mini
.
We instantiate an object (an instance) of a class by putting parentheses after its name, and specifying the constructor's arguments inside (Pet('Mini', 'Rasika', 'cat')
).
We call its methods using its variabla name and the "dot operator" (.
).
mini: Pet = Pet('Mini', 'Rasika', 'cat')
print(mini.make_sound()) # meow
Exercise: Let's define a class called Cat
- Attributes: self.name, self.age
- Constructor:
- Take name as parameter
- Make self.age equal 0
- Methods:
birthday()
incrementsself.age
make_sound()
returns the string'meow'
, multiplied by the cat's age (with spaces in between)
class Cat:
"""Represents a cat with a name"""
def __init__(self, name: str):
self.name = name
self.age = 0
def birthday(self) -> None:
"""Increments cat's age"""
self.age += 1
def make_sound(self) -> str:
"""Returns 'meow' multiplied by cat's age, with spaces in between"""
return ('meow ' * self.age).strip()
Poll: What does this output?
mini: Cat = Cat('Mini')
for year in range(3):
mini.birthday()
print(mini.make_sound() + Cat('Mega').make_sound())
- meow meow meow
- (blank line)
- Mini Mini Mini Mega
- Mini Mega
Organizing tests using unittest.TestCase
We saw an example of this in Lecture 1. We can organize our tests -- each class gets its own corresponding test class, where we test all of its methods.
To create a test class for a class named Class
:
- Create a class called
TestClass(unittest.TestCase)
- Put all the tests for
Class
insideTestClass
unittest.TestCase
contains a bunch of helpful methods that we inheritself.assertEqual()
takes two arguments. If they are equal, it does nothing. If they are not equal, it raises an error.self.assertAlmostEqual()
works likeassertEqual()
, but it allows a small difference if used forfloat
s.self.assertRaises()
takes an error as an argument, and does nothing if the block of code raises that error. If the block of code does not raise that error, thenassertRaises()
raises anAssertionError
. Recall this example:with self.assertRaises(ValueError): get_area_of_rectangle(-1, 4)
- The name of each method that has tests in it should start with
test_
- Then outside of
TestClass
, callunittest.main()
- Optional verbosity parameter can be 1, 2, or 3
- Don't forget to
import unittest
at the top of the file
Exercise: Let's write tests for Cat
.
Identify test cases and implement them as unit tests
For this course, you must write tests for every function or method that you write.
When testing a function, we consider all the ways the function might behave:
- The normal / happy case to check that the method works for expected inputs
assertEqual(5, add(2, 3))
assertNotEqual(1, add(2, 3))
assertEqual('A', calculateGrade(96))
- Invalid inputs
with self.assertRaises(ValueError): calculateGrade(-600)
with self.assertRaises(ValueError): add('two', 3)
with self.assertRaises(ValueError): get_area_of_rectangle(-1, 4)
- Edge cases at the boundaries of the normal case (almost invalid, but not quite)
assertEqual(0, get_area_of_rectangle(0, 4))
assertEqual(0, divide(0, 1))
If the function has conditionals, make sure you have test cases for each branch.
Poll: We're testing a function calculateGrade(score: int) -> str
that returns a letter grade given a percentage. Which test case is MOST important to include?
assertEqual('B+', calculateGrade(87))
assertEqual('F', calculateGrade(0))
with self.assertRaises(ValueError): calculateGrade(-600)
- All of these are equally important
Open ended poll: What other test cases can you come up with?
(Source: https://www.reddit.com/r/QualityAssurance/comments/3na0fq/qa_engineer_walks_into_a_bar)
Write well-named and organized tests which help the reader understand the purpose of a function
Poll: What's wrong with this test?
def test_make_sound_works_after_four_years(self) -> None:
self.assertEqual("", Cat('giga').make_sound())
- The test runs, but it fails (that's not how the implementation is supposed to work)
- Not all of the tests in this function always get executed
- The function's name doesn't reflect what it tests
- It's using the wrong type of test
Poll: What's wrong with this test?
def test_make_sound_works_during_first_four_years(self) -> None:
large: Cat = Cat('large')
meows: str = ""
for _ in range(4):
self.assertEqual(meows, large.make_sound())
large.birthday()
meows = (meows + " meow").strip()
- The test runs, but it fails (that's not how the implementation is supposed to work)
- Not all of the tests in this function always get executed
- The function's name doesn't reflect what it tests
- It's using the wrong type of test
Poll: What's wrong with this test?
def test_negative_area(self) -> None:
with self.assertRaises(ValueError):
self.assertEqual(-400, get_area_of_rectangle(-4, 100))
- The test runs, but it fails (that's not how the implementation is supposed to work)
- Not all of the tests in this function always get executed (it is possible for some tests to not run)
- The function's name doesn't reflect what it tests
- It's using the wrong type of test
Using setUp and tearDown
unittest
comes with four methods that we can write help us to reduce redundancy and write cleaner tests:
def setUp(self) -> None:
is a method which, if implemented, runs before each test.def tearDown(self) -> None:
similarly runs after each test.def setUpClass(cls) -> None:
runs once at the beginning, before any tests have run. It needs the annotation@classmethod
right above the method definition, which we will discuss more later on in the semester. Notice also that the argument iscls
, notself
.def tearDownClass(cls) -> None:
runs once at the end, after all of the tests have run. It also needs the annotation@classmethod
right above the method definition. We will discuss class methods later in the semester, and you don't need to understand the annotation to write tests usingsetUpClass(cls)
andtearDownClass(cls)
.
Poll: Why does this break? Why is it better to use setUp()
?
class TestShirt(unittest.TestCase):
def __init__(self) -> None:
self.shirt = Shirt(500, 'green')
def test_set_size_works_for_positive_values(self) -> None:
self.shirt.set_size(600)
self.assertEqual(600, self.shirt.size)
def test_cannot_set_size_to_negative_value(self) -> None:
self.assertEqual(500, self.shirt.size)
self.shirt.set_size(-700)
self.assertEqual(500, self.shirt.size)
- It unnecessarily tests the same thing multiple times
- It requires the tests to be run in a certain order, which is not guraranteed
- It doesn't test what the name implies it is testing
- It is possible for some tests to not be run
Using try/except safely
There is a control structure that we have not introduced until now: try / except
a: int = 4
b: int = 0
try:
result = a / b
print(result)
except ZeroDivisionError:
print("Cannot divide by zero")
It allows us to try to run risky code, and if an error is raised during that risky code, then it jumps immediately to the corresponding except
block.
It is acceptable to use try / except blocks while testing something: it is an alternative to using the built-in self.assertRaises()
.
Otherwise, we try to minimize the use of try / except, and only use it when absolutely necessary. We don't want to simply avoid fixing legitimate bugs by wrapping our code in a try / except.
Places where try / except is commonly used:
- Converting values
def get_user_age() -> int:
"""Get a numerical age from the user"""
user_input: str = input("Enter your age: ")
try:
age: int = int(user_input)
return age
except ValueError:
print("Please enter a valid number")
return -1
def parse_json_safely(json_string: str) -> Any:
"""Convert data from JSON to a readable format"""
try:
return json.loads(json_string)
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}")
return {}
- Operations that rely on external things like network requests or database operations
- Reading from files (though using a
with
block, as we have been doing, is recommended instead)
Keywords in a try / except block:
- Each error that can be raised should get its own
except
block. It is okay to have multipleexcept
blocks for the sametry
block. - One
except
block can handle multiple errors, if they require the same process:except (ValueError, TypeError) as e:
- Inside an
except
block, we may choose toraise
a different error. - If there is a
finally
block at the end of a try / except block, then it is run in all cases (whether thetry
was fully executed, or it jumped to theexcept
. - If there is an
else
block at the end of a try / except block, then it is run only if thetry
was fully executed (and it never jumped to anexcept
block
Best practices:
- Only use try / except for the few legitimate reasons, not for control flow of the program
- Make the errors handled in
except
blocks as specific as possible. It is okay to list multiple specific errors in the sameexcept
block.
Poll: What is output?
def noodle(hopefully_a_number: str) -> None:
try:
num: int = int(hopefully_a_number)
print('Cats rule')
except AssertionError as e:
print(f'{hopefully_a_number} is not a number')
noodle('hello')
- Cats rule
- hello is not a number
- Cats rule hello is not a number
- No output - it raises the error
Poll: What is output?
def noodle(hopefully_a_number: str) -> None:
try:
num: int = int(hopefully_a_number)
print('Cats rule')
except ValueError as e:
print(f'{hopefully_a_number} is not a number')
noodle('hello')
- Cats rule
- hello is not a number
- Cats rule hello is not a number
- No output - it raises the error