ЁЯПл The SchoolтА║ЁЯУЭ TestingтА║ЁЯдЭ рдзрдбрд╛ 07 тАФ Contract tests: consumer рдЪреНрдпрд╛ рдЕрдкреЗрдХреНрд╖рд╛ рд╡рд┐рд░реБрджреНрдз provider рдЪреЗ response
ЁЯЦ╝я╕П See the drawing + lab ЁЯПа Course home ЁЯМ┐ Branch on GitHub тЬПя╕П View source
ЁЯЦ╝я╕П рдЖрдХреГрддреА рдЖрдгрд┐ labThe drawing + lab рдкреВрд░реНрдг рдкрд╛рдирд╛рд╡рд░ рдЙрдШрдбрд╛ тЖЧOpen full page тЖЧ

ЁЯдЭ рдзрдбрд╛ 07 тАФ Contract tests: consumer рдЪреНрдпрд╛ рдЕрдкреЗрдХреНрд╖рд╛ рд╡рд┐рд░реБрджреНрдз provider рдЪреЗ response

ЁЯУН рддреБрдореНрд╣реА рдЗрдереЗ рдЖрд╣рд╛рдд: 12 рдкреИрдХреА рдзрдбрд╛ 07 ┬╖ рдорд╛рдЧреЗ: lesson-06-integration ┬╖ рдкреБрдвреЗ: lesson-08-e2e-pyramid


ЁЯУж рдпрд╛ рдмреНрд░рдБрдЪрдордзреНрдпреЗ рдХрд╛рдп рдЖрд╣реЗ

рдзрдбреЗ 01тАУ06, рдЖрдгрд┐ contract tests: report-card code (consumer) attendance service (provider) рдХрдбреВрди рддреНрдпрд╛рд▓рд╛ рдиреЗрдордХреЗ рдХрд╛рдп рд▓рд╛рдЧрддреЗ рддреЗ рд▓рд┐рд╣реВрди рдареЗрд╡рддреЛ, рдЖрдгрд┐ provider рдкреНрд░рддреНрдпреЗрдХ release ship рд╣реЛрдгреНрдпрд╛рдкреВрд░реНрд╡реА рддреНрдпрд╛ рдпрд╛рджреАрд╢реА рддрдкрд╛рд╕рддреЛ. exam/lab.py рдордзрд▓реЗ CONTRACT рдЖрдгрд┐ verify_contract(); exam/demo.py рдордзрд▓реЗ contract().

ЁЯзТ 5 рд╡рд░реНрд╖рд╛рдВрдЪреНрдпрд╛ рдореБрд▓рд╛рд▓рд╛ рд╕рдордЬрд╛рд╡рд▓реНрдпрд╛рд╕рд╛рд░рдЦреЗ

рдХрддрд░рд┐рдирд╛рдЪреНрдпрд╛ report cards рдирд╛ attendance office рдХрдбреВрди рджреЛрди рдЖрдХрдбреЗ рд▓рд╛рдЧрддрд╛рдд: рдЙрдкрд╕реНрдерд┐рдд рджрд┐рд╡рд╕ рдЖрдгрд┐ рдПрдХреВрдг рджрд┐рд╡рд╕. рддреА рддреЗ рдПрдХрд╛ рдХрд╛рд░реНрдбрд╡рд░ рд▓рд┐рд╣рд┐рддреЗ рдЖрдгрд┐ attendance office рдЪреНрдпрд╛ рднрд┐рдВрддреАрд╡рд░ рд▓рд╛рд╡рддреЗ: ЁЯдЭ

"рдореА рд╡рд┐рджреНрдпрд╛рд░реНрдерд┐рдиреА 42 рдмрджреНрджрд▓ рд╡рд┐рдЪрд╛рд░рддреЗ рддреЗрд╡реНрд╣рд╛ рдорд▓рд╛ рдкрд░рдд рд╣рд╡реЗ: days_present (рдкреВрд░реНрдг рд╕рдВрдЦреНрдпрд╛) рдЖрдгрд┐ days_total (рдкреВрд░реНрдг рд╕рдВрдЦреНрдпрд╛)."

рддреЗ рдХрд╛рд░реНрдб рдореНрд╣рдгрдЬреЗ contract.

