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
- Define using
- 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.agemake_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
ClassinsideTestClass
unittest.TestCasecontains 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 forfloats.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 unittestat the top of the file
Exercise: Let's write tests for Cat.
Identifying test cases
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)
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 decorator@classmethodright 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 decorator@classmethodright above the method definition. We will discuss class methods later in the semester, and you don't need to understand the decorator 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