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 pytest
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 - Put all the tests for
ClassinsideTestClass
- pytest uses plain
assertstatements for assertionsassert result == expectedreplacesself.assertEqual()assert result != expectedreplacesself.assertNotEqual()assert result == pytest.approx(expected)replacesself.assertAlmostEqual()forfloats.pytest.raises()is used as a context manager to check that an error is raised. Example:with pytest.raises(ValueError): get_area_of_rectangle(-1, 4)- The name of each method that has tests in it should start with
test_
- Run tests from the command line with
pytest(no call needed inside the file) - Don't forget to
import pytestat 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
assert add(2, 3) == 5assert add(2, 3) != 1assert calculateGrade(96) == 'A'
- Invalid inputs
with pytest.raises(ValueError): calculateGrade(-600)with pytest.raises(ValueError): add('two', 3)with pytest.raises(ValueError): get_area_of_rectangle(-1, 4)
- Edge cases at the boundaries of the normal case (almost invalid, but not quite)
assert get_area_of_rectangle(0, 4) == 0assert divide(0, 1) == 0
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?
assert calculateGrade(87) == 'B+'assert calculateGrade(0) == 'F'with pytest.raises(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:
assert 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):
assert large.make_sound() == meows
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 pytest.raises(ValueError):
assert get_area_of_rectangle(-4, 100) == -400
- 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_method and teardown_method
pytest comes with four methods that we can write to help us reduce redundancy and write cleaner tests:
def setup_method(self) -> None:is a method which, if implemented, runs before each test.def teardown_method(self) -> None:similarly runs after each test.def setup_class(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 teardown_class(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 usingsetup_class(cls)andteardown_class(cls).
Poll: Why does this break? Why is it better to use setup_method()?
class TestShirt:
def __init__(self) -> None:
self.shirt = Shirt(500, 'green')
def test_set_size_works_for_positive_values(self) -> None:
self.shirt.set_size(600)
assert self.shirt.size == 600
def test_cannot_set_size_to_negative_value(self) -> None:
assert self.shirt.size == 500
self.shirt.set_size(-700)
assert self.shirt.size == 500
- 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