Attendance office рдЖрдкрд▓рд╛ form рдмрджрд▓рдгреНрдпрд╛рдкреВрд░реНрд╡реА, рддреЛ form рдХрддрд░рд┐рдирд╛рдЪреНрдпрд╛ рдХрд╛рд░реНрдбрд╢реА рддрдкрд╛рд╕рддреЗ.

рджреЛрдиреНрд╣реА offices рдПрдХрддреНрд░ рдЪрд╛рд▓рд╡рдгреНрдпрд╛рдЪреА рдХреЛрдгрд╛рд▓рд╛рдЪ рдЧрд░рдЬ рдкрдбрд▓реА рдирд╛рд╣реА. рдкреНрд░рддреНрдпреЗрдХ рдмрд╛рдЬреВрдиреЗ рд╕реНрд╡рддрдГрд▓рд╛ рддреНрдпрд╛рдЪ рдХрд╛рд░реНрдбрд╢реА рддрдкрд╛рд╕рд▓реЗ.

ЁЯЧ║я╕П рдЖрдХреГрддреА

flowchart LR
    c["ЁЯУЗ consumer: report cards<br/>needs days_present:int, days_total:int"] -->|"writes the contract"| k["ЁЯдЭ contract<br/>GET /attendance/42 тЖТ 200"]
    k -->|"replayed in the provider's CI"| p["ЁЯПв provider: attendance"]
    p --> v1["v1 as today тЬЕ"]
    p --> v2["v2 adds late_days тЬЕ"]
    p --> v3["v3 renames to present тЭМ<br/>missing field days_present"]
    p --> v4["v4 days_total as text тЭМ<br/>str, expected int"]

ЁЯЧ║я╕П рдХрд╛рдврд▓реЗрд▓реА рдЖрд╡реГрддреНрддреА + рдПрдХ lab: https://school-edh.pages.dev/testing/lesson-diagrams.html#l07

тЭУ рдХрд╛рдп

ЁЯдФ рдХрд╛

рдХрд╛рд░рдг рдзрдбрд╛ 05 рдЪреЗ stubs рд╣реЗ рджреБрд╕рд▒реНрдпрд╛ team рдЪреНрдпрд╛ service рдмрджреНрджрд▓рдЪреЗ рджрд╛рд╡реЗ рдЖрд╣реЗрдд, рдЖрдгрд┐ рджреЛрдиреНрд╣реА services рдПрдХрддреНрд░ рдЪрд╛рд▓рд╡рдгрд╛рд▒реНрдпрд╛ end-to-end tests рд╣рд│реВ рдЖрдгрд┐ flaky рдЕрд╕рддрд╛рдд (рдзрдбрд╛ 08). Contract рддреНрдпрд╛ рджрд╛рд╡реНрдпрд╛рдЪреЗ рд░реВрдкрд╛рдВрддрд░ рджреЛрдиреНрд╣реА teams рдЬреНрдпрд╛рд╡рд░ test рдХрд░рддрд╛рдд рдЕрд╢рд╛ file рдордзреНрдпреЗ рдХрд░рддреЗ: consumer рдЪреНрдпрд╛ unit tests рддреА stub рдореНрд╣рдгреВрди рд╡рд╛рдкрд░рддрд╛рдд, рдЖрдгрд┐ provider рдЪрд╛ build рд╕рд┐рджреНрдз рдХрд░рддреЛ рдХреА рддреЛ рдЕрдЬреВрдирд╣реА рддрд╕реЗрдЪ рдЙрддреНрддрд░ рджреЗрддреЛ. рдореЛрдбрдгрд╛рд░рд╛ рдмрджрд▓ deployment рдЖрдзреА, provider рдЪреНрдпрд╛ pipeline рдордзреНрдпреЗ, рддреЛ рдмрджрд▓ рдХрд░рдгрд╛рд▒реНрдпрд╛ team рдХрдбреВрдирдЪ рдкрдХрдбрд▓рд╛ рдЬрд╛рддреЛ.

ЁЯФз рдХрд╕реЗ (рдпрд╛ repo рдордзреНрдпреЗ)

