Testing Basics¶
~20 min
In short¶
A test is code that calls your code with an input whose correct answer you already know, and fails when the answer is wrong. In interviews you need three habits: know the difference between unit and integration tests, test the edge cases of every problem before you trust your solution, and dry run your code by hand. Testing is not a separate skill from DSA. It is how you check the solution you just wrote.
Unit tests and integration tests¶
A unit test checks one function or class on its own, with small inputs you choose. An integration test checks several parts working together, for example a function that reads from a database and then formats the result.
| Unit test | Integration test | |
|---|---|---|
| Scope | One function or class | Several parts, often a real database or file |
| Speed | Very fast | Slower |
| Failure tells you | The logic inside that function is wrong | The parts do not fit together |
Interview problems are almost always unit-level: you write a function and test it directly. Integration tests matter more on the job, where services call each other.
Arrange, act, assert¶
Each test has three steps:
- Arrange: prepare the inputs and anything the code needs.
- Act: call the function.
- Assert: check the result against the answer you expected.
Keep one idea per test. The test name should say the behaviour, such as test_returns_none_for_empty_list, so a failure tells you what broke.
Edge cases for DSA problems¶
Before you trust a solution, run it on these inputs. Write the expected output by hand first.
- Empty input:
[],"", or an empty tree - One element:
[5] - Duplicates:
[5, 5, 5] - Already sorted and reverse sorted input
- Negative numbers and zero
- Target missing: the answer does not exist, so what should the function return?
- Large input: about 10^6 items, to check the running time you claimed
If the problem has limits in its statement, test the smallest and largest allowed values.
Worked example with pytest¶
Install with pip install pytest, then run pytest in the folder. pytest picks up files named test_*.py and functions named test_*. Plain assert is enough, because pytest shows the values when it fails.
The function returns the second largest distinct value:
# second_largest.py
def second_largest(nums: list[int]) -> int:
"""Return the second largest distinct value in nums.
Raises ValueError if nums has fewer than two distinct values.
"""
distinct = set(nums)
if len(distinct) < 2:
raise ValueError("need at least two distinct values")
distinct.remove(max(distinct))
return max(distinct)
The four tests:
# test_second_largest.py
import pytest
from second_largest import second_largest
@pytest.mark.parametrize(
"nums, expected",
[
([3, 1, 2], 2),
([10, -5, 7], 7),
([-1, -2], -2),
],
)
def test_returns_second_largest(nums, expected):
assert second_largest(nums) == expected
def test_duplicates_are_not_counted_twice():
assert second_largest([5, 5, 5, 4]) == 4
@pytest.mark.parametrize("nums", [[], [7], [7, 7]])
def test_too_few_distinct_values_raises(nums):
with pytest.raises(ValueError, match="at least two"):
second_largest(nums)
def test_large_input():
nums = list(range(1_000_000))
assert second_largest(nums) == 999_998
pytest.mark.parametrize runs one test function once per row, so one function covers several inputs. pytest.raises passes only if the block raises the named exception. Without it, an exception would just be an error, not a documented behaviour.
The same ideas work with the standard library's unittest, which uses classes and methods such as self.assertEqual and self.assertRaises. pytest can run unittest tests too. Interviewers care that you test, not which library you use. See the unittest documentation.
Stress testing against a brute-force solution¶
When your fast solution might be wrong, write a slow one that is obviously correct, generate many small random inputs, and compare the two. Small inputs catch most bugs.
# stress.py
import random
from second_largest import second_largest
def brute(nums):
# Slow but obviously correct: sort the distinct values.
distinct = sorted(set(nums), reverse=True)
if len(distinct) < 2:
raise ValueError("need at least two distinct values")
return distinct[1]
def outcome(fn, nums):
try:
return fn(nums)
except ValueError:
return "ValueError"
random.seed(42) # the same seed makes any failure repeat
for trial in range(10_000):
nums = [random.randint(-5, 5) for _ in range(random.randint(0, 8))]
got, want = outcome(second_largest, nums), outcome(brute, nums)
if got != want:
print(f"MISMATCH on {nums}: got {got}, expected {want}")
break
else:
print("10000 random trials matched the brute-force answer")
When a mismatch appears, copy the failing input and shrink it by hand until you can see the bug.
What "dry run your code" means¶
When an interviewer says "dry run", they want you to trace the code by hand on a small input. Write the variables in columns and update them one line at a time. Use an input that hits the tricky branch, such as duplicates. For second_largest([5, 5, 4]):
| Step | nums |
distinct after line |
Return value |
|---|---|---|---|
set(nums) |
[5, 5, 4] |
{4, 5} |
|
remove(max) |
{4} |
||
max(distinct) |
{4} |
4 |
Dry runs catch off-by-one errors, wrong starting values and missed branches before the interviewer does. Say each step out loud. The interviewer is grading how you reason, not only the final answer.
Try this¶
Pick a problem you solved this week. Before running anything, write five inputs with expected outputs: empty, one element, duplicates, a sorted case and a large case. Put them in test_<problem>.py and run pytest. Then write a brute-force version and run a 1,000-trial random comparison.