Table-Driven Tests in Python: A Go-Inspired Approach
Table of Contents
Go-inspired Table Driven Tests in Python #
I appreciate Go’s simplicity and explicitness. While I write much of my code in Python, I enjoy adapting patterns from other languages to see how well they fit Python’s idioms.
One aspect of Go I particularly appreciate is its testing structure. Table-driven tests provide an elegant way to define readable and maintainable test suites by declaring test inputs and expected outputs. I had wanted to bring this pattern to my Python codebases but hadn’t prioritized it until my colleague at Deepinsight, Amiran Gorgazjan, used Claude to generate the test structure we had discussed for code I’d written.
Seeing how modern LLMs like Claude can quickly materialize shared ideas into working code inspired me to adopt this pattern in new projects. This note shares the balanced and flexible test structure I’ve developed through this exploration.
All examples in this article are available in the demonstration repository.
What Are Table-Driven Tests? #
Table-driven tests are a well-known pattern for writing clean and scalable tests. The approach involves defining a table that describes different test cases (default, happy path, failure scenarios, etc.), then iterating through the table to execute the necessary operations for each case.
Table-Driven Tests in Python #
This approach isn’t new to Python. Lorenzo Peppoloni has written an excellent article explaining how to use this pattern with Python’s unittest framework.
This article presents an alternative implementation using pytest and Python’s dataclass module.
Simple table driven test #
For this example, I’ll use the generate_short_code function from shortener.py.
import hashlib
import string
from pydantic import HttpUrl
SHORT_CODE_LENGTH = 6
ALPHABET = string.ascii_letters + string.digits
ALPHABET_LENGTH = len(ALPHABET)
def generate_short_code(url: HttpUrl, length: int = SHORT_CODE_LENGTH) -> str:
hash_digest = hashlib.sha256(url.unicode_string().encode()).hexdigest()
hash_int = int(hash_digest, 16)
chars = []
for _ in range(length):
hash_int, remainder = divmod(hash_int, ALPHABET_LENGTH)
chars.append(ALPHABET[remainder])
return "".join(reversed(chars)).rjust(length, "0")
This trivial function generates a short code from a given URL (validated as an HttpUrl).
To write a table-driven test for this function, we’ll start by defining a test scenario structure using dataclass:
from dataclasses import dataclass
from pydantic import HttpUrl
@dataclass
class GenerateShortCodeTestScenario:
name: str
url: HttpUrl
expected_code: str
name— the test case identifierurl— the input URL for the functionexpected_code— the expected output to verify against
Next, we define our test scenarios. For cleaner test data construction, we’ll use Pydantic’s TypeAdapter.
from dataclasses import dataclass
from pydantic import HttpUrl, TypeAdapter
TA = TypeAdapter(HttpUrl)
GENERATE_SHORT_CODE_TEST_SCENARIOS: list[GenerateShortCodeTestScenario] = [
GenerateShortCodeTestScenario(
name="default",
url=TA.validate_python("https://domain.invalid/me"),
expected_code="Yox6eL",
),
GenerateShortCodeTestScenario(
name="with_query_params",
url=TA.validate_python("https://example.com/search?q=pytest&lang=en"),
expected_code="RhtMxd",
),
...
]
We then use these scenarios in our test through pytest parametrization.
The ids parameter customizes test names in pytest output, using our scenario names for clearer test reports.
@pytest.mark.parametrize(
"scenario",
GENERATE_SHORT_CODE_TEST_SCENARIOS,
ids=lambda scenario: scenario.name,
)
def test_generate_short_code(
scenario: GenerateShortCodeTestScenario,
) -> None:
actual = shortener.generate_short_code(scenario.url)
assert actual == scenario.expected_code
This approach is clean and maintainable. To test additional cases, simply add more scenarios to the GENERATE_SHORT_CODE_TEST_SCENARIOS list.
Full code is accesible in test_shortener.py.
Using pytest fixtures with Table-Driven tests #
Some tests require setup code to run before assertions execute. This setup code may share common logic while varying inputs and outputs across test cases. A typical example is testing GET endpoints, where you need to verify endpoint behavior against different database states.
For this example, we’ll use the GET /{code} endpoint:
@router.get(
"/{code}",
response_model=None,
)
def redirect_to_original(
code: str,
session: Session = Depends(get_session),
) -> RedirectResponse:
shortened_url = session.get(
ShortenedURL,
code,
)
if not shortened_url:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Short URL 'https://domain.invalid/{code}' is not found",
)
return RedirectResponse(
url=shortened_url.original_url,
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
The logic is straightforward: if the code maps to a URL in the database, return a RedirectResponse; otherwise, return a 404 error.
To test this endpoint, we’ll populate the database first, then verify the endpoint behavior. The test scenario structure looks like this:
@dataclass
class RedirectTestScenario:
name: str
setup: Callable[[Session], None]
code: str
setup_url: HttpUrl | None = None
expected_status_code: int = status.HTTP_307_TEMPORARY_REDIRECT
expected_redirect_url: str | None = field(default=None)
setup— aCallable[[Session], None]used to configure the database state for each test.
For the database setup, I use this function:
def setup_db(session: Session) -> None:
session.add_all(
[
ShortenedURL(
code="ABCDEF",
original_url="https://domain.invalid",
created_at=datetime(2025, 12, 2, 0, 0, 0),
),
]
)
session.commit()
return
The test scenarios follow the same structure:
REDIRECT_TEST_SCENARIOS: list[RedirectTestScenario] = [
RedirectTestScenario(
name="successful-redirect",
setup=setup_db,
code="ABCDEF",
expected_status_code=status.HTTP_307_TEMPORARY_REDIRECT,
expected_redirect_url="https://domain.invalid",
),
RedirectTestScenario(
name="non-existing-code",
setup=lambda session: None,
code="non-existing-code",
expected_status_code=status.HTTP_404_NOT_FOUND,
expected_redirect_url=None,
),
...
]
I’ll skip the session and client fixture configuration here (available in the repository) and focus on the test body:
@pytest.mark.parametrize(
"scenario",
REDIRECT_TEST_SCENARIOS,
ids=lambda scenario: scenario.name,
)
def test_redirect(
scenario: RedirectTestScenario, session: Session, client: TestClient
) -> None:
scenario.setup(session)
response = client.get(f"/{scenario.code}", follow_redirects=False)
assert response.status_code == scenario.expected_status_code
if scenario.expected_redirect_url is not None:
assert response.headers.get("location") == scenario.expected_redirect_url
Each test sets up the database with its specific scenario data, which varies across test cases. This single test body handles both the happy path and error cases.
Conclusion #
Table-driven tests with dataclasses provide a maintainable and scalable approach to testing in Python. By separating test data from test logic, you can easily add new test cases, improve readability, and reduce code duplication across your test suite.
When to Use Table-Driven Tests #
This pattern works best when:
- Test cases share the same execution logic but differ in inputs/outputs
- You need to test many variations of similar scenarios
- Setup and assertions follow a consistent pattern
- Readability benefits from seeing all cases together
When to Avoid This Pattern #
Consider separate test functions when:
- Tests require significantly different assertion logic
- Setup code becomes too complex (requiring its own tests)
- Test cases have divergent execution paths
- You have too many scenarios
For complex scenarios with unique requirements, separate test functions often provide better clarity than forcing everything into a table structure.