Skip to content

Commit 8d74f64

Browse files
travisjneumanclaude
andcommitted
feat: add tests for expansion modules 01-06
Adds pedagogically annotated test files for all 30 projects across web scraping, CLI tools, REST APIs, FastAPI, async Python, and databases/ORM modules. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f0adafc commit 8d74f64

30 files changed

Lines changed: 4714 additions & 0 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Tests for Module 01 / Project 01 — Fetch a Webpage.
2+
3+
These tests verify that fetch_page() and display_response_info() work
4+
correctly WITHOUT making real HTTP requests. We use unittest.mock.patch
5+
to replace requests.get() with a fake that returns controlled data.
6+
7+
WHY mock HTTP requests?
8+
- Tests should be fast and not depend on network access.
9+
- The real server might be slow, down, or rate-limit us.
10+
- Mocking lets us test our code in isolation from external services.
11+
"""
12+
13+
import sys
14+
import os
15+
from unittest.mock import patch, MagicMock
16+
17+
import pytest
18+
19+
# Add the project directory to the Python path so we can import project.py.
20+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
21+
22+
from project import fetch_page, display_response_info
23+
24+
25+
# ---------------------------------------------------------------------------
26+
# Helpers: create fake response objects
27+
# ---------------------------------------------------------------------------
28+
29+
def make_fake_response(status_code=200, text="<html>Hello</html>", headers=None):
30+
"""Build a MagicMock that behaves like a requests.Response object.
31+
32+
MagicMock is a flexible fake object — you can set any attribute on it,
33+
and it will just work. This is much simpler than creating a real Response.
34+
"""
35+
response = MagicMock()
36+
response.status_code = status_code
37+
response.text = text
38+
response.headers = headers or {"Content-Type": "text/html; charset=utf-8"}
39+
return response
40+
41+
42+
# ---------------------------------------------------------------------------
43+
# Tests for fetch_page()
44+
# ---------------------------------------------------------------------------
45+
46+
@patch("project.requests.get")
47+
def test_fetch_page_returns_response_object(mock_get):
48+
"""fetch_page() should return the response object from requests.get().
49+
50+
We check that:
51+
1. requests.get() is called with the URL we pass in.
52+
2. The return value is the response object (not .text, not .json()).
53+
"""
54+
fake = make_fake_response()
55+
mock_get.return_value = fake
56+
57+
result = fetch_page("http://example.com")
58+
59+
# The function should have called requests.get with our URL.
60+
mock_get.assert_called_once_with("http://example.com")
61+
62+
# The function should return the full response object.
63+
assert result is fake
64+
65+
66+
@patch("project.requests.get")
67+
def test_fetch_page_passes_through_error_status(mock_get):
68+
"""fetch_page() should return the response even when the status is not 200.
69+
70+
The function itself does not raise on error status codes — that is
71+
handled by the caller (main). We verify the response comes back as-is.
72+
"""
73+
fake = make_fake_response(status_code=404, text="Not Found")
74+
mock_get.return_value = fake
75+
76+
result = fetch_page("http://example.com/missing")
77+
78+
assert result.status_code == 404
79+
80+
81+
# ---------------------------------------------------------------------------
82+
# Tests for display_response_info()
83+
# ---------------------------------------------------------------------------
84+
85+
def test_display_response_info_prints_status_code(capsys):
86+
"""display_response_info() should print the status code of the response.
87+
88+
capsys is a pytest fixture that captures stdout. We call the function,
89+
then check that the captured output contains the expected text.
90+
"""
91+
fake = make_fake_response(status_code=200, text="A" * 600)
92+
93+
display_response_info(fake)
94+
95+
output = capsys.readouterr().out
96+
assert "200" in output
97+
98+
99+
def test_display_response_info_prints_content_type(capsys):
100+
"""display_response_info() should print the Content-Type header."""
101+
fake = make_fake_response(headers={"Content-Type": "text/html"})
102+
103+
display_response_info(fake)
104+
105+
output = capsys.readouterr().out
106+
assert "text/html" in output
107+
108+
109+
def test_display_response_info_prints_content_length(capsys):
110+
"""display_response_info() should print the character count of the body.
111+
112+
We pass a body with a known length and verify the number appears in output.
113+
"""
114+
body = "x" * 42
115+
fake = make_fake_response(text=body)
116+
117+
display_response_info(fake)
118+
119+
output = capsys.readouterr().out
120+
assert "42" in output
121+
122+
123+
def test_display_response_info_shows_preview(capsys):
124+
"""display_response_info() should show the first 500 characters of the body.
125+
126+
If the body is longer than 500 characters, only the first 500 should appear.
127+
"""
128+
body = "Hello World! " * 100 # Much longer than 500 chars
129+
fake = make_fake_response(text=body)
130+
131+
display_response_info(fake)
132+
133+
output = capsys.readouterr().out
134+
# The preview should contain text from the body.
135+
assert "Hello World!" in output
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Tests for Module 01 / Project 02 — Parse HTML.
2+
3+
These tests verify that fetch_page() and parse_books() correctly fetch
4+
and parse HTML from books.toscrape.com. All HTTP requests are mocked —
5+
we provide sample HTML that mirrors the real site's structure.
6+
7+
WHY provide sample HTML?
8+
- We control exactly what the parser receives, so tests are deterministic.
9+
- If the real site changes its HTML, our parser tests still pass because
10+
we are testing OUR parsing logic, not the external site's stability.
11+
"""
12+
13+
import sys
14+
import os
15+
from unittest.mock import patch, MagicMock
16+
17+
import pytest
18+
19+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
20+
21+
from project import fetch_page, parse_books, display_books
22+
23+
24+
# ---------------------------------------------------------------------------
25+
# Sample HTML that mirrors the structure of books.toscrape.com
26+
# ---------------------------------------------------------------------------
27+
28+
SAMPLE_HTML = """
29+
<html>
30+
<body>
31+
<article class="product_pod">
32+
<h3><a href="catalogue/book1.html" title="Test Book One">Test Book One</a></h3>
33+
<p class="price_color">£51.77</p>
34+
</article>
35+
<article class="product_pod">
36+
<h3><a href="catalogue/book2.html" title="Test Book Two">Test Book Two</a></h3>
37+
<p class="price_color">£23.99</p>
38+
</article>
39+
</body>
40+
</html>
41+
"""
42+
43+
44+
# ---------------------------------------------------------------------------
45+
# Tests for fetch_page()
46+
# ---------------------------------------------------------------------------
47+
48+
@patch("project.requests.get")
49+
def test_fetch_page_returns_text_on_success(mock_get):
50+
"""fetch_page() should return response.text when status is 200.
51+
52+
The function returns the raw HTML string so that parse_books() can
53+
process it. On success (200), we get the text; on failure, we get None.
54+
"""
55+
fake_response = MagicMock()
56+
fake_response.status_code = 200
57+
fake_response.text = "<html>OK</html>"
58+
mock_get.return_value = fake_response
59+
60+
result = fetch_page("http://example.com")
61+
62+
assert result == "<html>OK</html>"
63+
64+
65+
@patch("project.requests.get")
66+
def test_fetch_page_returns_none_on_failure(mock_get):
67+
"""fetch_page() should return None when the HTTP status is not 200.
68+
69+
This tells the caller that the page could not be fetched, so it
70+
should skip parsing and display an error message.
71+
"""
72+
fake_response = MagicMock()
73+
fake_response.status_code = 500
74+
mock_get.return_value = fake_response
75+
76+
result = fetch_page("http://example.com")
77+
78+
assert result is None
79+
80+
81+
# ---------------------------------------------------------------------------
82+
# Tests for parse_books()
83+
# ---------------------------------------------------------------------------
84+
85+
def test_parse_books_extracts_correct_count():
86+
"""parse_books() should find all <article class='product_pod'> elements.
87+
88+
Our sample HTML has 2 articles, so we expect 2 books.
89+
"""
90+
books = parse_books(SAMPLE_HTML)
91+
92+
assert len(books) == 2
93+
94+
95+
def test_parse_books_extracts_titles():
96+
"""parse_books() should extract the book title from the <a> tag's title attribute.
97+
98+
The title attribute holds the full title (the visible text may be truncated).
99+
"""
100+
books = parse_books(SAMPLE_HTML)
101+
102+
assert books[0][0] == "Test Book One"
103+
assert books[1][0] == "Test Book Two"
104+
105+
106+
def test_parse_books_extracts_prices():
107+
"""parse_books() should extract the price text from the price_color <p> tag.
108+
109+
Prices include the currency symbol (£) and are stripped of whitespace.
110+
"""
111+
books = parse_books(SAMPLE_HTML)
112+
113+
assert books[0][1] == "£51.77"
114+
assert books[1][1] == "£23.99"
115+
116+
117+
def test_parse_books_returns_tuples():
118+
"""parse_books() should return a list of (title, price) tuples.
119+
120+
Each tuple has exactly two elements: the title string and the price string.
121+
"""
122+
books = parse_books(SAMPLE_HTML)
123+
124+
for book in books:
125+
assert isinstance(book, tuple)
126+
assert len(book) == 2
127+
128+
129+
def test_parse_books_empty_html():
130+
"""parse_books() should return an empty list for HTML with no articles.
131+
132+
This handles the edge case where the page loads but has no book listings.
133+
"""
134+
empty_html = "<html><body><p>No books here</p></body></html>"
135+
books = parse_books(empty_html)
136+
137+
assert books == []
138+
139+
140+
# ---------------------------------------------------------------------------
141+
# Tests for display_books()
142+
# ---------------------------------------------------------------------------
143+
144+
def test_display_books_shows_all_titles(capsys):
145+
"""display_books() should print every book title in the output."""
146+
books = [("Book A", "£10.00"), ("Book B", "£20.00")]
147+
148+
display_books(books)
149+
150+
output = capsys.readouterr().out
151+
assert "Book A" in output
152+
assert "Book B" in output

0 commit comments

Comments
 (0)