Pytest.Item Abstraction: Creating TestItemPort & Adapters
Hey guys! Today, we're diving deep into the world of pytest and exploring how to create a TestItemPort along with its adapters for pytest.Item abstraction. This is a crucial step in decoupling our code and making it more testable and maintainable. So, buckle up and let's get started!
Issue Description
Our main goal here is to create a port (think of it as an interface) that abstracts away the dependencies of pytest.Item. We'll also create production and test adapters to go along with it. This abstraction is the foundation for eliminating direct coupling to pytest internals throughout our codebase. By doing this, we're setting ourselves up for a more flexible and robust testing environment.
Problem Statement
Currently, we have functions like _iter_sized_items that are tightly coupled to pytest.Item. This means that these functions directly interact with pytest.Item objects, making it difficult to test them in isolation. Here's a snippet of what that looks like:
def _iter_sized_items(items: list[pytest.Item], session_state: PluginState):
for item in items:
found_sizes = [size for size in TestSize if item.get_closest_marker(size.marker_name)]
# Direct method calls on pytest.Item - hard to test!
Impact of Tight Coupling
The impact of this tight coupling is significant. First and foremost, we can't unit test these functions without creating real pytest.Item objects. This makes our tests slower and more complex. We're forced to use heavy mocking, which can be cumbersome and make our tests less readable. More importantly, the domain logic within these functions can't be tested in isolation, making it harder to ensure that our code is working correctly. It is important to understand the impact of this type of issue, the whole project could be affected if we do not solve it.
Why Decoupling Matters
Decoupling, in this context, refers to the practice of reducing the dependencies between different parts of our codebase. By decoupling our code, we make it easier to test, maintain, and extend. When components are loosely coupled, changes in one component are less likely to have unintended consequences in other components. This leads to a more robust and reliable system. In our case, decoupling from pytest.Item allows us to write unit tests that focus solely on the logic of our functions, without the overhead of setting up and managing pytest objects.
Solution Design
To tackle this, we're going to implement a solution based on the hexagonal architecture pattern. This pattern helps us to isolate our domain logic from external dependencies, like pytest. Here’s the plan:
1. Create Port Interface
First, we'll create a TestItemPort interface. This interface will define the methods that our plugin logic needs to interact with, without directly using pytest.Item. This is the cornerstone of our decoupling strategy.
File: src/pytest_test_categories/types.py
from abc import ABC, abstractmethod
class TestItemPort(ABC):
"""Port for test item abstraction following hexagonal architecture pattern.
This interface decouples plugin logic from pytest.Item implementation,
enabling fast unit tests without pytest infrastructure (similar to TestTimer pattern).
"""
@property
@abstractmethod
def nodeid(self) -> str:
"""Get the unique node ID for this test."""
@abstractmethod
def get_marker(self, name: str) -> bool:
"""Check if test has a specific marker.
Args:
name: The marker name to check for
Returns:
True if the marker exists, False otherwise
"""
@abstractmethod
def set_nodeid(self, nodeid: str) -> None:
"""Update the test's node ID (used for size label appending).
Args:
nodeid: The new node ID string
"""
The TestItemPort interface defines three abstract methods:
nodeid: Gets the unique node ID for the test.get_marker: Checks if the test has a specific marker.set_nodeid: Updates the test's node ID.
This interface acts as a contract, ensuring that any class that implements it will provide these methods. This is a critical part of the hexagonal architecture, as it allows us to swap out different implementations without affecting the core logic of our plugin.
2. Create Production Adapter
Next, we'll create a PytestItemAdapter. This adapter will wrap a real pytest.Item object and implement the TestItemPort interface. It acts as a bridge between our plugin logic and pytest, allowing us to interact with pytest items through the abstraction we've created. This adapter is the key to using our abstraction in a real-world pytest environment.
File: src/pytest_test_categories/adapters/__init__.py (new)
File: src/pytest_test_categories/adapters/pytest_adapter.py (new)
from pytest import Item
from pytest_test_categories.types import TestItemPort
class PytestItemAdapter(TestItemPort):
"""Production adapter wrapping pytest.Item.
This adapter allows the plugin to work with real pytest.Item objects
while keeping domain logic decoupled from pytest internals.
"""
def __init__(self, item: Item):
self._item = item
@property
def nodeid(self) -> str:
return self._item.nodeid
def get_marker(self, name: str) -> bool:
return bool(self._item.get_closest_marker(name))
def set_nodeid(self, nodeid: str) -> None:
self._item._nodeid = nodeid # noqa: SLF001
The PytestItemAdapter class implements the TestItemPort interface and wraps a pytest.Item object. It provides implementations for the nodeid, get_marker, and set_nodeid methods, delegating the calls to the underlying pytest.Item object. By using this adapter, our plugin logic can interact with pytest items without knowing the specifics of the pytest.Item class. This abstraction makes our code more flexible and easier to maintain. This is a game-changer for testing and maintainability.
3. Create Test Adapter
Finally, we'll create a FakeTestItem. This is a test double that implements the TestItemPort interface. It allows us to test our plugin logic in isolation, without needing to create real pytest.Item objects. This is a huge win for unit testing, as it makes our tests faster, more deterministic, and easier to write. The FakeTestItem is your best friend when it comes to unit testing.
File: tests/_fixtures/__init__.py (new)
File: tests/_fixtures/test_item.py (new)
from typing import Set, Optional
from pytest_test_categories.types import TestItemPort
class FakeTestItem(TestItemPort):
"""Test double for testing plugin logic without pytest infrastructure.
Enables fast, deterministic unit tests similar to FakeTimer pattern.
"""
def __init__(self, nodeid: str, markers: Optional[Set[str]] = None):
self._nodeid = nodeid
self._markers = markers or set()
@property
def nodeid(self) -> str:
return self._nodeid
def get_marker(self, name: str) -> bool:
return name in self._markers
def set_nodeid(self, nodeid: str) -> None:
self._nodeid = nodeid
The FakeTestItem class implements the TestItemPort interface and provides a simple, in-memory implementation. It allows us to configure the nodeid and markers for the test item, making it easy to simulate different scenarios in our tests. This is a powerful tool for ensuring that our plugin logic is working correctly.
Acceptance Criteria
To ensure that we've successfully implemented this solution, we have a set of acceptance criteria:
- [ ]
TestItemPortabstract base class created intypes.py - [ ]
PytestItemAdaptercreated insrc/pytest_test_categories/adapters/pytest_adapter.py - [ ]
FakeTestItemcreated intests/_fixtures/test_item.py - [ ] All three components have comprehensive docstrings
- [ ] Unit tests for
PytestItemAdapter(using real pytest.Item) - [ ] Unit tests for
FakeTestItem(demonstrating test double functionality) - [ ] 100% test coverage maintained
- [ ] All existing tests pass
- [ ] Pre-commit hooks pass
These criteria are essential for verifying that our solution meets the requirements and is of high quality. We want to make sure we deliver a solid piece of code.
Testing Strategy
Our testing strategy involves writing unit tests for both the PytestItemAdapter and the FakeTestItem. This will ensure that each component is working correctly and that our abstraction is functioning as expected.
Unit Tests for PytestItemAdapter
We'll write tests to ensure that the PytestItemAdapter correctly wraps the pytest.Item and delegates calls to the underlying object. Here are a couple of examples:
def test_pytest_item_adapter_wraps_nodeid():
"""It wraps pytest.Item nodeid property."""
item = # create real pytest.Item
adapter = PytestItemAdapter(item)
assert adapter.nodeid == item.nodeid
def test_pytest_item_adapter_gets_marker():
"""It checks for markers on wrapped item."""
# Test with real pytest.Item that has markers
These tests will help us verify that the PytestItemAdapter is correctly bridging the gap between our plugin logic and pytest.
Unit Tests for FakeTestItem
We'll also write tests for the FakeTestItem to ensure that it behaves as expected. Here are a couple of examples:
def test_fake_test_item_returns_configured_nodeid():
"""It returns the configured node ID."""
fake = FakeTestItem("test_example.py::test_func")
assert fake.nodeid == "test_example.py::test_func"
def test_fake_test_item_has_configured_markers():
"""It reports configured markers as present."""
fake = FakeTestItem("test.py::test", markers={"small", "unit"})
assert fake.get_marker("small") is True
assert fake.get_marker("large") is False
These tests will help us verify that the FakeTestItem is a reliable test double that we can use in our unit tests.
Documentation Updates
To ensure that our team is aware of these changes, we'll update the CLAUDE.md document with the new architecture patterns. We'll also add docstrings referencing the TestTimer pattern as a precedent for this approach. Keeping the documentation up-to-date is crucial for team collaboration and knowledge sharing.
Related Issues
This work is part of Epic #34: Hexagonal Architecture Refactoring. It's a foundational issue that blocks other issues, such as #36 and #37 (OutputWriterPort, TestDiscoveryService). This means that completing this task will unlock further progress on our project. It is important to see how everything fits together.
Labels
We've labeled this issue as phase-1, epic:hexagonal-architecture, refactoring, and enhancement. These labels help us to categorize and track the issue, making it easier to manage our workload. Proper labeling is key to efficient project management.
Conclusion
So, there you have it! We've laid out a comprehensive plan for creating a TestItemPort and its adapters for pytest.Item abstraction. This is a significant step towards decoupling our code, making it more testable, and setting us up for future success. By following this plan, we'll be able to write cleaner, more maintainable code and ensure that our plugin is robust and reliable. Let's get to work and make it happen! Remember, quality code is a team effort. If you have any questions or insights, feel free to share. Let's build something awesome together!