Testing in Python

For this homework you are given 2 files, student.py and tests.py. student.py contains the class Student that we wrote in class, and tests.py contains some code to test it.

You will write some additional code that uses this class, and tests to make sure that your code functions correctly.

Virtual Environment

For this assignment you should use a virtual environment for your project interpreter in PyCharm. A Python virtual environment creates a new Python environment that is isolated from the system-wide Python installation. This lets you install packages in the virtual environment without cluttering up your system Python, and without needing administrator privileges. It is common practice for Python developers to create a new virtual environment for each project that they are working on, if the project needs third party libraries.

After you have opened this assignment as a project in PyCharm, open up PyCharm’s preferences and search for “project interpreter”. Click on the gear icon next to the currently selected interpreter and select “Add”. You want to add a new environment with Python 3 as the base interpreter. By default it will create the virtual environment in the directory venv within the project directory, which is fine. There is no need to inherit global site-packages or to make it available to all projects.

pytest

Git-keeper used the pytest tool to run tests on your code for the last homework assignment. For this assignment you will use pytest yourself.

The pytest tool does not come with Python by default, but can be installed in your virtual environment from within PyCharm. Install pytest in your virtual environment by clicking the plus sign button at the bottom of the project interpreter preferences. This will bring up a list of all the packages in the Python Package Index (PyPI). PyPI is a repository of third party libraries that you can easily install. Search for pytest and then click “Install Package”.

Now you want to tell PyCharm to use pytest as the test runner for the project. In the preferences, search for “python integrated tools”. For the “Default test runner” select “pytest”, then click OK.

Finally, we want to tell PyCharm about tests.py. From the “Run” menu, select “Edit Configurations”. Click on the plus sign icon to add a configuration and select Python Tests -> py.test. Give this configuration the name “tests.py”. Make sure that “Script path” is selected as the target, and then click on the folder icon to select tests.py as the script. Now click OK.

Running the Tests

Now you can try running the current tests. In the “Run” menu you should see “Run ‘tests.py’”. Click on it. You should see output that looks something like this:

============================= test session starts ==============================
platform darwin -- Python 3.8.5, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 -- /Users/nsommer/hw03-classes_and_tests/venv/bin/python
cachedir: .pytest_cache
rootdir: /Users/nsommer/hw03-classes_and_tests
collecting ... collected 1 item

tests.py::test_student PASSED                                            [100%]

============================== 1 passed in 0.02s ===============================

Take a look at the code in tests.py. Note that this module could not be run as a standalone script, because all the code is in the function test_student(). When we run pytest on this file however, pytest will look for functions that start with test_ and call all of those functions.

pytest considers a test to be passing if there were no exceptions raised when it was run. Typically a pytest test function will contain assert statements which assert things that should be true. If any of the assertions fail, an AssertionError exception will be raised and the test will fail. Try forcing this test to fail by changing this line:

assert s.get_name() == 'Bob'

to this:

assert s.get_name() == 'Bo'

Now re-run the tests and take a look at the output. Note that pytest tells you what was wrong:

AssertionError: assert 'Bob' == 'Bo'

You can now change 'Bo' back to 'Bob' so that the tests pass again.

bulk_declare_major()

Your first coding task is to write a function called bulk_declare_major() in student.py. This will not be a member of the Student class, but rather a function that operates on a list of Student objects. Place the function below the definition of the Student class in student.py.

Here is the header of the function, and the requirements for the function in the form of a docstring:

def bulk_declare_major(student_list, major, redeclare=True):
    """
    Set the major of the students in a list. By default all majors are set,
    even if the major was previously set. If redeclare is set to False, only
    the students that do not already have their major set will have their
    major set.

    The Student objects are mutated, nothing is returned.

    :param student_list: list of Student objects
    :param major: the major to set
    :param redeclare: Optional, defaults to True. If False, students that
     already have their majors set will remain unchanged
    """

Note that this function takes advantage of the fact that variables in Python are references. When a list is passed to this function, the list is not copied. When the function makes changes to the objects in the list, the changes are made directly to the objects that were intiailly created. That is why this function does not need to return anything, it mutates the original objects that it was given.

The redeclare parameter of this function is optional, and defaults to True. That means you can leave this parameter out when calling the function if you want the default behavior:

bulk_declare_major(students, 'CS')

Or if you want redeclare to be False you can call it like this:

bulk_declare_major(students, 'CS', redeclare=False)

Add a function to tests.py called test_bulk_declare_major(). In this function you need to make sure bulk_declare_major() works as it is supposed to by passing lists of Student objects to it and then making assertions about the state of the list after the function is called.

For example, you will want to check that it works properly on a list of two students who do not have their majors set. We can test that like so:

students = []
students.append(Student('Bob', 20))
students.append(Student('Jane', 10))

bulk_declare_major(students, 'CS')

assert students[0].get_major() == 'CS'
assert students[1].get_major() == 'CS'

You will also want to test the function with a list of students where some of the students have their major declared already and others do not, both with redeclare being True and False.

load_student()

The second task is to write a function called load_student(), also in student.py. This function will use Python’s built-in ConfigParser class from the configparser module (read about that here) in order to build a Student object from the contents of a file. You will need to add a line to import configparser at the top of student.py.

The files that it reads should have the following format:

[student]
name = Larry
ID = 1234
major = Biology

The major field is optional. If it does not exist, the Student object will have None for its major.

The function should have a single parameter, which is the name of a file to read from. Here is the header of the function along with a docstring:

def load_student(filename):
    """
    Load a Student object from the contents of a file which uses Python's
    configuration file language to represent a student's name, ID, and
    optionally their major.

    :param filename: the name of the file representing the student
    :return: a Student object representing the student, or None if there was an
     error reading from the file
    """

Use the ConfigParser class from configparser to read the data from the file (see the documentation for how to do this). If the file does not exist, or the file is in the wrong format, or the [student] section does not exist, or name or ID do not exist, or ID is not an integer, then the function should return None.

You can catch parsing errors by putting the call to the ConfigParser’s read() method in a try/except block. Here are the first lines of my function. This assumes you have imported configparser:

parser = configparser.ConfigParser()

student = None

try:
    parser.read(filename)
except configparser.ParsingError:
    return None

What the try/except block does is try to execute the code in the try: section. If a configparser.ParsingError exception is thrown, it executes what is in the except block, in this case returning None.

For testing, create several configuration files that test different cases. You can right click on the project folder in the project pane on the left of the screen and say New -> File. Use the extension .cfg for your files. I created 4 files for the following cases:

In order to be able to read the configuration files when running your test program in PyCharm, the working directory of your program needs to be the same as your project directory. To change your working directory, select Run -> Edit Configurations and then change the working directory to be the same directory as your project.

Submission

Push your code to submit. Git-keeper will run my tests against your code. For full credit all tests must pass and your code must not have PEP 8 violations.

PyCharm should notify you if you are not following PEP 8. You can also read PEP 8 itself for more information: https://www.python.org/dev/peps/pep-0008/