exam/lab.py рдордзрд▓реЗ CONTRACT рдПрдХ dict рдЖрд╣реЗ: consumer рдЖрдгрд┐ provider рдЪреА рдирд╛рд╡реЗ, request (GET /attendance/42) рдЖрдгрд┐ рдЕрдкреЗрдХреНрд╖рд┐рдд response (status 200, int type рдЪреЗ days_present рдЖрдгрд┐ days_total рдЕрд╕рд▓реЗрд▓реА body). verify_contract(contract, provider) request path рджреЗрдКрди provider рд▓рд╛ рдмреЛрд▓рд╡рддреЗ рдЖрдгрд┐ рдЕрдбрдЪрдгреАрдВрдЪреА рдпрд╛рджреА рдкрд░рдд рджреЗрддреЗ: рдЪреБрдХреАрдЪрд╛ status, рд╣рд░рд╡рд▓реЗрд▓реЗ field, рдХрд┐рдВрд╡рд╛ рдЪреБрдХреАрдЪреНрдпрд╛ type рдЪреЗ field. рд░рд┐рдХрд╛рдореА рдпрд╛рджреА рдореНрд╣рдгрдЬреЗ provider contract рдкрд╛рд│рддреЛ. рдЬрд╛рд╕реНрддреАрдЪреНрдпрд╛ fields рдХрдбреЗ рдореБрджреНрджрд╛рдо рджреБрд░реНрд▓рдХреНрд╖ рдХреЗрд▓реЗ рдЬрд╛рддреЗ.

ЁЯзк рдХрд░реВрди рдкрд╛рд╣рд╛

python3 exam/demo.py contract
python3 - <<'EOF'
from exam.lab import CONTRACT, verify_contract
from exam.grades import AttendanceClient
v5 = lambda p: (200, {"days_present": 172})
v6 = lambda p: (404, {"error": "moved"})
v7 = lambda p: (200, {"days_present": 172.0, "days_total": 200})
for name, prov in (("v5 drops days_total", v5), ("v6 moved the path", v6), ("v7 sends a float", v7)):
    print(f"{name:<20} тЖТ {verify_contract(CONTRACT, prov)}")
try: AttendanceClient(v5).attendance_percent("42")
except KeyError as e: print("without the contract, production sees: KeyError", e)
print("contract request:", CONTRACT["request"])
EOF

тЬЕ рддрдкрд╛рд╕рд╛ тАФ рддреБрдореНрд╣рд╛рд▓рд╛ рдХрд╛рдп рджрд┐рд╕рд╛рдпрд▓рд╛ рд╣рд╡реЗ

contract рдЫрд╛рдкрддреЗ:

   provider v1 as today                  тЖТ тЬЕ honours the contract
   provider v2 adds 'late_days'          тЖТ тЬЕ honours the contract
   provider v3 renames to 'present'      тЖТ тЭМ missing field 'days_present'
   provider v4 sends days_total as text  тЖТ тЭМ 'days_total' is str, expected int

рддреБрдордЪрд╛ snippet рдЫрд╛рдкрддреЛ:

v5 drops days_total  тЖТ ["missing field 'days_total'"]
v6 moved the path    тЖТ ['status 404, expected 200', "missing field 'days_present'", "missing field 'days_total'"]
v7 sends a float     тЖТ ["'days_present' is float, expected int"]
without the contract, production sees: KeyError 'days_total'
contract request: {'method': 'GET', 'path': '/attendance/42'}

ЁЯПБ рддреБрдореНрд╣реА рдЖрддреНрддрд╛рдЪ рдХрд╛рдп рд╕рд┐рджреНрдз рдХреЗрд▓реЗ

