Skip to content

Latest commit

 

History

History
705 lines (471 loc) · 23.4 KB

19.Testing.md

File metadata and controls

705 lines (471 loc) · 23.4 KB

Lesson 19: Testing

"In the world of Python, testing is not just a phase. It's a commitment to excellence in craftsmanship."

Content

  1. Why do we need testing?
  2. Types of Testing
  3. Introduction to unittest
  4. Introduction to pytest
  5. Mocking and Patching
  6. Advanced Techniques
  7. Coverage Analysis
  8. Applying Testing
  9. Homework

1. Why do we need testing?

Testing your code is essential for verifying its correctness, ensuring reliability, and maintaining code quality.

The general idea of testing, is ensuring that your application works as expected. Due to testing you will be able to detect bugs early during the development process and fix them immediately.

As well it can represent sort of documentation that helps developers to understand how the application should work and show the behavior of an application.

2. Types of Testing

There are several types of testing which are used widely in IT society.

Type of Testing Description
Unit Testing Testing individual units or components of a program in isolation to verify that each part functions correctly.
Integration Testing Testing the integration or interfaces between components to ensure they work together as expected.
Functional Testing Testing the application against its functional requirements to ensure it behaves as expected from an end-user perspective.

The best approach would be to cover all of these scenarios using testing tools, but during this lesson I want to focus more on unit testing and tell about available options in Python.

3. Introduction to unittest

The most common way to test Python application is to use built in unittest library.

Suppose we have the following logic encapsulated within our app:

Example

def add(a, b):
    return a + b

def substract(a, b):
    return a - b

def divide(a, b):
    return a / b

As a developer, several questions should come to mind regarding potential issues:

  • What if add or subtract are passed non-numeric types, like a string and a number?
  • What happens when attempting to divide by zero?
  • Will the application crash, or will it handle these situations gracefully?

Instead of making assumptions, we can write tests to guarantee that our application behaves as intended under various circumstances.

3.1 Writing Your First Unit Test

Unit tests are designed to test individual components, or "units", of your application in isolation.

This means you should be testing the smallest part of an application, like a function or a method, to ensure it does exactly what it’s supposed to do.

Example

import unittest

# Our app
def add(a, b):
    return a + b

def subtract(a, b):
    return a - b

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

# Test cases 
class TestArithmeticFunctions(unittest.TestCase):
    
    def test_add(self):
        """
        - Testing substraction with correct types and assure it works as exptected.
        - Testing substraction with incorrect types.
        """
        self.assertEqual(add(1, 2), 3)
        self.assertEqual(add(-1, 1), 0)
        self.assertRaises(TypeError, add, '1', 2)

    def test_subtract(self):
        """
        - Testing substraction with correct types and assure it works as exptected.
        - Testing substraction with incorrect types.
        """
        self.assertEqual(subtract(10, 5), 5)
        self.assertEqual(subtract(-1, -1), 0)
        self.assertRaises(TypeError, subtract, '10', 5)

    def test_divide(self):
        """
        - Testing division with correct types and assure it works as exptected.
        - Testing division by zero.
        - Testing division with incorrect types.
        """
        self.assertEqual(divide(10, 2), 5)
        self.assertRaises(ValueError, divide, 10, 0)
        self.assertRaises(TypeError, divide, '10', 2)
        

# This ensures that the tests will be run if the script is executed
if __name__ == '__main__':
    unittest.main()

The output shows that tests were successful. Try to modify written the tests and see what happens in case the logic we are trying to test, they should fail explaining what happens wrong, so that the developer can analyse and find out what happens wrong in application.

Output

...
----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

Testing the OOP applications isn't really different from we have seen already.

Let's create a small BankAccount system and try to write tests for it:

Example

class BankAccount:
    def __init__(self):
        self.balance = 0

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        self.balance += amount

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("Insufficient funds")
        self.balance -= amount

    def get_balance(self):
        return self.balance

Example

Here we would need to define the setUp method which creates an instance of BankAccount and it will be easily accessable within TestBankAccountmethod

import unittest

