Negative Testing
Testing what the system does when things go wrong — invalid inputs, failed dependencies, boundary violations.
Testing what the system does when things go wrong. Invalid inputs, failed dependencies, boundary violations.
Positive vs Negative Testing
Positive testing: verify the system works correctly with valid input
Negative testing: verify the system handles invalid/unexpected input gracefully
Both are required. Positive tests alone are insufficient because:
- Users don't always follow instructions
- Attackers deliberately send invalid data
- External dependencies fail unpredictably
- Edge cases are where most bugs hide
Negative test goal: the system should always respond predictably and safely —
never crash, never leak data, never expose internals, never silently corrupt state.
Categories of Negative Tests
1. Invalid input format
Expected: "name" is a string
Negative: send integer, null, array, missing field, empty string
2. Boundary violations
Expected: quantity 1-100
Negative: 0, -1, 101, 99999, MAX_INT, 0.5, "100"
3. Business rule violations
Expected: user can only cancel their own orders
Negative: cancel another user's order, cancel an already-shipped order
4. State violations
Expected: submit in "draft" state
Negative: submit an already-submitted form, modify a deleted resource
5. Resource not found
Expected: GET /orders/:id returns the order
Negative: non-existent ID, deleted ID, another user's ID
6. Dependency failures
Expected: payment service responds successfully
Negative: payment service times out, returns 500, returns malformed JSON
7. Concurrent access
Expected: user places one order
Negative: two simultaneous checkout requests for the same cart
Negative Test Design Patterns
import pytest
from httpx import AsyncClient
# Pattern 1: Parametrize all invalid inputs together
INVALID_QUANTITIES = [
pytest.param(0, id="zero"),
pytest.param(-1, id="negative"),
pytest.param(101, id="over-max"),
pytest.param(999999, id="extreme"),
pytest.param(0.5, id="fractional"),
pytest.param("ten", id="string"),
pytest.param(None, id="null"),
pytest.param([], id="array"),
]
@pytest.mark.parametrize("quantity", INVALID_QUANTITIES)
async def test_invalid_quantity_rejected(client: AsyncClient, quantity) -> None:
response = await client.post("/orders", json={
"product_id": "prod_123",
"quantity": quantity,
})
assert response.status_code == 422 # validation error, not 500
# Pattern 2: Missing required fields
REQUIRED_FIELDS = ["product_id", "quantity", "user_id"]
@pytest.mark.parametrize("missing_field", REQUIRED_FIELDS)
async def test_missing_field_rejected(client: AsyncClient, missing_field: str) -> None:
complete_payload = {"product_id": "prod_123", "quantity": 1, "user_id": "user_456"}
payload = {k: v for k, v in complete_payload.items() if k != missing_field}
response = await client.post("/orders", json=payload)
assert response.status_code == 422
errors = response.json()["detail"]
assert any(missing_field in str(e) for e in errors)Error Response Quality
# Good error response: structured, informative, no internals exposed
{
"status": 422,
"title": "Validation Error",
"errors": [
{
"field": "quantity",
"message": "Must be between 1 and 100",
"received": 0
}
]
}
# Bad error response: leaks internals
{
"detail": "IntegrityError: (psycopg2.errors.NotNullViolation) null value in column \"quantity\"",
"traceback": "..."
}
# Tests for error quality (not just status code)
async def test_error_response_does_not_leak_stack_trace(client: AsyncClient) -> None:
response = await client.post("/orders", json={"quantity": None})
body = response.text
assert "traceback" not in body.lower()
assert "psycopg2" not in body
assert "sqlalchemy" not in body
assert "line " not in body # "line X in file Y.py"
async def test_error_response_identifies_field(client: AsyncClient) -> None:
response = await client.post("/orders", json={"quantity": -1})
assert response.status_code == 422
body = response.json()
assert any("quantity" in str(e) for e in body["detail"])Dependency Failure Testing
import respx
import httpx
import pytest
@pytest.fixture
def mock_payment_service():
with respx.mock(base_url="https://payments.example.com") as respx_mock:
yield respx_mock
async def test_payment_service_timeout_handled(
client: AsyncClient, mock_payment_service
) -> None:
mock_payment_service.post("/charge").mock(side_effect=httpx.TimeoutException)
response = await client.post("/orders/complete", json=valid_order_data)
# System should degrade gracefully, not 500
assert response.status_code in (408, 503)
assert "payment" in response.json().get("message", "").lower()
async def test_payment_service_500_handled(
client: AsyncClient, mock_payment_service
) -> None:
mock_payment_service.post("/charge").mock(
return_value=httpx.Response(500, json={"error": "internal"})
)
response = await client.post("/orders/complete", json=valid_order_data)
assert response.status_code == 502 # Bad Gateway (upstream error)
async def test_payment_service_malformed_response(
client: AsyncClient, mock_payment_service
) -> None:
mock_payment_service.post("/charge").mock(
return_value=httpx.Response(200, content=b"not json{}")
)
response = await client.post("/orders/complete", json=valid_order_data)
assert response.status_code in (500, 502) # must not crash with unhandled exceptionState Transition Negatives
# Test invalid state transitions explicitly
ORDER_STATES = ["pending", "confirmed", "shipped", "delivered", "cancelled"]
INVALID_TRANSITIONS = [
("shipped", "pending"), # can't go backwards
("delivered", "shipped"), # can't go backwards
("cancelled", "confirmed"), # can't un-cancel
("delivered", "cancelled"), # can't cancel delivered order
]
@pytest.mark.parametrize("current_state,target_state", INVALID_TRANSITIONS)
async def test_invalid_state_transition_rejected(
client: AsyncClient, make_order, current_state: str, target_state: str
) -> None:
order = make_order(status=current_state)
response = await client.patch(f"/orders/{order['id']}", json={"status": target_state})
assert response.status_code == 409 # Conflict — invalid transition
assert "cannot" in response.json()["message"].lower()Concurrency Negatives
import asyncio
async def test_concurrent_stock_decrement_consistent(
client: AsyncClient, product_with_stock_1
) -> None:
"""Only one of two concurrent purchases should succeed when stock = 1."""
product_id = product_with_stock_1["id"]
results = await asyncio.gather(
client.post("/orders", json={"product_id": product_id, "quantity": 1}),
client.post("/orders", json={"product_id": product_id, "quantity": 1}),
return_exceptions=True,
)
status_codes = [r.status_code for r in results if not isinstance(r, Exception)]
# Exactly one should succeed, one should fail (409 Conflict or 422)
assert status_codes.count(201) == 1
assert status_codes.count(409) == 1 or status_codes.count(422) == 1
# Stock should now be 0
stock_check = await client.get(f"/products/{product_id}/stock")
assert stock_check.json()["quantity"] == 0Common Failure Cases
Only happy-path tests exist; negative inputs return 500 instead of 422
Why: developers validate inputs in the UI layer but not the API layer; sending quantity: null directly to the API bypasses client validation and hits an unhandled None dereference.
Detect: test_invalid_quantity_rejected asserts status_code == 422 but gets 500.
Fix: add Pydantic (or equivalent) schema validation at the API boundary so invalid inputs are rejected before reaching business logic; the parametrised negative test suite confirms each invalid variant.
Error responses leak internal implementation details
Why: uncaught ORM exceptions propagate to the default exception handler, which serialises the full traceback including table names and column types.
Detect: test_error_response_does_not_leak_stack_trace finds "psycopg2" or "sqlalchemy" in the response body.
Fix: add a top-level exception handler that catches all unhandled exceptions, logs the full traceback internally, and returns a generic {"status": 500, "title": "Internal Server Error"} with no stack trace.
Dependency failure tests use real endpoints — tests become integration tests
Why: mock_payment_service fixture is misconfigured or bypassed by a respx.mock scope error, causing the test to call the actual payments service.
Detect: the timeout test passes inconsistently depending on whether the payment sandbox is up; it runs for 30s instead of the expected near-instant mock response.
Fix: assert inside the test that respx_mock.calls.call_count == 1 after the request; a zero count means the mock was not activated and the real service was called.
Concurrent negative tests produce flaky results under low parallelism
Why: asyncio.gather fires both requests but the database uses serialisable isolation without a proper unique constraint; one request may succeed twice if the constraint is missing.
Detect: test_concurrent_stock_decrement_consistent occasionally returns status_codes.count(201) == 2.
Fix: add a database-level unique constraint or advisory lock on the stock row; confirm the test reliably returns exactly one 201 and one 409 across 20 repeated runs.
Connections
qa-hub · qa/test-case-design · qa/exploratory-testing · qa/security-testing-qa · qa/defect-prevention · technical-qa/load-testing-advanced
Open Questions
- What testing scenarios does this technique systematically miss?
- How does this approach need to change when delivery cadence moves to continuous deployment?
Related reading