JavaScript – Chapter 2: Development Environment
Learn what JavaScript is, where it runs, why it is important, and how beginners can start using it with simple examples and clear output.
Main reading content
Chapter 35: Testing Python Applications
A complete beginner-friendly guide to manual testing, automated testing, unit tests, unittest, pytest, fixtures, parameterization, mocking, integration tests, coverage, property-based testing, test-driven development, and continuous testing.
Chapter 35 Topics
```- 35.1 Why Testing Matters
- 35.2 Manual Testing
- 35.3 Automated Testing
- 35.4 Unit Testing
- 35.5 The
unittestModule - 35.6 Creating Test Cases
- 35.7 Assertions
- 35.8 Setup and Teardown
- 35.9 Test Suites
- 35.10 Introduction to
pytest - 35.11 Writing Pytest Tests
- 35.12 Pytest Fixtures
- 35.13 Parameterized Tests
- 35.14 Mocking
- 35.15 The
unittest.mockModule - 35.16 Test Doubles
- 35.17 Integration Testing
- 35.18 Functional Testing
- 35.19 End-to-End Testing
- 35.20 Test Coverage
- 35.21 Property-Based Testing
- 35.22 Test-Driven Development
- 35.23 Continuous Testing
- 35.24 Chapter Practice Exercises
- 35.25 Chapter Testing Project
35.1 Why Testing Matters
```Testing is the process of checking whether a program behaves as expected. A test gives the program a particular input, observes the result, and compares that result with the expected answer.
Testing helps developers discover errors before users encounter them. It also gives developers confidence when changing existing code because tests can reveal whether a new change accidentally breaks an older feature.
A program working once does not prove that it works in every situation. Good testing includes normal values, empty values, invalid input, minimum values, maximum values, and unusual combinations.
Example Function
def calculate_total(
price: float,
quantity: int
```
) -> float:
return price * quantity
print(calculate_total(10, 3))
print(calculate_total(5.50, 2))
print(calculate_total(0, 10))
```
Output
30
```
11.0
0
```
What Should Be Tested?
- A normal price and quantity
- A price containing decimal places
- A quantity of zero
- A negative quantity
- An invalid string instead of a number
- A very large quantity
Testing does not prove that software has no possible bugs, but it reduces risk and helps locate problems earlier.
```35.2 Manual Testing
```Manual testing means a person runs the program, enters values, observes the output, and decides whether the result is correct. It is often the first testing method beginners use.
Manual testing is useful for exploring a program, checking visual interfaces, and trying unusual workflows. However, it becomes slow and repetitive when the same checks must be performed after every code change.
Example Program
def classify_score(score):
if score >= 90:
return "A"
elif score >= 80:
return "B"
elif score >= 70:
return "C"
elif score >= 60:
return "D"
else:
return "F"
```
score = float(
input("Enter a score: ")
)
print(
"Grade:",
classify_score(score)
)
```
Manual Test Plan
| Input | Expected Result | Reason |
|---|---|---|
| 95 | A | Normal A-range value |
| 90 | A | Boundary value |
| 89 | B | Just below A |
| 60 | D | Lowest passing boundary |
| 59 | F | Just below passing |
Example Run
Enter a score: 95
```
Grade: A
```
A written test plan prevents the tester from relying only on memory and makes it easier to repeat the same checks later.
```35.3 Automated Testing
```Automated testing uses code to test other code. A test calls a function, compares its actual result with an expected result, and reports whether the check passed or failed.
Automated tests can be run repeatedly in seconds. They are especially valuable when a project grows or when multiple developers change the same code.
Simple Automated Checks
def add(first, second):
return first + second
```
assert add(2, 3) == 5
assert add(-2, 2) == 0
assert add(0, 0) == 0
print("All checks passed.")
```
Output
All checks passed.
Failing Check
def add(first, second):
return first - second
```
assert add(2, 3) == 5
```
Output
AssertionError
The assertion fails because the function subtracts instead of adding. Testing frameworks provide clearer reports, organized test discovery, setup tools, and many specialized assertions.
```35.4 Unit Testing
```A unit test checks one small unit of behavior, such as a function, method, or class. The unit should be tested separately from databases, networks, external services, and other complex dependencies whenever possible.
Small focused tests make failures easier to understand. When one unit test fails, the developer knows which behavior needs attention.
Application Code
# calculator.py
```
def add(first, second):
return first + second
def subtract(first, second):
return first - second
def multiply(first, second):
return first * second
def divide(first, second):
if second == 0:
raise ValueError(
"Cannot divide by zero."
)
```
return first / second
Possible Units to Test
- The
add()function - The
subtract()function - The
multiply()function - The normal behavior of
divide() - The zero-division behavior of
divide()
Each test should focus on one clear expectation so that failures are easy to diagnose.
```35.5 The unittest Module
```
The unittest module is Python's built-in testing framework. It provides test classes, assertions, setup methods, teardown methods, test discovery, test suites, and mocking tools.
Tests are commonly placed in files whose names begin with test_. Test methods inside a TestCase class also normally begin with test_.
Application File
# calculator.py
```
def add(first, second):
return first + second
```
Test File
# test_calculator.py
```
import unittest
from calculator import add
class TestCalculator(
unittest.TestCase
):
```
def test_add_positive_numbers(self):
result = add(2, 3)
self.assertEqual(
result,
5
)
```
if **name** == "**main**":
unittest.main()
```
Run Command
python test_calculator.py
Output
.
```
---
Ran 1 test in 0.000s
OK
```
The dot means one test passed. The final OK means no failures or errors occurred.
35.6 Creating Test Cases
```A test case describes a particular input, action, and expected result. A good test has a descriptive name and checks one behavior.
Tests often follow the Arrange, Act, Assert structure. Arrange prepares values, Act calls the code being tested, and Assert compares the result with the expectation.
Example
import unittest
```
def calculate_discount(
price,
percentage
):
return price * (
1 - percentage
)
class TestDiscount(
unittest.TestCase
):
```
def test_twenty_percent_discount(self):
# Arrange
price = 100
percentage = 0.20
# Act
result = calculate_discount(
price,
percentage
)
# Assert
self.assertEqual(
result,
80
)
def test_zero_percent_discount(self):
result = calculate_discount(
100,
0
)
self.assertEqual(
result,
100
)
```
if **name** == "**main**":
unittest.main()
```
Output
..
```
---
Ran 2 tests in 0.000s
OK
```
Good Test Names
test_empty_cart_has_zero_total
```
test_invalid_quantity_raises_error
test_free_delivery_at_minimum_amount
test_student_with_90_receives_grade_a
35.7 Assertions
```
Assertions compare the actual behavior of code with the expected behavior. The unittest.TestCase class provides specialized assertion methods that create useful failure messages.
Common unittest Assertions
assertEqual(a, b): Values are equal.assertNotEqual(a, b): Values are different.assertTrue(value): Value is true.assertFalse(value): Value is false.assertIsNone(value): Value isNone.assertIsNotNone(value): Value is notNone.assertIn(item, collection): Item exists in a collection.assertIsInstance(value, type): Value has a particular type.assertAlmostEqual(a, b): Numbers are approximately equal.assertRaises(error): Code raises an expected exception.
Example
import unittest
```
class TestAssertions(
unittest.TestCase
):
```
def test_common_assertions(self):
names = [
"Sara",
"Michael"
]
result = 10 / 3
self.assertEqual(
len(names),
2
)
self.assertIn(
"Sara",
names
)
self.assertTrue(
len(names) > 0
)
self.assertIsInstance(
names,
list
)
self.assertAlmostEqual(
result,
3.333333,
places=5
)
```
if **name** == "**main**":
unittest.main()
```
Testing an Exception
import unittest
```
def divide(first, second):
if second == 0:
raise ValueError(
"Cannot divide by zero."
)
```
return first / second
```
class TestDivision(
unittest.TestCase
):
```
def test_zero_division_raises_error(self):
with self.assertRaises(
ValueError
):
divide(10, 0)
```
if **name** == "**main**":
unittest.main()
35.8 Setup and Teardown
```Setup methods prepare test data before tests run. Teardown methods clean up resources afterward. They reduce duplicated preparation code and help keep tests independent.
In unittest, setUp() runs before every test method, while tearDown() runs after every test method.
Example
import unittest
```
class ShoppingCart:
```
def __init__(self):
self.items = []
def add_item(
self,
name,
price
):
self.items.append(
{
"name": name,
"price": price
}
)
def total(self):
return sum(
item["price"]
for item in self.items
)
```
class TestShoppingCart(
unittest.TestCase
):
```
def setUp(self):
self.cart = ShoppingCart()
self.cart.add_item(
"Keyboard",
50
)
def tearDown(self):
self.cart.items.clear()
def test_cart_contains_one_item(self):
self.assertEqual(
len(self.cart.items),
1
)
def test_cart_total(self):
self.assertEqual(
self.cart.total(),
50
)
```
if **name** == "**main**":
unittest.main()
```
Class-Level Setup
@classmethod
```
def setUpClass(cls):
print("Runs once before all tests.")
@classmethod
def tearDownClass(cls):
print("Runs once after all tests.")
35.9 Test Suites
```A test suite is a collection of tests that run together. Test suites can group tests by feature, module, speed, or testing level.
Python can discover tests automatically when files and methods follow standard naming conventions. Manual suites are useful when a specific group or execution order is needed.
Automatic Discovery
python -m unittest discover
Discover Tests in a Folder
python -m unittest discover -s tests
Manual Test Suite
import unittest
```
class TestMath(
unittest.TestCase
):
```
def test_addition(self):
self.assertEqual(
2 + 3,
5
)
def test_subtraction(self):
self.assertEqual(
10 - 4,
6
)
```
def create_suite():
suite = unittest.TestSuite()
```
suite.addTest(
TestMath(
"test_addition"
)
)
suite.addTest(
TestMath(
"test_subtraction"
)
)
return suite
```
if **name** == "**main**":
runner = unittest.TextTestRunner(
verbosity=2
)
```
runner.run(
create_suite()
)
Example Output
test_addition ... ok
```
test_subtraction ... ok
---
Ran 2 tests
OK
35.10 Introduction to pytest
```
Pytest is a popular third-party testing framework for Python. It supports simple test functions, plain assert statements, fixtures, parameterized tests, plugins, and detailed failure reports.
Pytest is not included automatically with Python and must normally be installed separately.
Install Pytest
python -m pip install pytest
Simple Test
# test_math.py
```
def add(first, second):
return first + second
def test_add_positive_numbers():
assert add(2, 3) == 5
```
Run Pytest
python -m pytest
Example Output
================ test session starts ================
```
collected 1 item
test_math.py . [100%]
================= 1 passed in 0.01s =================
```
Verbose Output
python -m pytest -v
Pytest automatically discovers files named test_*.py or *_test.py and functions beginning with test_.
35.11 Writing Pytest Tests
```
Pytest tests can be ordinary functions. The built-in assert statement compares actual and expected values. When an assertion fails, pytest displays the compared values and the relevant source line.
Application File
# grades.py
```
def get_grade(score):
if score < 0 or score > 100:
raise ValueError(
"Score must be between 0 and 100."
)
```
if score >= 90:
return "A"
if score >= 80:
return "B"
if score >= 70:
return "C"
if score >= 60:
return "D"
return "F"
Pytest File
# test_grades.py
```
import pytest
from grades import get_grade
def test_grade_a():
assert get_grade(95) == "A"
def test_grade_boundary():
assert get_grade(90) == "A"
def test_failing_grade():
assert get_grade(40) == "F"
def test_invalid_score():
with pytest.raises(
ValueError
):
get_grade(120)
```
Run One File
python -m pytest test_grades.py -v
Run One Test
python -m pytest test_grades.py::test_grade_a -v
```
35.12 Pytest Fixtures
```A fixture prepares data or resources that tests need. Pytest injects fixtures into tests by matching function parameter names.
Fixtures can create objects, temporary files, sample records, test database connections, and other reusable test resources.
Example
import pytest
```
class ShoppingCart:
```
def __init__(self):
self.items = []
def add(
self,
name,
price
):
self.items.append(
{
"name": name,
"price": price
}
)
def total(self):
return sum(
item["price"]
for item in self.items
)
```
@pytest.fixture
def cart():
test_cart = ShoppingCart()
```
test_cart.add(
"Keyboard",
50
)
return test_cart
```
def test_cart_total(cart):
assert cart.total() == 50
def test_cart_item_count(cart):
assert len(cart.items) == 1
```
Fixture with Cleanup
@pytest.fixture
```
def sample_file(tmp_path):
file_path = (
tmp_path
/ "sample.txt"
)
```
file_path.write_text(
"Testing Python",
encoding="utf-8"
)
yield file_path
# Pytest removes the temporary folder later.
Code before yield prepares the fixture. Code after yield performs cleanup.
35.13 Parameterized Tests
```Parameterized testing runs the same test logic with several input and expected-output combinations. It avoids creating many nearly identical test functions.
Pytest Parameterization
import pytest
```
def is_even(number):
return number % 2 == 0
@pytest.mark.parametrize(
"number, expected",
[
(2, True),
(3, False),
(0, True),
(-2, True),
(-5, False)
]
)
def test_is_even(
number,
expected
):
assert (
is_even(number)
== expected
)
```
Example Output
test_even.py::test_is_even[2-True] PASSED
```
test_even.py::test_is_even[3-False] PASSED
test_even.py::test_is_even[0-True] PASSED
test_even.py::test_is_even[-2-True] PASSED
test_even.py::test_is_even[-5-False] PASSED
```
unittest Subtests
import unittest
```
class TestEvenNumbers(
unittest.TestCase
):
```
def test_multiple_values(self):
cases = [
(2, True),
(3, False),
(0, True)
]
for number, expected in cases:
with self.subTest(
number=number
):
self.assertEqual(
number % 2 == 0,
expected
)
```
35.14 Mocking
```Mocking replaces a real dependency with a controlled test object. It is useful when real behavior is slow, expensive, unpredictable, unavailable, or unsafe during testing.
Common mocked dependencies include web services, email senders, payment gateways, clocks, random values, file systems, and database clients.
Application Code
def send_welcome_email(
email_service,
address
```
):
message = (
"Welcome to our application."
)
```
return email_service.send(
address,
message
)
Test with a Simple Fake Object
class FakeEmailService:
def __init__(self):
self.sent_messages = []
def send(
self,
address,
message
):
self.sent_messages.append(
(address, message)
)
return True
```
def test_welcome_email():
service = FakeEmailService()
```
result = send_welcome_email(
service,
"sara@example.com"
)
assert result is True
assert service.sent_messages == [
(
"sara@example.com",
"Welcome to our application."
)
]
No real email is sent. The test records what the application attempted to send.
```35.15 The unittest.mock Module
```
The unittest.mock module provides Mock, MagicMock, patch, and other tools for replacing dependencies and verifying interactions.
Mock Example
from unittest.mock import Mock
```
def process_payment(
payment_service,
amount
):
return payment_service.charge(
amount
)
def test_payment():
service = Mock()
```
service.charge.return_value = {
"success": True,
"transaction_id": "T100"
}
result = process_payment(
service,
50
)
assert result["success"] is True
service.charge.assert_called_once_with(
50
)
Patching a Function
# notifications.py
```
def send_email(address, message):
print(
"Sending real email..."
)
```
return True
```
def register_user(
name,
email
):
sent = send_email(
email,
f"Welcome {name}"
)
```
return {
"name": name,
"email_sent": sent
}
# test_notifications.py
```
from unittest.mock import patch
from notifications import register_user
@patch(
"notifications.send_email"
)
def test_register_user(
mock_send_email
):
mock_send_email.return_value = True
```
result = register_user(
"Sara",
"sara@example.com"
)
assert result["email_sent"] is True
mock_send_email.assert_called_once_with(
"sara@example.com",
"Welcome Sara"
)
Patch the name where it is used, not necessarily where it was originally defined.
```35.16 Test Doubles
```A test double is any object used in place of a real dependency during testing. Different types of test doubles serve different purposes.
Common Test Doubles
- Dummy: Passed into code but not actually used.
- Stub: Returns prepared values.
- Fake: Provides a simplified working implementation.
- Spy: Records how it was called.
- Mock: Has predefined expectations about interactions.
Stub Example
class WeatherStub:
def get_temperature(
self,
city
):
return 20
```
def display_temperature(
service,
city
):
temperature = (
service.get_temperature(city)
)
```
return (
f"{city}: "
f"{temperature}°C"
)
```
service = WeatherStub()
print(
display_temperature(
service,
"Toronto"
)
)
```
Output
Toronto: 20°C
Fake Repository Example
class FakeStudentRepository:
def __init__(self):
self.students = {}
def save(self, student):
self.students[
student["id"]
] = student
def find(self, student_id):
return self.students.get(
student_id
)
```
35.17 Integration Testing
```Integration testing checks whether multiple units work correctly together. It may test a service with a repository, an application with a database, or a file parser with a reporting component.
Integration tests are usually slower than unit tests because they involve more real components. However, they can find problems that isolated unit tests cannot detect.
Application Components
class StudentRepository:
def __init__(self):
self.students = {}
def save(self, student):
self.students[
student["id"]
] = student
def find(self, student_id):
return self.students.get(
student_id
)
```
class StudentService:
```
def __init__(
self,
repository
):
self.repository = repository
def register(
self,
student_id,
name
):
student = {
"id": student_id,
"name": name
}
self.repository.save(
student
)
return student
Integration Test
def test_register_and_find_student():
repository = StudentRepository()
service = StudentService(
repository
)
service.register(
101,
"Sara"
)
student = repository.find(
101
)
assert student == {
"id": 101,
"name": "Sara"
}
This test verifies that the service and repository communicate correctly.
```35.18 Functional Testing
```Functional testing checks a complete feature from the user's or requirement's perspective. It focuses on what the application should do rather than how internal functions are implemented.
Requirement
A customer receives free delivery when the subtotal is at least $40. Otherwise, the delivery charge is $5.
Application Code
def calculate_delivery(
subtotal
```
):
if subtotal >= 40:
return 0
```
return 5
Functional Tests
def test_free_delivery_above_minimum():
assert calculate_delivery(50) == 0
```
def test_free_delivery_at_boundary():
assert calculate_delivery(40) == 0
def test_delivery_below_minimum():
assert calculate_delivery(39.99) == 5
```
These tests directly represent the business requirement and its important boundary.
```35.19 End-to-End Testing
```End-to-end testing checks a complete application workflow from beginning to end. It may include user input, business rules, data storage, external services, and final output.
End-to-end tests provide confidence that major workflows operate correctly, but they are usually slower and more difficult to maintain than unit tests.
Example Workflow
- A customer adds products to a cart.
- The program calculates the subtotal.
- A discount is applied.
- Tax and delivery are calculated.
- The order is saved.
- A receipt is produced.
Simple End-to-End Example
def checkout(
items,
tax_rate=0.13
```
):
subtotal = sum(
item["price"]
* item["quantity"]
for item in items
)
```
delivery = (
0
if subtotal >= 40
else 5
)
tax = subtotal * tax_rate
return {
"subtotal": round(
subtotal,
2
),
"tax": round(
tax,
2
),
"delivery": delivery,
"total": round(
subtotal
+ tax
+ delivery,
2
)
}
```
def test_complete_checkout():
items = [
{
"price": 20,
"quantity": 2
}
]
```
receipt = checkout(items)
assert receipt == {
"subtotal": 40,
"tax": 5.2,
"delivery": 0,
"total": 45.2
}
```
35.20 Test Coverage
```Test coverage measures which parts of the source code run during tests. Coverage tools can report executed lines, missed lines, branches, and files.
High coverage does not automatically mean good tests. A test may execute a line without checking its result. Coverage should help identify untested areas, not replace thoughtful test design.
Install Coverage Support
python -m pip install pytest-cov
Run Pytest with Coverage
python -m pytest --cov=app
Show Missing Lines
python -m pytest --cov=app --cov-report=term-missing
Example Coverage Output
Name Stmts Miss Cover Missing
```
---
app/calculator.py 12 2 83% 18-19
app/grades.py 15 0 100%
--------------------------------------
TOTAL 27 2 93%
```
HTML Coverage Report
python -m pytest --cov=app --cov-report=html
This creates an HTML report that can be opened in a browser to inspect covered and uncovered lines.
```35.21 Property-Based Testing
```Property-based testing checks general rules across many automatically generated values. Instead of manually selecting every example, the testing tool generates inputs and searches for values that break the stated property.
Hypothesis is a popular Python library for property-based testing.
Install Hypothesis
python -m pip install hypothesis
Example Property
from hypothesis import given
```
from hypothesis import strategies as st
def reverse_text(text):
return text[::-1]
@given(st.text())
def test_reversing_twice_returns_original(
text
):
assert (
reverse_text(
reverse_text(text)
)
== text
)
```
Math Property Example
from hypothesis import given
```
from hypothesis import strategies as st
@given(
st.integers(),
st.integers()
)
def test_addition_is_commutative(
first,
second
):
assert (
first + second
== second + first
)
```
Hypothesis tries many generated values, including unusual and boundary values that a developer may not think to test manually.
```35.22 Test-Driven Development
```Test-driven development, or TDD, is a process where a developer writes a failing test before writing the production code that satisfies it.
The common cycle is Red, Green, Refactor. Red means write a test that fails. Green means write the smallest code needed to pass. Refactor means improve the design while keeping all tests passing.
Step 1: Write a Failing Test
def test_empty_cart_total_is_zero():
cart = ShoppingCart()
assert cart.total() == 0
The test fails because ShoppingCart does not exist yet.
Step 2: Write the Smallest Code
class ShoppingCart:
def total(self):
return 0
The first test now passes.
Step 3: Add Another Test
def test_total_after_adding_item():
cart = ShoppingCart()
cart.add_item(20)
assert cart.total() == 20
Step 4: Expand the Code
class ShoppingCart:
def __init__(self):
self.prices = []
def add_item(self, price):
self.prices.append(price)
def total(self):
return sum(self.prices)
TDD Cycle
1. Write one failing test.
```
2. Confirm that it fails for the expected reason.
3. Write the smallest working implementation.
4. Run all tests.
5. Improve the design.
6. Repeat.
35.23 Continuous Testing
```Continuous testing means running tests regularly and automatically as code changes. Tests may run when a file is saved, when code is committed, or when changes are submitted to a shared repository.
Continuous integration systems can install dependencies, run tests, perform static checks, measure coverage, and report whether a code change is safe to merge.
Local Testing Commands
python -m pytest
```
python -m pytest -v
python -m pytest --maxfail=1
python -m pytest --cov=app
```
Typical Continuous Testing Process
- Download the latest source code.
- Create a clean Python environment.
- Install project dependencies.
- Run formatting and linting checks.
- Run static type checks.
- Run unit tests.
- Run integration tests.
- Measure coverage.
- Report pass or failure status.
Simple Shell Command
python -m pip install -r requirements.txt
```
python -m pytest --cov=app
python -m mypy app
```
A failed test should stop the automated process so the problem can be corrected before deployment.
```35.24 Chapter Practice Exercises
```Complete these exercises to practise unit testing, pytest, fixtures, parameterization, mocking, integration testing, coverage, and test-driven development.
- Create a manual test plan for a grade calculator.
- Test a function using basic
assertstatements. - Create a
unittest.TestCaseclass. - Test positive, negative, and zero values.
- Use
assertEqual(). - Use
assertTrue()andassertFalse(). - Use
assertIn(). - Use
assertAlmostEqual()for decimal calculations. - Test an expected
ValueError. - Create reusable test data in
setUp(). - Clean up test data in
tearDown(). - Run tests with unittest discovery.
- Create a manual unittest test suite.
- Install and run pytest.
- Convert a unittest test into a pytest test.
- Create a pytest fixture for a shopping cart.
- Create a fixture using
tmp_path. - Write a parameterized grade test.
- Use unittest subtests.
- Create a stub weather service.
- Create a fake repository.
- Create a spy email service.
- Use
Mockto replace a payment service. - Verify that a mock was called once.
- Patch a function in its usage module.
- Write an integration test for a service and repository.
- Write a functional test for free delivery.
- Write an end-to-end checkout test.
- Measure test coverage.
- Create an HTML coverage report.
- Write a property-based test with Hypothesis.
- Test that sorting preserves all original items.
- Use TDD to create a password validator.
- Use TDD to create a shopping cart.
- Add a regression test for a previously corrected bug.
- Create tests for an empty collection.
- Create tests for minimum and maximum values.
- Create tests for invalid types.
- Create tests for missing dictionary keys.
- Build a fully tested order-processing application.
Practice Example: Password Validator
# password_validator.py
```
def validate_password(password):
if len(password) < 8:
return False
```
if not any(
character.isupper()
for character in password
):
return False
if not any(
character.isdigit()
for character in password
):
return False
return True
Pytest Tests
# test_password_validator.py
```
import pytest
from password_validator import (
validate_password
)
@pytest.mark.parametrize(
"password, expected",
[
("Python123", True),
("short1A", False),
("python123", False),
("PythonABC", False),
("A1234567", True)
]
)
def test_password_validation(
password,
expected
):
assert (
validate_password(password)
is expected
)
```
Run Command
python -m pytest test_password_validator.py -v
```
35.25 Chapter Testing Project
```This project builds and tests a small shopping-cart application. It demonstrates unit tests, pytest fixtures, parameterized tests, exception testing, mocking, integration testing, and coverage.
Project Folder Structure
shopping_cart_project/
```
│
├── shop/
│ ├── **init**.py
│ ├── cart.py
│ ├── pricing.py
│ ├── repository.py
│ └── service.py
│
├── tests/
│ ├── test_cart.py
│ ├── test_pricing.py
│ ├── test_service.py
│ └── test_integration.py
│
└── requirements.txt
```
File 1: shop/cart.py
from dataclasses import dataclass
```
@dataclass(frozen=True)
class CartItem:
name: str
price: float
quantity: int
```
def __post_init__(self):
if not self.name.strip():
raise ValueError(
"Item name cannot be empty."
)
if self.price < 0:
raise ValueError(
"Price cannot be negative."
)
if self.quantity <= 0:
raise ValueError(
"Quantity must be greater than zero."
)
def total(self) -> float:
return round(
self.price * self.quantity,
2
)
```
class ShoppingCart:
```
def __init__(self):
self._items: list[CartItem] = []
def add_item(
self,
item: CartItem
) -> None:
self._items.append(item)
def remove_item(
self,
name: str
) -> bool:
for index, item in enumerate(
self._items
):
if item.name == name:
del self._items[index]
return True
return False
def items(self) -> list[CartItem]:
return self._items.copy()
def subtotal(self) -> float:
return round(
sum(
item.total()
for item in self._items
),
2
)
def is_empty(self) -> bool:
return len(self._items) == 0
File 2: shop/pricing.py
TAX_RATE = 0.13
```
FREE_DELIVERY_MINIMUM = 40.00
DELIVERY_CHARGE = 5.00
def calculate_tax(
subtotal: float
) -> float:
if subtotal < 0:
raise ValueError(
"Subtotal cannot be negative."
)
```
return round(
subtotal * TAX_RATE,
2
)
```
def calculate_delivery(
subtotal: float
) -> float:
if subtotal < 0:
raise ValueError(
"Subtotal cannot be negative."
)
```
if subtotal >= FREE_DELIVERY_MINIMUM:
return 0.0
return DELIVERY_CHARGE
```
def calculate_total(
subtotal: float
) -> float:
tax = calculate_tax(subtotal)
delivery = calculate_delivery(
subtotal
)
```
return round(
subtotal + tax + delivery,
2
)
File 3: shop/repository.py
class OrderRepository:
def __init__(self):
self._orders: dict[
int,
dict
] = {}
def save(
self,
order: dict
) -> None:
self._orders[
order["order_id"]
] = order
def find(
self,
order_id: int
) -> dict | None:
return self._orders.get(
order_id
)
def count(self) -> int:
return len(self._orders)
File 4: shop/service.py
from shop.cart import ShoppingCart
```
from shop.pricing import (
calculate_delivery,
calculate_tax,
calculate_total
)
class CheckoutService:
```
def __init__(
self,
repository,
payment_service
):
self.repository = repository
self.payment_service = (
payment_service
)
def checkout(
self,
order_id: int,
cart: ShoppingCart
) -> dict:
if cart.is_empty():
raise ValueError(
"Cannot check out an empty cart."
)
subtotal = cart.subtotal()
tax = calculate_tax(subtotal)
delivery = calculate_delivery(
subtotal
)
total = calculate_total(
subtotal
)
payment_result = (
self.payment_service.charge(
total
)
)
if not payment_result[
"success"
]:
raise RuntimeError(
"Payment failed."
)
order = {
"order_id": order_id,
"items": cart.items(),
"subtotal": subtotal,
"tax": tax,
"delivery": delivery,
"total": total,
"transaction_id": (
payment_result[
"transaction_id"
]
)
}
self.repository.save(order)
return order
File 5: tests/test_cart.py
import pytest
```
from shop.cart import (
CartItem,
ShoppingCart
)
@pytest.fixture
def cart():
return ShoppingCart()
@pytest.fixture
def keyboard():
return CartItem(
name="Keyboard",
price=49.99,
quantity=2
)
def test_new_cart_is_empty(cart):
assert cart.is_empty() is True
def test_add_item(
cart,
keyboard
):
cart.add_item(keyboard)
```
assert cart.is_empty() is False
assert len(cart.items()) == 1
```
def test_item_total(keyboard):
assert keyboard.total() == 99.98
def test_cart_subtotal(
cart,
keyboard
):
cart.add_item(keyboard)
```
cart.add_item(
CartItem(
name="Mouse",
price=20,
quantity=1
)
)
assert cart.subtotal() == 119.98
```
def test_remove_existing_item(
cart,
keyboard
):
cart.add_item(keyboard)
```
result = cart.remove_item(
"Keyboard"
)
assert result is True
assert cart.is_empty() is True
```
def test_remove_missing_item(cart):
result = cart.remove_item(
"Unknown"
)
```
assert result is False
```
@pytest.mark.parametrize(
"name, price, quantity",
[
("", 10, 1),
("Mouse", -5, 1),
("Mouse", 10, 0),
("Mouse", 10, -1)
]
)
def test_invalid_cart_item(
name,
price,
quantity
):
with pytest.raises(
ValueError
):
CartItem(
name=name,
price=price,
quantity=quantity
)
```
File 6: tests/test_pricing.py
import pytest
```
from shop.pricing import (
calculate_delivery,
calculate_tax,
calculate_total
)
@pytest.mark.parametrize(
"subtotal, expected",
[
(0, 0),
(10, 1.30),
(40, 5.20),
(100, 13.00)
]
)
def test_calculate_tax(
subtotal,
expected
):
assert (
calculate_tax(subtotal)
== expected
)
@pytest.mark.parametrize(
"subtotal, expected",
[
(0, 5.00),
(39.99, 5.00),
(40.00, 0.00),
(100.00, 0.00)
]
)
def test_calculate_delivery(
subtotal,
expected
):
assert (
calculate_delivery(subtotal)
== expected
)
def test_total_below_free_delivery():
assert calculate_total(20) == 27.60
def test_total_at_free_delivery_boundary():
assert calculate_total(40) == 45.20
@pytest.mark.parametrize(
"function",
[
calculate_tax,
calculate_delivery,
calculate_total
]
)
def test_negative_subtotal(
function
):
with pytest.raises(
ValueError
):
function(-1)
```
File 7: tests/test_service.py
from unittest.mock import Mock
```
import pytest
from shop.cart import (
CartItem,
ShoppingCart
)
from shop.repository import (
OrderRepository
)
from shop.service import (
CheckoutService
)
@pytest.fixture
def cart():
test_cart = ShoppingCart()
```
test_cart.add_item(
CartItem(
name="Pizza",
price=20,
quantity=2
)
)
return test_cart
```
@pytest.fixture
def repository():
return OrderRepository()
@pytest.fixture
def payment_service():
service = Mock()
```
service.charge.return_value = {
"success": True,
"transaction_id": "TX100"
}
return service
```
def test_successful_checkout(
cart,
repository,
payment_service
):
service = CheckoutService(
repository,
payment_service
)
```
order = service.checkout(
1001,
cart
)
assert order["subtotal"] == 40
assert order["tax"] == 5.20
assert order["delivery"] == 0
assert order["total"] == 45.20
assert order[
"transaction_id"
] == "TX100"
payment_service.charge.assert_called_once_with(
45.20
)
assert repository.find(
1001
) == order
```
def test_empty_cart_fails(
repository,
payment_service
):
service = CheckoutService(
repository,
payment_service
)
```
empty_cart = ShoppingCart()
with pytest.raises(
ValueError,
match="empty cart"
):
service.checkout(
1001,
empty_cart
)
payment_service.charge.assert_not_called()
```
def test_failed_payment(
cart,
repository,
payment_service
):
payment_service.charge.return_value = {
"success": False,
"transaction_id": None
}
```
service = CheckoutService(
repository,
payment_service
)
with pytest.raises(
RuntimeError,
match="Payment failed"
):
service.checkout(
1001,
cart
)
assert repository.count() == 0
File 8: tests/test_integration.py
class FakePaymentService:
def charge(self, amount):
return {
"success": True,
"transaction_id": (
f"FAKE-{amount}"
)
}
```
def test_checkout_integration():
from shop.cart import (
CartItem,
ShoppingCart
)
```
from shop.repository import (
OrderRepository
)
from shop.service import (
CheckoutService
)
cart = ShoppingCart()
cart.add_item(
CartItem(
name="Burger",
price=10,
quantity=3
)
)
repository = OrderRepository()
service = CheckoutService(
repository,
FakePaymentService()
)
order = service.checkout(
2001,
cart
)
assert order["subtotal"] == 30
assert order["tax"] == 3.90
assert order["delivery"] == 5
assert order["total"] == 38.90
saved_order = repository.find(
2001
)
assert saved_order is not None
assert saved_order["order_id"] == 2001
File 9: requirements.txt
pytest
```
pytest-cov
```
Install Requirements
python -m pip install -r requirements.txt
Run All Tests
python -m pytest -v
Example Output
================ test session starts ================
```
collected 24 items
tests/test_cart.py ........... [ 45%]
tests/test_pricing.py ......... [ 83%]
tests/test_service.py ... [ 95%]
tests/test_integration.py . [100%]
================ 24 passed in 0.12s =================
```
Run with Coverage
python -m pytest --cov=shop --cov-report=term-missing
Project Explanation
The cart tests verify item validation, adding items, removing items, subtotal calculation, and empty-cart behavior. Fixtures create reusable cart and product objects.
The pricing tests use parameterization to verify several tax and delivery values, including the free-delivery boundary. Invalid negative subtotals are tested with expected exceptions.
The checkout service tests replace the payment provider with a mock. This allows the tests to control whether payment succeeds and verify the exact amount sent to the payment service.
The integration test uses a real cart, repository, pricing functions, and checkout service together. Only the external payment service is replaced with a simple fake.
How to Run the Project
- Create the project folders shown above.
- Create every Python file in its correct folder.
- Add an empty
__init__.pyfile inside theshopfolder. - Copy each code section into its matching file.
- Open a terminal in the main project folder.
- Install dependencies with
python -m pip install -r requirements.txt. - Run all tests with
python -m pytest -v. - Run coverage with
python -m pytest --cov=shop --cov-report=term-missing. - Create an HTML report using
python -m pytest --cov=shop --cov-report=html. - Open the generated coverage report and inspect missed lines.
Project Challenges
- Add discount-code support and tests.
- Add percentage and fixed discounts.
- Add tests for invalid discount codes.
- Add a maximum order quantity.
- Add duplicate product merging.
- Add a customer loyalty-points calculator.
- Mock an email receipt service.
- Verify that email is sent only after successful payment.
- Add a refund service and tests.
- Add a fake database repository.
- Add temporary JSON storage tests.
- Use Hypothesis to test cart totals.
- Use TDD to add product removal by quantity.
- Reach high branch coverage without writing meaningless tests.
- Create a continuous testing command for the complete project.
A modern course built to help learners study step by step with clarity, comfort, and confidence.