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
Classas methods insideTestClass
- pytest uses plain
assertstatements for assertionsassert result == expected- Use
assert result == pytest.approx(expected)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 pytest fixtures
Most of the time, our tests start with instantiating the object that we are testing. This leads to redundancy -- many of our tests have the same setup lines at the beginning.
To reduce redundancy, pytest offers a fixture which is instantiated at the beginning of each test.
In this example, a Rectangle named rectangle will be instantiated before each test.
import pytest
@pytest.fixture(name="rectangle")
def rectangle_fixture() -> Rectangle:
"""Define a Rectangle for testing."""
return Rectangle(3, 4)
class TestRectangle:
"""Tests for the Rectangle class."""
def test_area(self, rectangle: Rectangle) -> None:
"""Test the area of a 3 by 4 rectangle."""
assert rectangle.get_area() == 12
def test_perimeter(self, rectangle: Rectangle) -> None:
"""Test the perimeter of a 3 by 4 rectangle."""
assert rectangle.get_perimeter() == 14
Note: make sure that the fixture's name argument is different from the name of the function -- otherwise, Pylint will complain when that same name is re-used as the argument to the test functions.
If we want to make it so the fixture is only defined once at the beginning of the test class, instead of before every test method, we can add scope="class" to the fixture's arguments. This means that any changes made to the object in a test may remain in place during other tests.
Poll: Why does this break? Why is it better to use a pytest fixture?
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