{
const scripts = Array.from(
document.querySelectorAll("script.fcpython-ojs-quiz-config")
);
const script = scripts.find(
(node) => node.dataset.fcpythonRendered !== "true"
);
if (!script) {
return html`<div class="fcpython-quiz fcpython-quiz-warning">
Quiz configuration was not found.
</div>`;
}
script.dataset.fcpythonRendered = "true";
const quiz = JSON.parse(script.textContent);
const container = html`<div class="fcpython-quiz"></div>`;
const title = document.createElement("h3");
title.textContent = quiz.title;
container.appendChild(title);
const instructions = document.createElement("p");
instructions.textContent = quiz.instructions;
container.appendChild(instructions);
const progress = document.createElement("div");
progress.className = "fcpython-quiz-progress";
const counter = document.createElement("p");
counter.className = "fcpython-quiz-counter";
counter.setAttribute("aria-live", "polite");
progress.appendChild(counter);
const tabs = document.createElement("div");
tabs.className = "fcpython-quiz-steps";
tabs.setAttribute("role", "tablist");
tabs.setAttribute("aria-label", "Quiz questions");
progress.appendChild(tabs);
container.appendChild(progress);
const questions = document.createElement("div");
questions.className = "fcpython-quiz-questions";
const feedbackNodes = [];
const questionPanels = [];
const stepButtons = [];
let currentQuestion = 0;
function setStepStatus(questionIndex, status) {
const step = stepButtons[questionIndex];
const question = quiz.questions[questionIndex];
const statuses = {
answered: "answered",
correct: "correct",
incorrect: "incorrect",
unanswered: "not answered",
};
step.classList.remove(
"is-answered",
"is-correct",
"is-incorrect",
"is-unanswered"
);
if (status) {
step.classList.add(`is-${status}`);
}
const statusLabel = status ? `, ${statuses[status]}` : "";
step.setAttribute(
"aria-label",
`Question ${questionIndex + 1}: ${question.prompt}${statusLabel}`
);
}
function showQuestion(questionIndex, focusPanel = false) {
currentQuestion = Math.max(
0,
Math.min(questionIndex, quiz.questions.length - 1)
);
questionPanels.forEach((panel, index) => {
panel.hidden = index !== currentQuestion;
});
stepButtons.forEach((step, index) => {
const isCurrent = index === currentQuestion;
step.classList.toggle("is-current", isCurrent);
step.setAttribute("aria-selected", String(isCurrent));
step.tabIndex = isCurrent ? 0 : -1;
});
counter.textContent = `Question ${currentQuestion + 1} of ${quiz.questions.length}`;
const selected = questionPanels[currentQuestion].querySelector(
"input[type='radio']:checked"
);
const isLastQuestion = currentQuestion === quiz.questions.length - 1;
previous.hidden = currentQuestion === 0;
next.hidden = isLastQuestion || !selected;
check.hidden = !isLastQuestion;
if (focusPanel) {
questionPanels[currentQuestion].focus();
}
}
quiz.questions.forEach((question, questionIndex) => {
const tabId = `${quiz.id}-question-tab-${questionIndex + 1}`;
const panelId = `${quiz.id}-question-panel-${questionIndex + 1}`;
const step = document.createElement("button");
step.type = "button";
step.className = "fcpython-quiz-step";
step.id = tabId;
step.textContent = String(questionIndex + 1);
step.setAttribute("role", "tab");
step.setAttribute("aria-controls", panelId);
step.setAttribute("aria-selected", "false");
step.tabIndex = -1;
step.addEventListener("click", () => showQuestion(questionIndex));
step.addEventListener("keydown", (event) => {
let destination = null;
if (event.key === "ArrowRight") {
destination = (questionIndex + 1) % quiz.questions.length;
} else if (event.key === "ArrowLeft") {
destination =
(questionIndex - 1 + quiz.questions.length) % quiz.questions.length;
} else if (event.key === "Home") {
destination = 0;
} else if (event.key === "End") {
destination = quiz.questions.length - 1;
}
if (destination !== null) {
event.preventDefault();
showQuestion(destination);
stepButtons[destination].focus();
}
});
stepButtons.push(step);
tabs.appendChild(step);
setStepStatus(questionIndex, "");
const panel = document.createElement("div");
panel.className = "fcpython-quiz-panel";
panel.id = panelId;
panel.setAttribute("role", "tabpanel");
panel.setAttribute("aria-labelledby", tabId);
panel.tabIndex = -1;
const fieldset = document.createElement("fieldset");
fieldset.className = "fcpython-quiz-question";
const legend = document.createElement("legend");
legend.textContent = question.prompt;
fieldset.appendChild(legend);
question.options.forEach((option, optionIndex) => {
const label = document.createElement("label");
label.className = "fcpython-quiz-option";
const input = document.createElement("input");
input.type = "radio";
input.name = `${quiz.id}-${question.id}`;
input.value = String(optionIndex);
input.addEventListener("change", () => {
setStepStatus(questionIndex, "answered");
feedbackNodes[questionIndex].textContent = "";
feedbackNodes[questionIndex].className = "fcpython-quiz-feedback";
score.textContent = "";
showQuestion(questionIndex);
});
const text = document.createElement("span");
text.textContent = option;
label.appendChild(input);
label.appendChild(text);
fieldset.appendChild(label);
});
const feedback = document.createElement("p");
feedback.className = "fcpython-quiz-feedback";
feedback.setAttribute("aria-live", "polite");
feedbackNodes.push(feedback);
fieldset.appendChild(feedback);
panel.appendChild(fieldset);
questionPanels.push(panel);
questions.appendChild(panel);
});
container.appendChild(questions);
const actions = document.createElement("div");
actions.className = "fcpython-quiz-actions";
const previous = document.createElement("button");
previous.type = "button";
previous.className = "fcpython-quiz-secondary";
previous.textContent = "Previous";
previous.addEventListener("click", () => {
showQuestion(currentQuestion - 1, true);
});
const next = document.createElement("button");
next.type = "button";
next.textContent = "Next question";
next.addEventListener("click", () => {
showQuestion(currentQuestion + 1, true);
});
const check = document.createElement("button");
check.type = "button";
check.textContent = "Check answers";
const reset = document.createElement("button");
reset.type = "button";
reset.className = "fcpython-quiz-secondary fcpython-quiz-reset";
reset.textContent = "Reset";
const score = document.createElement("p");
score.className = "fcpython-quiz-score";
score.setAttribute("aria-live", "polite");
check.addEventListener("click", () => {
let correctCount = 0;
let firstQuestionToReview = null;
quiz.questions.forEach((question, questionIndex) => {
const selected = container.querySelector(
`input[name="${quiz.id}-${question.id}"]:checked`
);
const feedback = feedbackNodes[questionIndex];
if (!selected) {
feedback.textContent = "Choose an answer before checking.";
feedback.className = "fcpython-quiz-feedback";
setStepStatus(questionIndex, "unanswered");
if (firstQuestionToReview === null) {
firstQuestionToReview = questionIndex;
}
return;
}
const selectedIndex = Number(selected.value);
if (selectedIndex === question.answer_index) {
correctCount += 1;
feedback.textContent = `✅ Correct. ${question.explanation}`;
feedback.className = "fcpython-quiz-feedback is-correct";
setStepStatus(questionIndex, "correct");
} else {
const answer = question.options[question.answer_index];
feedback.textContent = `❌ Not yet. Correct answer: ${answer}. ${question.explanation}`;
feedback.className = "fcpython-quiz-feedback is-incorrect";
setStepStatus(questionIndex, "incorrect");
if (firstQuestionToReview === null) {
firstQuestionToReview = questionIndex;
}
}
});
score.textContent = `Score: ${correctCount}/${quiz.questions.length}`;
if (firstQuestionToReview !== null) {
showQuestion(firstQuestionToReview, true);
}
});
reset.addEventListener("click", () => {
container.querySelectorAll("input[type='radio']").forEach((input) => {
input.checked = false;
});
feedbackNodes.forEach((feedback) => {
feedback.textContent = "";
feedback.className = "fcpython-quiz-feedback";
});
stepButtons.forEach((_, questionIndex) => {
setStepStatus(questionIndex, "");
});
score.textContent = "";
showQuestion(0, true);
});
actions.appendChild(previous);
actions.appendChild(next);
actions.appendChild(check);
actions.appendChild(reset);
container.appendChild(actions);
container.appendChild(score);
showQuestion(0);
return container;
}
FreeCampus Python
Preserve Bugs and Explore General Rules
Turn minimal failures into named regressions, complete an honest red-green-refactor cycle, and use Hypothesis strategies and shrinking to test invariants beyond a few remembered examples.
python-foundations
testing-python-programs
regression-testing
tdd
property-based-testing
hypothesis
Course progress
0%
A hand-picked suite can be thoughtful and still miss a strange input. A user may type a tab where you expected a space, combine Unicode letters, repeat a separator, or provide a number near a boundary you did not remember. Once such a failure is found, a named regression prevents its quiet return. A property test can then explore the broader rule that the one example violated.
This lesson connects three practices:
- regression testing: keep evidence for a defect that once occurred;
- test-driven development (TDD): add one promised behavior through red, green, and refactor; and
- property-based testing: generate many examples from a described input space and check an invariant for all examples explored in the run.
They are complementary. TDD does not discover an unclear contract for you. Generated examples do not replace readable ordinary examples. A regression test does not prove all related inputs. Use each for the question it answers.
As you work, answer:
- How can a large failure be reduced without removing the behavior that causes it?
- What evidence distinguishes a useful red test from a broken test setup?
- Which invariants express more than a list of expected examples?
- How do strategies define valid data and shrinking find a smaller failure?
- When should a generated counterexample become a descriptive regression test?
1. Reduce a failure before preserving it
Meteor Watch normalizes a station callsign for matching:
It works for one ordinary string:
But a user report contains a tab:
The result is "RIDGE\tSEVEN" because split(" ") recognizes only the literal space separator. The original report may have contained several records and a large traceback. Reduce it while preserving the failure:
- remove unrelated records;
- remove unrelated fields;
- shorten the station text;
- replace multiple whitespace forms one at a time; and
- stop when removing the tab makes the failure disappear.
"a\tb" is a smaller counterexample to the same rule:
Run this test against the buggy implementation. It must fail because a tab remains, not because the package import or fixture setup is broken. Then repair the implementation with text.split(), whose no-argument form splits on runs of Unicode whitespace:
Keep the regression after the repair. Its descriptive name records why a tab matters even if a future refactor changes the implementation again.
Record enough history, not an incident report in the test
The name and a short comment may be enough:
Issue links can live in a comment when the history is useful and stable. Do not paste a long ticket into the test. The executable input, expected result, and reason should remain understandable if the link disappears.
2. Make red fail for the intended missing behavior
TDD is often summarized as:
- red: write a small test for one missing or changed behavior;
- green: write the smallest clear production change that satisfies it; and
- refactor: improve structure while all tests remain green.
The color is not enough. A test that is red because meteor_watch cannot import does not demonstrate missing behavior. Read the report and confirm the expected assertion or exception is responsible.
Meteor Watch needs a new "critical" result when wind is at least 110 kph. The current contract has only green, amber, and red. Start with the changed requirement table:
| Wind | Visibility | Expected |
|---|---|---|
| 109.9 | 10 | red |
| 110 | 10 | critical |
Write the boundary test first:
Run only that node. The useful red evidence is assert 'red' == 'critical'. If it passes before production changes, either behavior already exists or the test is not reaching the intended code.
Checkpoint: preserve a meaningful failure
3. Write the smallest clear green change
Add the critical branch before red:
def alert_level(wind_kph, visibility_km):
if wind_kph < 0:
raise ValueError("wind must be non-negative")
if visibility_km < 0:
raise ValueError("visibility must be non-negative")
if wind_kph >= 110:
return "critical"
if wind_kph >= 70 or visibility_km < 1:
return "red"
if wind_kph >= 40 or visibility_km < 5:
return "amber"
return "green"Run the new node, then the existing boundary table. The new test may be green while an old invariant test still allows only {"green", "amber", "red"}. That old failure is valuable: the public vocabulary changed and its test must be updated deliberately.
Do not implement speculative categories, configuration, or notification rules that no current contract asks for. “Smallest” does not mean cryptic; it means no unrequested design expansion.
4. Refactor only while the behavior stays green
The branch ordering is readable, but duplicated validation can move into a helper if several functions use it:
def validate_measurements(wind_kph, visibility_km):
if wind_kph < 0:
raise ValueError("wind must be non-negative")
if visibility_km < 0:
raise ValueError("visibility must be non-negative")
def alert_level(wind_kph, visibility_km):
validate_measurements(wind_kph, visibility_km)
if wind_kph >= 110:
return "critical"
if wind_kph >= 70 or visibility_km < 1:
return "red"
if wind_kph >= 40 or visibility_km < 5:
return "amber"
return "green"Run the complete suite after the extraction. Refactoring means changing structure without intentionally changing public behavior. If you add another category during this step, you have mixed a feature with the refactor and made failures harder to interpret.
Each TDD phase has different evidence. Refactor returns to green after a structural change; it does not quietly add a second requirement.
flowchart LR A["One explicit requirement"] --> B["Red for the intended reason"] B --> C["Small clear implementation"] C --> D["Green focused and full suite"] D --> E["Behavior-preserving refactor"] E -->|"stay green"| D D --> F["Choose next requirement"]
When TDD helps—and when to explore first
TDD is useful when a small behavior can be stated before implementation:
- a parser accepts a documented record;
- a boundary changes at a known value;
- a defect has a reproducible input;
- a new function has a clear contract; or
- an adapter must translate a known status.
Exploration may come first when the problem, library, or user need is unclear. Use a notebook or disposable spike to learn. Then discard or clean the spike, write the contract learned from it, and add durable tests. Pretending an uncertain experiment was known in advance turns TDD into ceremony.
5. State properties that cover more than remembered examples
Examples say what should happen for specific inputs. A property says what relationship should hold across a range of inputs.
Useful property shapes include:
- idempotence: normalizing twice equals normalizing once;
- round trip: encoding then decoding returns the original supported value;
- bounds: a risk score always remains within its promised interval;
- monotonicity: increasing wind while holding visibility fixed cannot reduce severity;
- invariance: adding outside whitespace does not change a normalized callsign; and
- model agreement: a production result equals a smaller independently trusted model.
For callsigns:
That is a property-shaped example. It communicates the invariant but explores only one string. Hypothesis can generate many strings from a strategy.
6. Install Hypothesis and generate valid text
Install Hypothesis in the practice environment:
The repository declares Hypothesis as a development dependency, so a Poetry development installation includes it.
Import given and strategies, conventionally named st:
For each generated text, Hypothesis calls the test and checks the assertion. The exact number of attempted examples is configurable and may include database replays or targeted cases; do not assert that a run always uses one fixed list.
The empty string is valid under st.text(). Decide whether it belongs to the normalizer contract. If empty callsigns should be rejected, test a validator with a strategy that represents its accepted domain rather than hiding all empty failures.
Constrain the strategy to the contract
If a station callsign accepts letters, digits, spaces, tabs, and hyphens with a maximum raw length of 40:
Use it:
Constraining generation directly is usually clearer and faster than generating arbitrary huge strings and rejecting most with .filter(...) or assume(...). Use assume when a relationship between generated values is genuinely hard to encode, and watch for health-check evidence that too many examples are discarded.
Checkpoint: choose properties and strategies
7. Read the counterexample and the shrink
Restore the buggy split(" ") normalizer and run the idempotence property. Idempotence may still pass because preserving a tab twice is stable. This is an important lesson: a true but weak property can miss the defect.
Add an invariant from the contract: normalized output contains no whitespace other than single spaces between non-space tokens.
Hypothesis may report a falsifying example such as text='0\t0'. It tries to shrink a failure: find a simpler value that still makes the assertion false. A small counterexample helps reveal the rule and reduces debugging noise.
Representative output:
Do not assume the first shown value is the only possible failure. Read the assertion, strategy domain, and minimized example. Reproduce it as a direct call, confirm split(" ") is responsible, and repair with split().
Generation searches the described domain. Shrinking keeps the failure while removing irrelevant complexity, producing a more useful debugging input.
flowchart LR
A["Callsign strategy"] --> B["Generated examples"]
B --> C{"Property holds?"}
C -->|"yes"| B
C -->|"no"| D["Failing example"]
D --> E["Shrink while failure remains"]
E --> F["Minimal counterexample"]
F --> G["Diagnosis and regression"]
Preserve important generated failures
Hypothesis maintains an examples database that normally replays failures. Still add a named ordinary regression when the value represents a meaningful bug or communicates a boundary:
The property continues exploring variations. The named regression explains the specific obligation without depending on database state.
8. Build numeric strategies around real boundaries
Properties can complement exact alert examples. Define severity ordering:
Increasing wind should not reduce severity while visibility is fixed:
from hypothesis import given
from hypothesis import strategies as st
@given(
lower=st.floats(
min_value=0,
max_value=200,
allow_nan=False,
allow_infinity=False,
),
increase=st.floats(
min_value=0,
max_value=200,
allow_nan=False,
allow_infinity=False,
),
visibility=st.floats(
min_value=0,
max_value=20,
allow_nan=False,
allow_infinity=False,
),
)
def test_more_wind_never_reduces_alert_severity(
lower,
increase,
visibility,
):
higher = lower + increase
lower_severity = SEVERITY[alert_level(lower, visibility)]
higher_severity = SEVERITY[alert_level(higher, visibility)]
assert higher_severity >= lower_severityThe strategy excludes NaN and infinity because the current numeric contract accepts finite non-negative measurements. If production should reject non-finite floats, add explicit examples and a separate property for that validation contract.
Properties can be wrong. If increasing wind legitimately changes another mode, the monotonic claim may overconstrain behavior. Review the property like any expected value.
9. Reproduce generated failures without hard-coding a random seed
Hypothesis reports a counterexample and saves it in its database. Rerun the focused node first. During investigation:
- copy the minimal value into a direct function call;
- confirm the property fails for the documented reason;
- simplify production or the property one change at a time;
- retain a named regression if the example communicates a real defect; and
- rerun the property and full suite.
Hypothesis offers settings, seeds, and explicit @example values, but do not pin a seed merely to turn property-based exploration into one fixed random list. Use @example(...) when a known case should always accompany generated cases; use a normal regression test when a descriptive node is more helpful.
Checkpoint: interpret shrinking and combine evidence
10. Shrink the strange callsign
Build a final lab around normalize_callsign and alert_level:
- begin with the buggy
split(" ")normalizer; - keep ordinary examples for trimming, casing, repeated spaces, and hyphens;
- add an idempotence property and explain why it does not expose every whitespace bug;
- define a bounded callsign strategy including letters, digits, spaces, tabs, and hyphens;
- add a property that normalized output has no tabs, no doubled spaces, and no outside whitespace;
- retain Hypothesis’s minimal counterexample and reduce it as a direct call;
- add a named tab regression, verify it is red, and fix production with
split(); - add the critical wind requirement through a separate red–green–refactor cycle;
- add the monotonic-severity property for finite non-negative measurements; and
- run ordinary examples, regressions, properties, and the complete project from a clean process.
Your evidence log should distinguish the regression failure, TDD boundary failure, and property counterexample. Do not describe one as proof of the others.
Hint 1: make the whitespace property stricter than idempotence
Assert that"\t" and " " are absent and that normalized == normalized.strip(). The buggy result can be stable under a second call while still containing forbidden whitespace.
Hint 2: constrain generation at the strategy
Usest.text with an alphabet containing letters, digits, " ", "\t", and "-", plus min_size=1 and max_size=40. Avoid generating arbitrary objects and discarding nearly all of them.
Hint 3: keep the two red phases separate
Finish and verify the normalization regression before starting the new critical-alert requirement. A refactor happens with the existing suite green; the next feature begins with a new intentional red node.Show a complete normalization property group
from hypothesis import given
from hypothesis import strategies as st
from meteor_watch.callsigns import normalize_callsign
callsign_text = st.text(
alphabet=st.characters(
categories=("L", "N"),
include_characters=" \t-",
),
min_size=1,
max_size=40,
)
def test_callsign_trims_collapses_and_uppercases():
assert normalize_callsign(" ridge seven ") == "RIDGE SEVEN"
def test_tab_between_callsign_words_is_collapsed_regression():
assert normalize_callsign("0\t0") == "0 0"
@given(callsign_text)
def test_normalization_is_idempotent(text):
once = normalize_callsign(text)
assert normalize_callsign(once) == once
@given(callsign_text)
def test_normalized_callsign_uses_single_spaces(text):
normalized = normalize_callsign(text)
assert "\t" not in normalized
assert " " not in normalized
assert normalized == normalized.strip()The repaired implementation is intentionally small:
Add the separate critical threshold and monotonic-severity work from the lab contract before declaring the lesson complete.Key points
- Reduce a real failure to the smallest input that preserves its cause.
- Keep meaningful failures as descriptive regression tests after the repair.
- TDD red must fail for the intended missing behavior, not a broken environment.
- Green is the smallest clear implementation of the current requirement; refactor changes structure while the complete suite remains green.
- Explore first when the problem is unclear, then turn what you learned into a contract and durable tests.
- Properties express relationships such as idempotence, round trips, bounds, and monotonicity across generated examples.
- Strategies should describe the supported input domain directly.
- Shrinking searches for a simpler value that still fails.
- A passing weak property can miss a defect; review the property as carefully as an expected example.
- Keep communicative examples, named regressions, and broader properties together when each supplies distinct evidence.