рдкреНрд░рддреНрдпреЗрдХ рдореЛрдбрдгрд╛рд▒реНрдпрд╛ рдмрджрд▓рд╛рдЪреЗ рдиреЗрдордХреЗ рдирд╛рд╡ рд╕рд╛рдВрдЧрд┐рддрд▓реЗ рдЧреЗрд▓реЗ тАФ рдХреЛрдгрддреЗ field, рдХреЛрдгрддрд╛ type тАФ consumer рдЕрдЬрд┐рдмрд╛рдд рди рдЪрд╛рд▓рд╡рддрд╛. Contract рдирд╕рддрд╛ рддрд░ v5 production рдкрд░реНрдпрдВрдд рдкреЛрд╣реЛрдЪрд▓рд╛ рдЕрд╕рддрд╛ рдЖрдгрд┐ report-card code рдордзреНрдпреЗ KeyError рдореНрд╣рдгреВрди fail рдЭрд╛рд▓рд╛ рдЕрд╕рддрд╛, рддреЛ рдХрд╛рд░рдгреАрднреВрдд рдмрджрд▓рд╛рдкрд╛рд╕реВрди рдЦреВрдк рджреВрд░. рдЖрдгрд┐ v7 рд╣рд╛ рдХрдбрдХрдкрдгрд╛рдЪрд╛ рдзрдбрд╛ рдЖрд╣реЗ: AttendanceClient 172.0 рдЕрдЧрджреА рд╡реНрдпрд╡рд╕реНрдерд┐рдд рд╣рд╛рддрд╛рд│реВ рд╢рдХрд▓рд╛ рдЕрд╕рддрд╛. рдЬрд░ рдПрдЦрд╛рджрд╛ contract consumer рд▓рд╛ рдкрд░реНрд╡рд╛ рдирд╕рд▓реЗрд▓реЗ рдмрджрд▓ рдирд╛рдХрд╛рд░рдд рдЕрд╕реЗрд▓, рддрд░ teams рддреНрдпрд╛рдХрдбреЗ рджреБрд░реНрд▓рдХреНрд╖ рдХрд░реВ рд▓рд╛рдЧрддрд╛рдд; рддреБрдореНрд╣рд╛рд▓рд╛ рд▓рд╛рдЧрддреЗ рддреЗрд╡рдвреЗрдЪ рдкрдХреНрдХреЗ рдХрд░рд╛, рдЬрд╛рд╕реНрдд рдирд╛рд╣реА.

тЪая╕П рдиреЗрд╣рдореАрдЪреНрдпрд╛ рдЪреБрдХрд╛

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд

рдЦрд▒реНрдпрд╛ project рд╡рд░ тАФ consumer рдЪреНрдпрд╛ tests рд▓рд┐рд╣рд┐рддрд╛рдд рддрд╢реА рдПрдХ pact file (Pact specification v3). Matching rules "рдиреЗрдордХреЗ 172" рдРрд╡рдЬреА "рдХреЛрдгрддрд╛рд╣реА integer" рд╕рд╛рдВрдЧрддрд╛рдд:

{
  "consumer": {"name": "report-cards"},
  "provider": {"name": "attendance"},
  "interactions": [{
    "description": "attendance for pupil 42",
    "providerStates": [{"name": "pupil 42 exists"}],
    "request": {"method": "GET", "path": "/attendance/42"},
    "response": {
      "status": 200,
      "body": {"days_present": 172, "days_total": 200},
      "matchingRules": {"body": {
        "$.days_present": {"matchers": [{"match": "integer"}]},
        "$.days_total":   {"matchers": [{"match": "integer"}]}
      }}
    }
  }],
  "metadata": {"pactSpecification": {"version": "3.0.0"}}
}

Pact рдХрдбреЗ Python, JavaScript, Java, Go, .NET рдЖрдгрд┐ рдЗрддрд░ рднрд╛рд╖рд╛рдВрд╕рд╛рдареА libraries рдЖрд╣реЗрдд. Pacts рд╕рд╣рд╕рд╛ Pact Broker (рдХрд┐рдВрд╡рд╛ PactFlow) рдордзреВрди share рдХреЗрд▓реЗ рдЬрд╛рддрд╛рдд, рдЬреЛ "рд╣реЗ version deploy рдХрд░рдгреЗ рд╕реБрд░рдХреНрд╖рд┐рдд рдЖрд╣реЗ рдХрд╛?" рдпрд╛рдЪреЗрд╣реА рдЙрддреНрддрд░ рджреЗрддреЛ:

pact-broker can-i-deploy --pacticipant attendance --version 2.3.0 --to-environment production

рдЗрддрд░ рдкрджреНрдзрддреА: Spring Cloud Contract (JVM), рдЖрдгрд┐ OpenAPI рд╡рд░ рдЖрдзрд╛рд░рд┐рдд checks рдЬрд╕реЗ Schemathesis рдХрд┐рдВрд╡рд╛ Dredd, рдЬрд┐рдереЗ shared schema contract рдЪреА рднреВрдорд┐рдХрд╛ рдмрдЬрд╛рд╡рддреЗ.

