Python Lesson 29: parametrize() Turns 1 Test Into 10
š Full written solution: https://interview-kit-fe.vercel.app/python-lesson-29-testing-with-pytest š» Code on GitHub: https://github.com/timepasshub2539-hue/leetcode-python/tree/main/python-lesson-29-testing-with-pytest Chapters: 0:00 You Tested It. It Still Broke. 0:11 Hey, I'm Kai 0:33 The Real Fear 0:40 What pytest Actually Is 0:53 Your First Test 1:14 Assertions Are Just Checks 1:37 Breaking It On Purpose 2:05 Stop Repeating Setup 2:21 One Test, Many Inputs 2:44 The Habit, Not the Tool 3:06 Closing the Loop 3:18 What to Remember Learn pytest from scratch: writing your first test, using plain assert statements, deliberately breaking code to prove a test actually catches bugs, sharing setup with fixtures, and covering many inputs with parametrization. By the end you'll have the test-writing habit, not just the syntax. #python #pytest #unittesting #softwaretesting #codingtutorial Watch next: - The Problem: Length of Longest Subarray With at Most K Frequency #shorts: https://youtu.be/wJhY8bNTjjE - Length of Longest Subarray With at Most K Frequency: https://youtu.be/1A9kGJZp-p4 - This Code Passed Every Test. It Still Crashed. #shorts: https://youtu.be/I1wB4_W_Q3I
8. Introduction: Why Testing Matters Before You Deploy
Imagine you write a function to add two numbers. It works perfectly today. Two weeks later, a teammate changes a variable name nearby, or updates a dependency version, and suddenly the output is wrong. The user complains first because they didn't know it was broken until then. That is the silent killer of software quality.
In the world of professional software engineering, we don't rely on "hope." We rely on automated tests. But writing them manually for every single input can feel like drudgery. Today, we are diving deep into one of the most powerful tools in the Python ecosystem: pytest. Specifically, we will master the concept of parametrization. This is not just about syntax; it is about shifting your mindset from "testing once" to "testing exhaustively."
Interviewers love testing questions because they reveal whether a developer thinks about failure modes or only happy paths. By the end of this lesson, you will understand how to write assertions that catch bugs instantly, how to use fixtures to avoid code duplication, and crucially, how @pytest.mark.parametrize allows you to cover zero inputs, negative numbers, and massive integers without writing ten separate files.
9. Problem Overview
The core challenge in software development is maintaining stability. As code evolves, old logic breaks unless verified automatically. The "problem" we are solving here is twofold:
- Repetition: Writing the same test setup for every single scenario is inefficient and prone to human error (copy-paste mistakes).
- Coverage Gaps: Developers naturally focus on the "happy path" (the code working as expected). We often forget to write tests for edge cases, leading to subtle bugs that only appear in production.
We will build a simple function called add and create a testing suite that proves it works not just for $2+3$, but for an infinite variety of inputs. We will move from the primitive assert statement to a robust, parametrized architecture that scales.
10. Example
Let's look at what we are building. We have a simple function:
def add(a, b):
return a + b
And we want to verify it behaves correctly in different scenarios. Here is the expected behavior we need to validate:
- Happy Path:
add(2, 3)should return5. - Edge Case (Zero):
add(0, 10)should return10(identity property). - Edge Case (Negative):
add(-1, -4)should return-5. - Large Numbers:
add(10**9, 2)should handle large integers correctly.
11. Intuition: How an Experienced Engineer Discovers the Solution
How do we get from "one test" to "a suite of tests"? The intuition comes from data-driven thinking.
Instead of writing a new function test_add_zero, then another test_add_negative, think of the inputs as data points in a table.
| Input A | Input B | Expected Output |
|---|---|---|
| 2 | 3 | 5 |
| 0 | 10 | 10 |
| -1 | -4 | -5 |
If you view your tests as a dataset, you stop seeing them as separate code blocks. You see them as rows of data that need to feed into the same logic. This is why @pytest.mark.parametrize exists: it allows you to define this table once and let the test runner execute your single test function multiple times, feeding each row's data into the arguments automatically.
The "aha" moment usually hits when you realize: "I am not writing 10 tests; I am defining 10 scenarios that share one logic." This reduces cognitive load and ensures that every time you fix a bug in your test assertion, it fixes the bug for all those edge cases simultaneously.
12. Brute Force Solution
Before we use the decorator, let's see the "Brute Force" approach. You simply copy-paste the same test function over and over again.
The Algorithm
- Define
test_add_zero. Inside, assert the result. - Define
test_add_negative. Inside, assert the result. - Define
test_add_large_numbers. Inside, assert the result. - Repeat for every single edge case.
Code Structure (Brute Force)
def test_add_simple():
assert add(2, 3) == 5
def test_add_zero():
assert add(0, 10) == 10
def test_add_negative():
assert add(-1, -4) == -5
Analysis
- Advantages: It is easy to understand for a beginner. No decorators or advanced syntax required.
- Disadvantages:
- Duplication: You are repeating the
assertlogic (the mental check "I expect X") multiple times. If you decide to change how you report errors, you have to update three functions instead of one. - Maintenance: If the test fails, you have to look at multiple function definitions to understand which row failed.
- Scalability: Writing a test for every possible edge case becomes tedious and painful quickly.
- Duplication: You are repeating the
Complexity
- Time Complexity: $O(N)$ where $N$ is the number of test cases written manually. The setup time scales linearly with the number of inputs.
- Space Complexity: $O(1)$ per function, but high overall file size due to code duplication.
13. Optimal Solution: @pytest.mark.parametrize
Now, let's reconstruct the solution using the optimal approach described in this lesson. We will use the parametrize decorator.
The Algorithm
- Define a list (or list of lists) containing tuples of inputs and expected outputs.
- Decorate your single test function with
@pytest.mark.parametrize. Pass the parameter name(s) and the data rows. - Inside the function, use placeholders (e.g.,
a,b) for the arguments.
Visualizing the Mechanism
Think of this like a loop that runs behind the scenes:
Row 1: Input A=2, B=3 -> Call test_function(2, 3)
Row 2: Input A=0, B=10 -> Call test_function(0, 10)
Row 3: Input A=-1, B=-4 -> Call test_function(-1, -4)
Python Solution
import pytest
def add(a, b):
return a + b
# The parametrize decorator takes two arguments:
# 1. The name of the parameters in the function definition ('a', 'b')
# 2. A list of lists where each inner list is a row of data (input, expected_output)
@pytest.mark.parametrize("a, b", [
(2, 3), # Row 1
(0, 10), # Row 2: Edge case - Zero
(-1, -4), # Row 3: Edge case - Negative numbers
(10**9, 2), # Row 4: Edge case - Large integers
])
def test_add(a, b):
# This single line is now executed once for every row above
assert add(a, b) == a + b
Why This Works
The decorator creates distinct test functions dynamically at runtime. If you run pytest -v, you will see four separate tests in the output:
test_add[2-3]test_add[0-10]test_add[-1--4]test_add[1000000000-2]
This separation allows you to see exactly which input caused the failure, while maintaining a single, clean definition of logic.
14. Python Code (Production Quality)
Here is the complete, production-quality module including error handling and fixtures to demonstrate best practices.
# test_add.py
import pytest
def add(a: int, b: int) -> int:
"""
Adds two integers.
Args:
a (int): The first number.
b (int): The second number.
Returns:
int: The sum of a and b.
"""
return a + b
# Fixture to provide a shared utility if needed,
# though for simple math we can keep it simple.
@pytest.fixture(scope="function")
def calculator():
return lambda x, y: x + y
# The core parametrized test
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5), # Standard case
(0, 10, 10), # Edge case: Zero identity
(-1, -4, -5), # Edge case: Negative numbers
(10**9, 2, 1000000002), # Edge case: Large integers
])
def test_addition(a, b, expected):
"""
Verifies that add() returns the correct sum for various inputs.
This test covers the happy path and critical edge cases.
"""
assert add(a, b) == expected
# Fixture example: Setting up a mock or shared environment
@pytest.fixture
def sample_data():
"""Creates a reusable object to avoid copy-paste setup."""
return {"x": 10, "y": 20}
def test_sample_data_consistency(sample_data):
"""
Demonstrates how fixtures work.
If we needed complex setup, we'd do it here once and pass 'sample_data' to tests.
"""
assert sample_data["x"] + sample_data["y"] == 30
15. Code Walkthrough
Let's break down the test_addition function line by line.
import pytest: This imports the testing framework. Without it, the decorators won't be recognized.@pytest.mark.parametrize("a, b", ...): This decorator is the key."a, b"tells pytest which variables in the test function below should be replaced by the data from the rows.- The list
[(2, 3), (0, 10)...]represents the dataset. Each tuple is one row of execution.
def test_addition(a, b, expected):: Notice that our function definition now matches the parameter names defined in the decorator. Pytest will inject the correct values for each row into these variables before running the code inside the function.assert add(a, b) == expected: This is the assertion. It compares the actual output ofadd()with our manually calculated expectation. If they differ, pytest halts immediately and prints a clear error message indicating which specific row failed.
16. Dry Run: Tracing One Complete Execution
Let's simulate what happens when you run pytest on our file, specifically focusing on the second row of data: (0, 10).
Step 1: Test Discovery
Pytest scans the file and finds test_addition. It sees it has a parametrize decorator with 2 parameters (a, b).
Step 2: Data Extraction
The decorator extracts the second row from our list: [0, 10]. Wait, our example included expected in the optimized version above. Let's stick to the simpler two-parameter version for clarity on the dry run.
- Row 2 Input:
a=0,b=10.
Step 3: Function Instantiation
Pytest generates a unique test instance, conceptually named test_addition[0-10]. It binds a to 0 and b to 10 in the local scope of the function.
Step 4: Execution The function body runs:
- Calls
add(0, 10). - Internal logic computes
0 + 10 = 10. - Assert checks: Is
10equal to the expected result? (Assuming we storedexpected=10in the decorator data).
Step 5: Result Handling
Since 10 == 10, the check passes. Pytest prints a green "PASSED" for this specific run and proceeds immediately to load the next row (-1, -4).
If we had written add(0, 10) expecting 99 (a bug), Step 5 would catch it instantly: AssertionError: assert 10 == 99. The error message explicitly states "Expected: 99" and "Got: 10".
17. Complexity Analysis
Time Complexity
- Setup: Parsing the decorator arguments takes constant time relative to the number of rows $N$, but this happens once at import/module load time.
- Execution: The test body runs $N$ times. If the function inside (
add) takes $O(1)$ time, the total execution time is $O(N)$. - Comparison with Brute Force: In brute force, you write $N$ functions. In parametrized testing, you write 1 function and define a list of size $N$. The computational overhead is negligible because the test runner's loop is highly optimized in C (inside pytest).
Space Complexity
- Storage: We store $N$ tuples in memory for the decorator arguments. This is $O(N)$.
- Runtime: Since we are not creating new objects for every row (just reusing the function and passing integers), the auxiliary space is minimal, effectively $O(1)$ per run of the test body.
18. Alternative Solutions
Fixtures vs. Parametrization
Sometimes you might think, "Can't I just copy-paste?" No, that's brute force. What about fixtures?
A fixture (like @pytest.fixture) is for setup code.
- Scenario: You have a database connection string. You don't want to paste the login logic in every test. You make it a fixture.
- Difference: Fixtures manage state and lifecycle of objects. Parametrization manages data inputs for functions. They are often used together! You can parametrize your input values inside a test that uses a fixture.
Manual Data-Driven Testing (CSV/JSON)
Another alternative is reading test cases from a data.json file. This is useful if your test data changes frequently and you want to separate logic from data. However, parametrize is preferred for unit tests because the logic is tightly coupled with the expected behavior in the code itself, making reviews easier.
19. Edge Cases: The Real Value of Parametrization
The true power of this lesson shines here. Without parametrization, developers often skip these cases because they feel "extra work":
- Empty Inputs:
add(None, None)(Type error, but good to catch). - Zeroes:
add(0, 0),add(-5, 5)(Result 0). - Max Integers: Python handles arbitrary precision integers, so overflow isn't an issue like in C++, but testing limits is still vital for performance profiling.
- Negative Numbers: Ensuring signs are handled correctly.
By placing these rows into the decorator list, you force yourself to think about them while coding. You cannot easily forget a row of data that sits right at the top of your test file.
20. Common Mistakes
- Mistaking Parameters for Variables: Writing
@pytest.mark.parametrize("x", ...)but defining the function asdef test_x(y):. This causes a naming conflict and confusion. Ensure the variable names in the decorator match the function arguments exactly. - Hardcoding Values in Tests: Don't put actual values like
add(1, 2)inside an assertion if you are parametrizing. Use the variables passed from the decorator (e.g.,assert add(a, b) == expected). Hardcoding defeats the purpose of automation. - Ignoring the
expectedColumn: Sometimes people only pass inputs(a, b)and forget to provide what the output should be. Theassertstatement needs a target to compare against. You can use a helper function or just compare to a computed value, but explicitexpectedvalues are better for debugging. - Using Lists of Tuples vs. Dicts: While you can pass dicts `{ "a": 2, "b":
The solution
import pytest
@pytest.mark.parametrize("n,expected", [
(0, 0),
(-5, 5),
(1000000, 1000000),
])
def test_abs(n, expected):
assert abs(n) == expectedReady to try it yourself? Solve Python problems with instant feedback in the practice sandbox.
Practice now ā