class TestBankAccount(unittest.TestCase):
    
    def setUp(self):
        """
        The setUp method is run before each test. It's a good place to set up
        a clean environment for each test.
        """
        self.account = BankAccount()

    def test_deposit(self):
        """
        Test that depositing money into the account works correctly.
        """
        self.account.deposit(100)
        self.assertEqual(self.account.get_balance(), 100)

    def test_withdraw(self):
        """
        Test that withdrawing money from the account works correctly.
        """
        self.account.deposit(200)
        self.account.withdraw(50)
        self.assertEqual(self.account.get_balance(), 150)

    def test_withdraw_insufficient_funds(self):
        """
        Test that a ``ValueError`` is raised when trying to withdraw more money
        than the balance of the account.
        """
        self.account.deposit(50)
        with self.assertRaises(ValueError):
            self.account.withdraw(100)

    def test_deposit_negative_amount(self):
        """
        Test that a ``ValueError`` is raised when trying to deposit a negative amount.
        """
        with self.assertRaises(ValueError):
            self.account.deposit(-20)

    def test_initial_balance(self):
        """
        Test that the initial balance of the account is zero.
        """
        self.assertEqual(self.account.get_balance(), 0)

# This ensures that the tests will be run if the script is executed
if __name__ == '__main__':
    unittest.main()

Output

.....
----------------------------------------------------------------------
Ran 5 tests in 0.000s

3.2 Structuring Your Tests

As everything in programming MUST have a correct structure, as we have seen it already

Same applies to testing with unittest, each test case class MUST have the following:

  • Setup: Prepare the necessary environment or state before the actual tests run. This is often done in a method named setUp().
  • Test Cases: Individual functions that start with the word test_ to inform the test runner what to execute.
  • Assertions: Statements that check if the output of your code matches the expected result.
  • Teardown (Optional): Clean-up steps that need to be taken after the test cases run, often implemented in a method named tearDown().

Generally, testing should not be crossed with code which will be used in production, a great approach would be to create a tests/ directory and include all tests and files there.

Example

my_project/
│
├── my_project/
│   ├── __init__.py
│   └── bank_account.py
│
└── tests/
    ├── __init__.py
    └── test_bank_account.py

3.3 Running Tests with a Command

To run the tests, navigate to the directory containing your test script and execute the following command:

python test_bank_account.py

Alternatively, if you want to run all tests across different test files, use:

python -m unittest discover

4. Introduction to pytest

There is no really much difference between pytest and unittest tools for testing in terms of serving their purposes. They are both configurable, they both test your application and work pretty much similar.

But, pytest simplifies the writing of small tests yet scales to support complex functional testing. This is why pytest is my favored tool and most of production code is tested within this library.

4.1 Install pytest

To begin using pytest, you first need to install it via pip:

pip install pytest

Once installed, writing a test is as straightforward as defining a function prefixed with test_, and then using plain assert statements:

Example

# test_example.py
def add(a, b):
    return a + b

def test_add():
    assert add(2, 3) == 5
    assert add('space', 'ship') == 'spaceship'

Running tests

pytest

pytest will automatically discover and run any files named test_*.py or *_test.py in the current directory and its subdirectories.

You can create the same tests for your application, and using pytest in testing your app.

4.2 Running Tests with pytest:

There are some useful commands which I use in production day to day, they can narrow down the tests or provide more information:

Command Description
pytest test_example.py Executes all the tests defined in test_example.py.
pytest tests/ Runs all the tests found in the tests directory.
pytest -v Provides a verbose output, detailing all the tests run along with their individual outcomes.
pytest test_example.py::test_add Executes only the test_add function within the test_example.py file.
pytest -k "expression" Runs tests that match the given substring expression, a powerful way to run specific tests within a larger suite.
pytest --ignore=tests/ Ignores the specified directory or file during the test run.
pytest --maxfail=num Exits the test session after num failures, useful for quickly identifying and addressing errors.
pytest --tb=style Modifies the traceback output format for easier debugging. Valid styles include long, short, line, native, and no.
pytest -x Stops the test run on the first failure encountered, which can be useful during development.
pytest --lf Reruns only the tests that failed at the last run, or skips tests that passed.

Don't hesitate to use anything from this command during development, they can save you a decent ampunt of time, helping with debugging of your application.

5. Test-Driven Development (TDD)

Test-Driven Development (TDD) is a modern software development practice where tests are written before the code that will make the tests pass. It follows a simple iterative cycle known as "Red-Green-Refactor":

  1. Red: Write a test that defines a function or improvements of a function, which should fail because the function isn't implemented yet.
  2. Green: Implement the function in the simplest way possible to make the test pass.
  3. Refactor: Clean up the code, while ensuring that tests still pass.

TDD encourages developers to think through their design before writing the code.

5.1 Benefits of TDD

  • Documentation: The tests serve as live documentation for the application.
  • Design: Helps in building a better design as it requires writing testable code.
  • Confidence: Each change is made with confidence that the existing features are not broken.

The disadvantage is that sometimes it is really hard to follow this approach, either it is time-consuming, the project is enormously big and it is hard for developer to put it alltogether.

