---
description:
globs: tests/integration/*
alwaysApply: false
---
# Integration Testing Guidelines
This document provides detailed guidelines for writing effective integration tests that verify our interaction with live external APIs. Unlike unit tests which focus on logic with mocked data, integration tests ensure the "contract" between our tools and external services remains valid.
## Categories of Integration Tests
### Category 1: Helper-Level Integration Tests (Connectivity & Basic Contract)
These tests target the low-level helper functions in `tools/common.py` (e.g., `make_blockscout_request`, `get_blockscout_base_url`).
- **Purpose:** To verify basic network connectivity and ensure the fundamental HTTP request/response cycle with each external service is working.
- **Location:** `tests/integration/test_common_helpers.py`.
- **What to Assert:**
- The request was successful (no HTTP errors).
- The top-level structure of the response is as expected (e.g., `isinstance(response, list)`).
- Presence of a few key, stable fields to confirm we're hitting the right endpoint.
**Example:**
```python
import pytest
from blockscout_mcp_server.tools.common import make_blockscout_request, get_blockscout_base_url
@pytest.mark.integration
async def test_make_blockscout_request_connectivity():
"""Test basic connectivity to Blockscout API."""
base_url = await get_blockscout_base_url("1") # Ethereum mainnet
response = await make_blockscout_request(
base_url=base_url,
api_path="/api/v2/blocks",
params={"limit": 1}
)
# Assert basic structure
assert isinstance(response, dict)
assert "items" in response
assert len(response["items"]) > 0
# Assert key fields are present
block = response["items"][0]
assert "hash" in block
assert "number" in block
```
### Category 2: Tool-Level Integration Tests (Data Extraction & Schema Validation)
These tests target the high-level MCP tool functions themselves (e.g., `get_latest_block`, `get_tokens_by_address`).
- **Purpose:** To validate the "contract" between our tool's data processing logic and the live API's response schema. They ensure that the fields our tools *extract and transform* are still present and correctly structured in the live data. This protects against breaking changes in the API schema.
- **Location:** Domain-specific folders under `tests/integration/` with modules named `test_<tool_name>_real.py` (for example, `tests/integration/block/test_get_latest_block_real.py`). Each MCP tool must have a dedicated module that contains all integration scenarios for that tool. Do not combine multiple tools in a single module.
- **What to Call:** The actual MCP tool function (e.g., `await get_latest_block(chain_id="1", ctx=mock_ctx)`).
- **What to Assert:**
- Focus on the **final, processed result** returned by the tool.
- Verify that the extracted data has the correct type and format (e.g., `assert isinstance(result["block_number"], int)`).
- For lists, check that items in the list contain the expected processed fields (e.g., `assert "address" in item`).
- For tools with string formatting (like pagination hints), assert that the key substrings are present in the final output.
- **What to Avoid:** Do not re-test complex formatting logic already covered by unit tests. The focus is on verifying the *data extraction* was successful.
**Example:**
```python
import pytest
from blockscout_mcp_server.tools.block.get_latest_block import get_latest_block
@pytest.mark.integration
async def test_get_latest_block_data_extraction(mock_ctx):
"""Test that get_latest_block extracts expected fields from live API."""
result = await get_latest_block(chain_id="1", ctx=mock_ctx)
# Assert the tool returns a ToolResponse structure
assert hasattr(result, 'data')
assert "block_number" in result.data
assert "hash" in result.data
assert "timestamp" in result.data
# Assert data types are correct
assert isinstance(result.data["block_number"], int)
assert isinstance(result.data["hash"], str)
assert result.data["hash"].startswith("0x")
# Assert reasonable data ranges
assert result.data["block_number"] > 0
assert len(result.data["hash"]) == 66 # 0x + 64 hex chars
```
- **What to Avoid:** Do not re-test complex formatting logic already covered by unit tests. The focus is on verifying the *data extraction* was successful.
#### Handling Paginated Data
For tools that return paginated data, integration tests must be robust enough to find specific data patterns that may not be on the first page. Instead of assuming data is in the initial response, tests should loop through pages.
**Best Practice:**
- Use a `while` or `for` loop with a `max_pages_to_check` limit to prevent infinite loops.
- In each iteration, call the tool with the current `cursor`.
- Inspect the response for the target data. If found, break the loop.
- If not found, parse the new cursor from the response string and continue to the next page.
- If the data is not found after checking the maximum number of pages, the test should be skipped with a clear message.
**Example:**
```python
import pytest
@pytest.mark.integration
async def test_tool_with_pagination_search(mock_ctx):
"""Test that we can find specific data by searching across pages."""
MAX_PAGES_TO_CHECK = 5
cursor = None
found_item = None
for _ in range(MAX_PAGES_TO_CHECK):
# Call the paginated tool
result = await some_paginated_tool(chain_id="1", cursor=cursor, ctx=mock_ctx)
# Logic to search for the specific item in the ToolResponse data
for item in result.data:
# if item_matches_criteria:
# found_item = item
# break
pass
if found_item:
break
# Extract next cursor from structured pagination if more pages exist
if result.pagination:
cursor = result.pagination.next_call.params["cursor"]
else:
break
if not found_item:
pytest.skip(f"Could not find target item within {MAX_PAGES_TO_CHECK} pages.")
# Assertions on the found_item
assert found_item["some_field"] == "expected_value"
```
**Testing Pagination and Cursors:**
For tools that support cursor-based pagination, a specific two-step test is required to validate the full lifecycle of the feature.
- **Purpose:** To verify that a cursor generated from a first call can be successfully used in a second call to retrieve the next page of data.
- **Process:**
1. Make an initial call to the tool without a cursor.
2. Extract the cursor string from the response's pagination hint.
3. Make a second call to the same tool, passing the extracted cursor.
4. Assert that the second call succeeds and that its data is different from the first call's data.
**Example:**
```python
import pytest
from blockscout_mcp_server.tools.address_tools import get_tokens_by_address
@pytest.mark.integration
async def test_get_tokens_by_address_pagination_integration(mock_ctx):
"""Tests that get_tokens_by_address can use a cursor to fetch a second page."""
# ARRANGE: Use a stable address known to have many results.
address = "0x47ac0fb4f2d84898e4d9e7b4dab3c24507a6d503" # Binance Wallet
chain_id = "1"
# ACT 1: Get the first page.
first_page_response = await get_tokens_by_address(chain_id=chain_id, address=address, ctx=mock_ctx)
# ASSERT 1: Check for and extract the next call information.
assert first_page_response.pagination is not None, "Pagination info is missing."
next_call_info = first_page_response.pagination.next_call
# ACT 2: Use the structured parameters to get the next page.
second_page_response = await get_tokens_by_address(**next_call_info.params, ctx=mock_ctx)
# ASSERT 2: Verify the second page.
assert len(second_page_response.data) > 0
assert first_page_response.data[0] != second_page_response.data[0]
```
## Testing the `direct_api_call` Dispatcher
To test that the dispatcher correctly routes to a handler:
- **Location**: `tests/integration/direct_api/test_<handler_name>_real.py`.
- **Method**: Call the main `direct_api_call` tool with an `endpoint_path` that should trigger the handler.
- **Assertion**: Assert that the returned `ToolResponse` contains the specific data model processed by the handler (e.g., `isinstance(result.data, list)` and `isinstance(result.data[0], AddressLogItem)`), not the generic `DirectApiData`. This verifies the end-to-end dispatch flow.
## General Rules for All Integration Tests
### Use Stable Targets
Always test against non-volatile data points to ensure tests are reliable across different runs:
- **Historical blocks:** Use well-known blocks (e.g., Genesis block, specific milestone blocks)
- **Famous addresses:** Use well-known contract addresses (e.g., USDC, prominent DeFi protocols)
- **ENS names:** Use established ENS names that are unlikely to change
**Examples:**
```python
# Good: Historical data that won't change
GENESIS_BLOCK_HASH = "0xd4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3"
USDC_CONTRACT = "0xa0b86a33e6ac4454df1b3b7df6b2e55e5ef2a74f"
VITALIK_ENS = "vitalik.eth"
# Bad: Latest/current data that changes frequently
await get_latest_block(...) # Block data changes every ~12 seconds
```
### Integration Test Markers
Every integration test function **must** be decorated with `@pytest.mark.integration`:
```python
@pytest.mark.integration
async def test_some_integration_scenario():
# Test implementation
pass
```
This allows running integration tests separately:
```bash
# Run only integration tests
pytest -m integration
# Run everything except integration tests
pytest -m "not integration"
```
**Important:** Always use the `-v` (verbose) flag when running integration tests to see the reason for any skipped tests:
```bash
pytest -m integration -v
```
This verbose output will show you why specific tests were skipped (e.g., network connectivity issues, missing API keys, or external service unavailability), which is crucial for understanding the test results and debugging integration issues.
### Error Handling
Integration tests should be resilient to temporary network issues:
```python
import pytest
import httpx
@pytest.mark.integration
async def test_with_retry_logic():
"""Integration test with basic retry for network issues."""
max_retries = 3
for attempt in range(max_retries):
try:
result = await some_tool_function(...)
break # Success, exit retry loop
except (httpx.TimeoutException, httpx.ConnectError) as e:
if attempt == max_retries - 1:
pytest.skip(f"Network connectivity issue after {max_retries} attempts: {e}")
continue
# Proceed with assertions
assert result is not None
```
### Avoid Hardcoding Environment-Specific URLs
When asserting on output that contains a URL resolved by the server, **DO NOT** hardcode the URL in your test. The server dynamically resolves URLs (e.g., using `get_blockscout_base_url`), and these resolved values can change.
Instead, the test itself should call the same helper to get the expected URL and use that variable in the assertion. This makes the test resilient to changes in the underlying service infrastructure.
**Incorrect (Brittle):**
```python
@pytest.mark.integration
async def test_tool_with_hardcoded_url(mock_ctx):
result = await some_tool_that_generates_a_url(chain_id="1", ...)
# This will break if the resolved URL for chain 1 changes.
assert "https://eth.blockscout.com/some/path" in result
```
**Correct (Robust):**
```python
from blockscout_mcp_server.tools.common import get_blockscout_base_url
@pytest.mark.integration
async def test_tool_with_dynamic_url(mock_ctx):
base_url = await get_blockscout_base_url("1")
result = await some_tool_that_generates_a_url(chain_id="1", ...)
# This test is resilient to changes in the resolved URL.
assert f"{base_url}/some/path" in result
```
## File Size Limitations
**Integration test files must not exceed 500 LOC.** If a file approaches this limit, split tests into multiple focused modules such as `test_get_transaction_logs_real_part1.py` and `test_get_transaction_logs_real_part2.py` to maintain readability and logical organization.
## Test Organization
- Group related tests using descriptive class names or clear function naming patterns.
- Use descriptive test names that indicate what contract/schema is being validated
- Separate helper-level tests from tool-level tests into different files when appropriate
- Include clear comments explaining why specific test data was chosen (especially for stable targets)