ЁЯПн Production рдордзреНрдпреЗ рд╣реЗ рдХрд╛ рдорд╣рддреНрддреНрд╡рд╛рдЪреЗ: рддреБрдордЪреА service рдЬреНрдпрд╛ рдЬреНрдпрд╛ API рдирд╛ call рдХрд░рддреЗ рддреНрдпрд╛рдВрдЪреА рдпрд╛рджреА рдХрд░рд╛. рдкреНрд░рддреНрдпреЗрдХрд╛рд╕рд╛рдареА рд╡рд┐рдЪрд╛рд░рд╛ "рддреНрдпрд╛рдВрдиреА рдЙрджреНрдпрд╛ рдПрдЦрд╛рджреНрдпрд╛ field рдЪреЗ рдирд╛рд╡ рдмрджрд▓рд▓реЗ, рддрд░ рдХреЛрдгрддрд╛ build рд▓рд╛рд▓ рд╣реЛрдИрд▓?" рдЙрддреНрддрд░ "рдЖрдордЪрд╛, production рдордзреНрдпреЗ" рдЕрд╕реЗрд▓, рддрд░ рддреБрдореНрд╣рд╛рд▓рд╛ contract рд╣рд╡рд╛.

тПня╕П рдкреБрдвреЗ

Unit, integration, contract тАФ рдЖрдгрд┐ рд╕рд░реНрд╡рд╛рдд рд╡рд░, рд╕рдЧрд│реНрдпрд╛рдд рд╣рд│реВ рдЖрдгрд┐ рд╕рдЧрд│реНрдпрд╛рдд рдкреВрд░реНрдг test: end-to-end. Suite рдордзреНрдпреЗ рдкреНрд░рддреНрдпреЗрдХ рдкреНрд░рдХрд╛рд░рдЪреНрдпрд╛ рдХрд┐рддреА рдЕрд╕рд╛рд╡реНрдпрд╛рдд? Pyramid рдЖрдгрд┐ trophy.

git checkout lesson-08-e2e-pyramid

ЁЯдЭ Lesson 07 тАФ Contract tests: consumer expectations vs provider response

ЁЯУН You are here: Lesson 07 of 12 ┬╖ Previous: lesson-06-integration ┬╖ Next: lesson-08-e2e-pyramid


ЁЯУж What's in this branch

Lessons 01тАУ06, plus contract tests: the report-card code (the consumer) writes down exactly what it needs from the attendance service (the provider), and the provider checks every release against that list before it ships. CONTRACT and verify_contract() in exam/lab.py; contract() in exam/demo.py.

ЁЯзТ Explain like I'm 5

Katrina's report cards need two numbers from the attendance office: days present and days total. She writes them on a card and pins it on the attendance office wall: ЁЯдЭ

"When I ask for pupil 42, I need back: days_present (a whole number) and days_total (a whole number)."

That card is the contract.

Before the attendance office changes its form, it checks the form against Katrina's card.

Nobody had to run both offices together. Each side checked itself against the same card.

ЁЯЧ║я╕П Diagram

flowchart LR
    c["ЁЯУЗ consumer: report cards<br/>needs days_present:int, days_total:int"] -->|"writes the contract"| k["ЁЯдЭ contract<br/>GET /attendance/42 тЖТ 200"]
    k -->|"replayed in the provider's CI"| p["ЁЯПв provider: attendance"]
    p --> v1["v1 as today тЬЕ"]
    p --> v2["v2 adds late_days тЬЕ"]
    p --> v3["v3 renames to present тЭМ<br/>missing field days_present"]
    p --> v4["v4 days_total as text тЭМ<br/>str, expected int"]

ЁЯЧ║я╕П Drawn version + a lab: https://school-edh.pages.dev/testing/lesson-diagrams.html#l07

тЭУ What

ЁЯдФ Why

Because lesson 05's stubs are claims about another team's service, and end-to-end tests that run both services together are slow and flaky (lesson 08). A contract turns the claim into a file both teams test against: the consumer's unit tests use it as their stub, and the provider's build proves it still answers that way. The breaking change is caught in the provider's pipeline, before deployment, by the team that made it.