I won't lie, if I have a deadline for the task, sometimes I might neglect using TDD, though no one should do this :)

Let's create an application using TDD approach step by step to see how it should look like:

Step 1: Writing a Failing Test (Red Phase)

Suppose we're building a simple calculator application. Our first feature is an addition function. We start by writing a test for this functionality before the function itself exists.

Create a test file test_calculator.py:

# test_calculator.py

def test_addition():
    assert add(2, 3) == 5

Running this test (pytest test_calculator.py) will result in a failure because the add function does not exist yet. This is the Red phase of TDD.

Step 2: Making the Test Pass (Green Phase)

Now, we write the minimal amount of code needed to pass the test. Create a file calculator.py and implement the add function:

# calculator.py

def add(a, b):
    return a + b

And modify test_calculator.py to import the add function:

# test_calculator.py

from calculator import add

def test_addition():
    assert add(2, 3) == 5

Running the tests again with pytest, we see that the test now passes. This is the Green phase of TDD.

Step 3: Refactoring (Refactor Phase)

With the test passing, we can now refactor our code with confidence. This might involve the following:

  • Renaming functions for clarity.
  • Optimizing the algorithm.
  • Restructuring the code for better readability.

Step 4. Run tests again

After refactoring, run the tests again to ensure nothing has broken. This continuous cycle enhances the code quality over time.

Output

.
----------------------------------------------------------------------
Ran 1 tests in 0.000s

OK

TDD requires strong discipline and may initially slow down development, especially for teams new to this approach. However, the long-term benefits are much more significant, consider adopting this practice into your applications.

5. Mocking and Patching

5.1 Mocking

Mocking objects simulate the behavior of real objects within your system.

Mock objects can be programmed with predefined responses, making them highly flexible for testing a wide range of scenarios.

  • Test components in isolation from the rest of the system.
  • Simulate various states of external systems or resources that are difficult or time-consuming to replicate in a test environment.
  • Avoid side effects that can interfere with test outcomes.
  • Control the test environment by specifying expected inputs and outputs.

5.2 Patching

Patching (often used in conjunction with mocking) involves temporarily replacing the actual implementation of a class, method, or function with a mock during test execution.

  • Functions and methods, to control their outputs or side effects.
  • System-level operations, such as file I/O , to prevent tests from altering the system's state.
  • Libraries and frameworks, to test your code's interaction with them without requiring the actual implementation to be invoked.

5.3 Practice!

pytest can integrate the unittest.mock module from the Python standard library, enabling the use of mocks and patches in your tests.

With this integration you can simulate complex behaviors and the assertion of interactions with mock objects.

Example

Imagine we have a simple application that sends email notifications to users about important events. For simplicity, let's focus on the function that triggers sending the email.

# notifications.py

def send_email_notification(email_address, message):
    if not email_address or not message:
        raise ValueError("Email address and message are required")
    print(f"Sending email to {email_address}: {message}")
    return True

Testing

# test_notifications.py

import pytest
from unittest.mock import patch
from notifications import send_email_notification

@patch('notifications.print')  # Mocking the print function to simulate email sending
def test_send_email_notification(mock_print):
    email_address = "user@example.com"
    message = "Your application has been approved."

    result = send_email_notification(email_address, message)

    # Assert the mock (print function here) was called with the expected arguments
    mock_print.assert_called_with(f"Sending email to {email_address}: {message}")
    assert result == True

def test_send_email_notification_missing_arguments():
    # Testing missing arguments to ensure our error handling works
    with pytest.raises(ValueError):
        send_email_notification("", "")

Explanation

  • Mocking: In this example, we mock the print function to simulate the action of sending an email. The @patch decorator from unittest.mock is used to replace print with a mock object only for the duration of the test.
  • Assertion: We assert that the mock object was called with the expected arguments, simulating the check that the email notification was triggered with the correct content.

Instead of sending emails every time we run the test, we "simulated" the logic of email sending and have tested that everything works as expected.

6. Advanced Techniques

6.1 Parameterized Testing

Parameterized testing allows you to run the same test function with different inputs, reducing code duplication and making it easier to cover a wide range of scenarios.

pytest.mark.parametrize

pytest offers a simple way to parameterize tests using the @pytest.mark.parametrize decorator.

import pytest

@pytest.mark.parametrize("test_input,expected", [
    (5, 25),
    (9, 81),
    (2, 4)
])
def test_square(test_input, expected):
    assert test_input ** 2 == expected

This test will run three times, each with the different test_input and expected values provided. (So there will be 3 tests with different values inside one, instead of creating tones of duplicate code)

