Module 4: Negative & validation testing
Modules 1–3 tested the happy path — valid input, expected success. But the highest-value
testing is often the opposite: send bad input and confirm the API rejects it correctly.
A robust API answers bad input with the right error status and a helpful message — not a
crash, not a silent success. In this module you’ll deliberately trigger 400, 401,
404, and 409, assert the API fails the right way, and — when it doesn’t — document
the bug with xfail.
Keep TestMarket Lab running at http://localhost:3000 and your .venv active.
🎬 Video coming soon
Module 4: negative testing at a glance — slide guide Slides — opens in a new tabWhy negative testing matters
Section titled “Why negative testing matters”Happy-path tests prove the feature works when everything is right. Negative tests prove it fails safely when something is wrong — and that’s where real bugs hide. A user will eventually submit an empty form, a wrong password, or a duplicate email; your tests should pin down exactly what the API does when they do.
Two things make a good negative test:
- The right status code —
400for bad input,401for bad credentials,404for a missing resource,409for a conflict. The wrong code (or a500crash) is itself a bug. - A useful error body — a message that tells the caller what went wrong.
Remember: negative testing asserts the system fails correctly — the right 4xx status and
a helpful message. Untested error paths are where bugs live.
400 Bad Request — missing or invalid input
Section titled “400 Bad Request — missing or invalid input”400 means “your request was malformed” — a required field is missing or a value is invalid.
TestMarket Lab validates several endpoints:
import requests
BASE_URL = "http://localhost:3000"
def test_login_missing_password(): assert response.status_code == 400
def test_create_product_missing_price(): response = requests.post(f"{BASE_URL}/api/products", json={"name": "No Price"}) assert response.status_code == 400
def test_register_password_too_short(): response = requests.post( f"{BASE_URL}/api/auth/register", ) assert response.status_code == 400Each of these would-be writes is rejected, so nothing is added — the tests are safe to re-run without resetting.
Remember: 400 = malformed request (missing field, invalid value). It’s the API’s first
line of defense; assert it wherever a field is required or constrained.
401 Unauthorized — bad credentials
Section titled “401 Unauthorized — bad credentials”401 means “I don’t know who you are” — authentication failed. A wrong password and an
unknown email both return 401 (deliberately the same response, so an attacker can’t tell
which emails exist):
import requests
BASE_URL = "http://localhost:3000"
def test_login_wrong_password(): response = requests.post( f"{BASE_URL}/api/auth/login", ) assert response.status_code == 401
def test_login_unknown_email(): response = requests.post( f"{BASE_URL}/api/auth/login", ) assert response.status_code == 401Remember: 401 = authentication failed (bad or missing credentials). Note the security
touch: wrong-password and unknown-email give the identical 401, so the API never leaks which
emails are registered.
404 Not Found — missing resource
Section titled “404 Not Found — missing resource”404 means the thing you asked for doesn’t exist. It applies to reads and writes against a
missing id:
import requests
BASE_URL = "http://localhost:3000"
MISSING_ID = 99999
def test_get_missing_product(): assert requests.get(f"{BASE_URL}/api/products/{MISSING_ID}").status_code == 404
def test_update_missing_product(): response = requests.put(f"{BASE_URL}/api/products/{MISSING_ID}", json={"price": 9.99}) assert response.status_code == 404
def test_delete_missing_product(): assert requests.delete(f"{BASE_URL}/api/products/{MISSING_ID}").status_code == 404Remember: 404 = the resource (by id or URL) doesn’t exist — for GET, PUT, and
DELETE alike. Use an id you know is absent (a huge number like 99999).
409 Conflict — duplicate
Section titled “409 Conflict — duplicate”409 means “this conflicts with what already exists.” Registering an email that’s already
taken is the classic case:
import requests
BASE_URL = "http://localhost:3000"
def test_register_duplicate_email(): # [email protected] is a seeded account, so registering it again conflicts response = requests.post( f"{BASE_URL}/api/auth/register", ) assert response.status_code == 409Remember: 409 = a conflict with existing state (a duplicate). It’s distinct from 400:
the request is well-formed, it just clashes with a resource that already exists.
Assert the error, not just the status
Section titled “Assert the error, not just the status”The status code is the headline; the body is the detail. Assert both — but assert the message loosely (a key substring), so a harmless wording tweak doesn’t break your test:
import requests
BASE_URL = "http://localhost:3000"
def test_error_body_is_helpful(): response = requests.post(f"{BASE_URL}/api/auth/login", json={}) assert response.status_code == 400
body = response.json() assert "error" in body # the API returns an error field assert "required" in body["error"].lower() # …and it says what's wrongRemember: assert the status precisely and the message loosely — check for a key
word ("required", "password") rather than the exact string, so wording changes don’t cause
false failures.
Putting it together: a validation matrix
Section titled “Putting it together: a validation matrix”Negative cases are a natural fit for parametrize (Module 2) — one test, a table of
bad-input rows, each with its expected status. This “validation matrix” is the shape of most
real negative-test suites:
import pytestimport requests
BASE_URL = "http://localhost:3000"
@pytest.mark.parametrize( "payload, expected_status", [ ({}, 400), # missing everything ], ids=["valid", "bad-password", "unknown-email", "missing-password", "empty"],)def test_login_validation(payload, expected_status): response = requests.post(f"{BASE_URL}/api/auth/login", json=payload) assert response.status_code == expected_statusKeep one valid row as a control — it proves the endpoint works, so a failing negative row is really about the input, not a broken endpoint.
Remember: a validation matrix (parametrize of (bad_input, expected_status)) covers many
failure modes in one readable test. Always include a valid control row.
When the API fails wrong: document a bug with xfail
Section titled “When the API fails wrong: document a bug with xfail”Sometimes negative testing finds a real defect — the API doesn’t fail cleanly. TestMarket Lab
has one: posting a product whose name duplicates an existing one returns 500 (a server
crash) instead of a proper 409/400. That’s exactly the kind of bug negative testing exists
to surface.
Don’t delete the finding — encode it with xfail (Module 2), asserting the behavior you
want, and mark it strict=True. It stays green as a known issue today; the day the bug
is fixed the test XPASSes and fails the run — forcing you to delete the now-stale marker
(a plain, non-strict xfail would let the fix slip by as a silent summary line):
import pytest
@pytest.mark.xfail(reason="BUG: duplicate product name returns 500 instead of 409", strict=True)def test_duplicate_product_name_should_conflict(reset_db, base_url, api): payload = {"name": "Repeat Item", "price": 9.99, "category": "electronics"} api.post(f"{base_url}/api/products", json=payload) # arrange: this name now exists
duplicate = api.post(f"{base_url}/api/products", json=payload) assert duplicate.status_code == 409 # what SHOULD happen (today it's 500)This test writes (it adds a product), so it takes reset_db from your Module 2
conftest.py to stay re-runnable. One caveat: xfail marks the whole test, so a failure
anywhere in it counts as the expected failure — keep bug tests small and focused on the one
defect, or a big one could quietly absorb an unrelated regression.
Remember: capture a known bug as a strict=True xfail test that asserts the
correct behavior with a reason. It documents the defect, keeps the suite green today, and
fails loudly the moment the fix lands — far better than a comment or a deleted test.
Exercises
Section titled “Exercises”Work in your python-sdet project with TestMarket Lab running. Run with pytest -v.
Exercise 1 — Missing-field 400s (10 min)
Section titled “Exercise 1 — Missing-field 400s (10 min)”Write tests that assert 400 for: POST /api/auth/login with no body {}, POST /api/products
with only a name, and POST /api/auth/register with a missing name. Confirm each returns
400.
Exercise 2 — The 401 pair (5 min)
Section titled “Exercise 2 — The 401 pair (5 min)”Assert that a wrong password and an unknown email both return 401 from
POST /api/auth/login — and note they return the same status on purpose.
Exercise 3 — 404 across verbs (10 min)
Section titled “Exercise 3 — 404 across verbs (10 min)”For a product id you know doesn’t exist, assert 404 from GET, PUT, and DELETE on
/api/products/<id>.
Exercise 4 — Assert the message (5 min)
Section titled “Exercise 4 — Assert the message (5 min)”POST /api/auth/register with a 3-character password. Assert 400, and that the error body’s
message mentions "password" (case-insensitive, substring — not the exact string).
Exercise 5 — A register validation matrix (15 min)
Section titled “Exercise 5 — A register validation matrix (15 min)”Parametrize POST /api/auth/register over rows of (payload, expected_status): a valid new
signup (201), a duplicate email (409), a short password (400), and a missing field
(400). Give each row a readable id. (Tip: a valid signup writes — take reset_db so
re-runs start clean.)
Bonus — Find and xfail a bug
Section titled “Bonus — Find and xfail a bug”Reproduce the duplicate-product-name 500 yourself, then write the xfail test that asserts
the correct 409. Run pytest -rx to see the “expected failure” reason in the summary.
In Module 5 we go deep on test data setup & teardown — using POST /api/reset and
arrange-via-API (POST /api/products / POST /api/orders) so every test controls its own
starting state instead of depending on the seed.