ЁЯФз How (in this repo)

CONTRACT in exam/lab.py is a dict: the consumer and provider names, the request (GET /attendance/42) and the expected response (status 200, a body with days_present and days_total of type int). verify_contract(contract, provider) calls the provider with the request path and returns a list of problems: a wrong status, a missing field, or a field of the wrong type. An empty list means the provider honours the contract. Extra fields are ignored on purpose.

ЁЯзк Try it

python3 exam/demo.py contract
python3 - <<'EOF'
from exam.lab import CONTRACT, verify_contract
from exam.grades import AttendanceClient
v5 = lambda p: (200, {"days_present": 172})
v6 = lambda p: (404, {"error": "moved"})
v7 = lambda p: (200, {"days_present": 172.0, "days_total": 200})
for name, prov in (("v5 drops days_total", v5), ("v6 moved the path", v6), ("v7 sends a float", v7)):
    print(f"{name:<20} тЖТ {verify_contract(CONTRACT, prov)}")
try: AttendanceClient(v5).attendance_percent("42")
except KeyError as e: print("without the contract, production sees: KeyError", e)
print("contract request:", CONTRACT["request"])
EOF

тЬЕ Verify тАФ what you should see

contract prints:

   provider v1 as today                  тЖТ тЬЕ honours the contract
   provider v2 adds 'late_days'          тЖТ тЬЕ honours the contract
   provider v3 renames to 'present'      тЖТ тЭМ missing field 'days_present'
   provider v4 sends days_total as text  тЖТ тЭМ 'days_total' is str, expected int

Your snippet prints:

v5 drops days_total  тЖТ ["missing field 'days_total'"]
v6 moved the path    тЖТ ['status 404, expected 200', "missing field 'days_present'", "missing field 'days_total'"]
v7 sends a float     тЖТ ["'days_present' is float, expected int"]
without the contract, production sees: KeyError 'days_total'
contract request: {'method': 'GET', 'path': '/attendance/42'}

ЁЯПБ What you just proved

Every breaking change was named precisely тАФ which field, which type тАФ without running the consumer at all. Without the contract, v5 would have reached production and failed as a KeyError inside the report-card code, far from the change that caused it. And v7 is a lesson in strictness: AttendanceClient would cope with 172.0 perfectly well. If a contract refuses changes the consumer does not care about, teams start ignoring it; pin what you need, no more.

тЪая╕П Common mistakes

ЁЯПн In production

On a real project тАФ a pact file (Pact specification v3) as the consumer's tests write it. Matching rules say "any integer" instead of "exactly 172":

{
  "consumer": {"name": "report-cards"},
  "provider": {"name": "attendance"},
  "interactions": [{
    "description": "attendance for pupil 42",
    "providerStates": [{"name": "pupil 42 exists"}],
    "request": {"method": "GET", "path": "/attendance/42"},
    "response": {
      "status": 200,
      "body": {"days_present": 172, "days_total": 200},
      "matchingRules": {"body": {
        "$.days_present": {"matchers": [{"match": "integer"}]},
        "$.days_total":   {"matchers": [{"match": "integer"}]}
      }}
    }
  }],
  "metadata": {"pactSpecification": {"version": "3.0.0"}}
}

Pact has libraries for Python, JavaScript, Java, Go, .NET and more. Pacts are usually shared through a Pact Broker (or PactFlow), which also answers "is it safe to deploy this version?":

pact-broker can-i-deploy --pacticipant attendance --version 2.3.0 --to-environment production

Other approaches: Spring Cloud Contract (JVM), and OpenAPI-based checks such as Schemathesis or Dredd, where the shared schema plays the part of the contract.

ЁЯПн Why this matters in production: list every API your service calls. For each one, ask "if they renamed a field tomorrow, which build would go red?" If the answer is "ours, in production", you need a contract.

тПня╕П Next

Unit, integration, contract тАФ and at the top, the slowest and most complete test of all: end-to-end. How many of each should a suite have? The pyramid and the trophy.

git checkout lesson-08-e2e-pyramid
тЖР PreviousintegrationNext тЖТe2e pyramid

This page is the lesson's README from the lesson-07-contract-tests branch, shown here so the whole School stays on one site. Code files open on GitHub at the same branch.