6.2 Fixture Management

Fixtures in pytest are functions run by pytest before (and sometimes after) the actual test functions to which they're applied. Fixtures are a powerful feature for setting up and tearing down test environments or contexts.

Example

import pytest

@pytest.fixture
def sample_data():
    return [1, 2, 3, 4, 5]

# After creating a fixture we can use an object to test different conditions
def test_sum(sample_data):
    assert sum(sample_data) == 15

def test_length(sample_data):
    assert len(sample_data) == 5

The sample_data fixture is automatically injected into the test_sum and test_length function by pytest, demonstrating fixture management for reusable test data setup.

6.3 Error Handling in Tests

Testing how your application handles errors is as crucial as testing its success paths. pytest simplifies the process of asserting exceptions.

Testing for Expected Errors

import pytest

def raise_custom_error():
    raise ValueError("An error occurred")

def test_raise_custom_error():
    with pytest.raises(ValueError) as e:     
        raise_custom_error()
    assert str(e.value) == "An error occurred"

This test checks that raise_custom_error raises a ValueError with the expected message.

All tools can be easily integrated in order to enhance the quality of your tests and after having such knowledge you as developer will be able to write well-structured easy to use tests.

7. Coverage Analysis

Code coverage is a measure used to describe the degree to which the source code of a program is executed when a particular test suite runs.

A program with high code coverage has had more of its source code tested, which can lead to fewer bugs.

7.1 Install coverage.py

coverage.py is a tool for measuring code coverage of Python programs. It monitors your program, noting which parts of the code have been executed.

pip install coverage

7.2 Use coverage.py with pytest

  1. Run your tests under coverage run:
coverage run -m pytest
  1. Then, generate a report:
coverage report

Or for an HTML version:

coverage html

This report will show which lines of code were not executed by your tests, helping identify areas needing additional testing, which can be added.

8. Applying Testing

Let's create a new application, but now, we will use everything we have learnt during this section

Step 1: Defining Our Application's Requirements

For our Task Manager application, we have the following requirements:

  • Ability to add tasks with a unique identifier.
  • Ability to mark tasks as complete.
  • Send a notification when a task is marked as complete.

Step 2: Writing Tests First (TDD Approach)

Following the TDD approach, we start by writing tests for our yet-to-be-implemented features. We focus on testing our application's core functionality and how it handles edge cases.

test_task_manager.py

import pytest
from task_manager import TaskManager

@pytest.fixture
def task_manager():
    return TaskManager()        # Instance of our class

def test_add_task(task_manager):
    task_id = task_manager.add_task("Learn pytest")
    assert task_manager.get_task(task_id) == "Learn pytest"

def test_mark_task_as_complete(task_manager):
    task_id = task_manager.add_task("Learn mocking")
    task_manager.mark_task_as_complete(task_id)
    assert task_id not in task_manager.tasks
    assert task_id in task_manager.completed_tasks

def test_mark_nonexistent_task_as_complete(task_manager):
    with pytest.raises(ValueError):
        task_manager.mark_task_as_complete(999)

Step 3: Implementing the Application Logic

After defining our tests, we proceed to implement the application logic to make these tests pass.

task_manager.py

class TaskManager:
    def __init__(self):
        self.tasks = {}
        self.completed_tasks = {}

    def add_task(self, description):
        task_id = len(self.tasks) + 1
        self.tasks[task_id] = description
        return task_id

    def mark_task_as_complete(self, task_id):
        if task_id not in self.tasks:
            raise ValueError("Task ID does not exist")
        self.completed_tasks[task_id] = self.tasks.pop(task_id)

    def get_task(self, task_id):
        return self.tasks.get(task_id, "Task does not exist")

Step 4: Running the Tests

Use pytest to run your tests and ensure they all pass:

pytest test_task_manager.py

Step 5: Parameterized Testing

Use parameterized tests to run the same test logic with different inputs effortlessly.

@pytest.mark.parametrize("description", ["Task 1", "Task 2", "Task 3"])
def test_add_multiple_tasks(task_manager, description):
    task_id = task_manager.add_task(description)
    assert task_manager.get_task(task_id) == description

Step 6: Coverage Analysis

Finally, assess your test suite's effectiveness using coverage analysis tools to ensure every line of your application logic is tested.

pytest --cov=task_manager test_task_manager.py

Congratulations, we have learnt how to write Unit Tests!

9. Homework

Cover your existing projects with unit tests, aim to get 99% of coverage. Add mocking and patching to the application we have written in Section 8.