{"title":"API Contract Testing with Playwright and JSON Schema","excerpt":"Playwright's request fixture gives you a browser-free API suite, but a schema check only catches a renamed or vanished field if it sets additionalProperties: false and keeps the name in required. This walks through the open-schema trap, the Ajv settings that decide whether failures are readable, generating schemas from an OpenAPI document, why allOf silently reopens a closed schema, and the honest boundary between schema validation and consumer-driven contract testing with Pact.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/api-contract-testing-playwright-json-schema","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["playwright","api-testing","contract-testing","json-schema","ajv","openapi"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-09-10T13:30:00.000Z","dateModified":"2026-09-07T11:52:05.017Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v2/api-contract-testing-playwright-json-schema.webp","alt":"Dark technical illustration of a JSON payload being checked field by field. Eight horizontal lanes run edge to edge, each carrying a rounded key pill and a longer value pill from the left. In the centre stands a tall green schema wall with rectangular slots cut into it, one per declared property; most lanes drop into their slot and continue to the right as green result pills. One lane is drawn hollow and dashed with a crossed-out orange circle at its slot, marking a required property that never arrived. In the middle of the wall there is no slot at all, only a gap with torn orange edges, through which a bright orange lane passes uninspected and carries on to the right edge."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> JSON Schema ignores properties it was not told about. Unless a schema sets <code>additionalProperties: false</code> — or <code>unevaluatedProperties: false</code> once composition is involved — a response can drop a field, add a differently spelled replacement, and still validate. Playwright's <code>request</code> fixture gives you a browser-free HTTP client with <code>baseURL</code> and <code>extraHTTPHeaders</code> already applied; Ajv gives you the validator. Closing the schema is what turns the pair into a contract check rather than a shape suggestion.</div>\n\n<p>Take a payments service that returns an order summary. In a minor release the field <code>amountDue</code> is renamed to <code>amount_due</code>. The consumer's contract suite runs against the deployed service on every pipeline and reports twelve green checks. The web front end starts rendering an empty total two days later, and the schema that was supposed to prevent exactly this had the field written into it the whole time.</p>\n\n<p>The schema listed <code>amountDue</code> under <code>properties</code> and left it out of <code>required</code>, because in a draft order it can legitimately be absent. So the rename produced two effects that cancelled out. The old key was gone, and nothing required it. The new key was unrecognised, and unrecognised keys are permitted. Both halves of the failure were invisible to the validator, and the sum of two invisible failures is a pass.</p>\n\n<h2 id=\"browser-free-request-suite\">A request suite that never starts a browser</h2>\n\n<p>Playwright's test runner ships a <code>request</code> fixture that speaks HTTP directly from Node. The documentation is explicit that it \"respects configuration options like <code>baseURL</code> or <code>extraHTTPHeaders</code>\", and that behind the scenes it calls <code>apiRequest.newContext()</code> for you. Nothing launches, so a contract project runs in the time it takes to do the round trips.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// playwright.config.ts\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n  projects: [\n    {\n      name: 'contract',\n      testDir: './tests/contract',\n      use: {\n        baseURL: process.env.ORDERS_API ?? 'http://localhost:8080',\n        extraHTTPHeaders: {\n          Accept: 'application/json',\n          Authorization: `Bearer ${process.env.ORDERS_TOKEN}`,\n        },\n      },\n    },\n    // ...browser projects live alongside and are unaffected.\n  ],\n});</code></pre>\n\n<p>Per-file overrides use <code>test.use()</code>, which the options reference documents as the way to override some options for a file. That matters when one endpoint sits behind a different gateway or needs a different media type:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// tests/contract/orders.spec.ts\nimport { test, expect } from '@playwright/test';\n\ntest.use({ extraHTTPHeaders: { Accept: 'application/vnd.orders.v2+json' } });\n\ntest('GET /orders/{id} answers with a summary', async ({ request }) =&gt; {\n  const response = await request.get('/orders/8842');\n\n  // toBeOK() asserts the status is inside 200..299 — nothing about the body.\n  await expect(response).toBeOK();\n  expect(response.headers()['content-type']).toContain('application/json');\n\n  const body = await response.json();\n  expect(body.id).toBe('8842');\n\n  // Response bodies stay in memory until the context closes. Long files that\n  // fetch large payloads should hand them back explicitly.\n  await response.dispose();\n});</code></pre>\n\n<p><code>expect(response).toBeOK()</code> and <code>response.json()</code> are the whole of what Playwright offers about payload correctness: a status range and a parsed object. Every claim about the shape of that object has to come from somewhere else.</p>\n\n<h2 id=\"open-schema-passes-anything\">The schema that cannot fail</h2>\n\n<p>Here is the check that let the rename through, written out in full. Both payloads below satisfy it.</p>\n\n<pre class=\"language-json\"><code class=\"language-json\">{\n  \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n  \"type\": \"object\",\n  \"properties\": {\n    \"id\":        { \"type\": \"string\" },\n    \"status\":    { \"type\": \"string\", \"enum\": [\"draft\", \"open\", \"settled\"] },\n    \"amountDue\": { \"type\": \"number\" },\n    \"currency\":  { \"type\": \"string\" }\n  },\n  \"required\": [\"id\", \"status\"]\n}</code></pre>\n\n<pre class=\"language-json\"><code class=\"language-json\">// Valid — the shape everyone believes is being enforced.\n{ \"id\": \"8842\", \"status\": \"open\", \"amountDue\": 4150, \"currency\": \"INR\" }\n\n// Also valid. amountDue is gone; amount_due is an unrecognised property,\n// and unrecognised properties are allowed by default.\n{ \"id\": \"8842\", \"status\": \"open\", \"amount_due\": 4150, \"currency\": \"INR\" }</code></pre>\n\n<p>The specification's own words for this are that \"by default any additional properties are allowed\", and separately that \"by default, the properties defined by the <code>properties</code> keyword are not required\". Listing a property is a conditional statement: <em>if</em> this key is present, it must look like this. It is never an instruction that the key must exist. Two keywords, and neither of them is doing what the author assumed.</p>\n\n<p>The closed version differs by two lines and behaves completely differently:</p>\n\n<pre class=\"language-json\"><code class=\"language-json\">{\n  \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n  \"type\": \"object\",\n  \"properties\": {\n    \"id\":        { \"type\": \"string\" },\n    \"status\":    { \"type\": \"string\", \"enum\": [\"draft\", \"open\", \"settled\"] },\n    \"amountDue\": { \"type\": [\"number\", \"null\"] },\n    \"currency\":  { \"type\": \"string\", \"minLength\": 3, \"maxLength\": 3 }\n  },\n  \"required\": [\"id\", \"status\", \"amountDue\", \"currency\"],\n  \"additionalProperties\": false\n}</code></pre>\n\n<p>Now the rename fails twice over: <code>required</code> reports a missing <code>amountDue</code>, and <code>additionalProperties</code> reports <code>amount_due</code> as a property that does not belong. Note also what happened to nullability. A draft order has no amount yet, but the field is still sent, so the type becomes <code>[\"number\", \"null\"]</code> and the name goes into <code>required</code>. The reference documentation states the rule plainly: in JSON a property whose value is <code>null</code> is not equivalent to the property not being present. Modelling \"sometimes empty\" as \"sometimes missing\" is what pushed the field out of <code>required</code> in the first place, and that single modelling choice is what disarmed the check.</p>\n\n<div class=\"callout callout-warning\"><strong>Read your existing schemas for this pattern:</strong> a property that appears under <code>properties</code>, is absent from <code>required</code>, and is documented somewhere as \"null when the order is a draft\". Each one of those is a field your suite currently cannot notice the disappearance of.</div>\n\n<h2 id=\"ajv-configuration-that-matters\">Configuring Ajv so failures are loud</h2>\n\n<p>Ajv compiles a schema into a JavaScript validation function, which is why the compile step is comparatively slow and the validation step is not. Its own guidance is to compile once and reuse the returned function — module scope is the natural home for that.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// tests/contract/schema.ts\nimport Ajv2020 from 'ajv/dist/2020';\nimport addFormats from 'ajv-formats';\nimport type { ErrorObject, ValidateFunction } from 'ajv';\n\n// strictSchema defaults to true, and leaving it alone is the point: it turns a\n// typo such as \"requried\" from a silently ignored keyword into a compile throw.\nconst ajv = new Ajv2020({\n  allErrors: true, // default is false: reporting stops at the first error\n  verbose: true,   // attaches the offending data to each error object\n});\n\n// Ajv 7 and later ship no formats at all. Without this line, \"format\": \"uuid\"\n// throws during compilation rather than being quietly skipped.\naddFormats(ajv);\n\nexport const compile = (schema: object): ValidateFunction =&gt; ajv.compile(schema);\n\nexport const explain = (errors: ErrorObject[] | null | undefined): string =&gt;\n  (errors ?? [])\n    .map((e) =&gt; {\n      const where = e.instancePath || '(root)';\n      const extra = JSON.stringify(e.params);\n      return `${where} ${e.keyword}: ${e.message} ${extra}`;\n    })\n    .join('\\n');</code></pre>\n\n<p>Three details in there change what your failures look like. <code>allErrors</code> defaults to <code>false</code>, so an unconfigured validator reports one problem per run and hides the other four. <code>verbose</code> adds the failing <code>data</code>, <code>schema</code> and <code>parentSchema</code> to each error object. And the <code>params</code> object is keyword-specific in a way that is exactly what you want to print: <code>required</code> errors carry <code>missingProperty</code>, and <code>additionalProperties</code> errors carry <code>additionalProperty</code> — the name of the key nobody expected.</p>\n\n<p>One trap deserves its own sentence, because Ajv's getting-started guide flags it and it survives code review easily: every call to a validation function overwrites the <code>errors</code> property. Capture it immediately, or the assertion you eventually write will report the wrong response's problems.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// tests/contract/orders.spec.ts\nimport { test, expect } from '@playwright/test';\nimport { compile, explain } from './schema';\nimport orderSummary from './schemas/order-summary.json';\n\nconst validateOrder = compile(orderSummary);\n\ntest('the order summary matches the published contract', async ({ request }) =&gt; {\n  const response = await request.get('/orders/8842');\n  await expect(response).toBeOK();\n\n  const body = await response.json();\n  const valid = validateOrder(body);\n  // Copy the reference before anything else can run the validator again.\n  const errors = validateOrder.errors ? [...validateOrder.errors] : [];\n\n  expect(valid, `contract violated:\\n${explain(errors)}`).toBe(true);\n});</code></pre>\n\n<h2 id=\"schemas-from-openapi\">Taking the schema from the OpenAPI document instead</h2>\n\n<p>Hand-maintained schemas drift, and a drifted schema is a check that agrees with whatever it was last edited to agree with. If the provider publishes an OpenAPI 3.1 document, you can validate against that document directly, because the specification defines its Schema Object as \"a superset of JSON Schema Specification Draft 2020-12\". The extras it adds are the OAS base vocabulary — <code>discriminator</code>, <code>xml</code>, <code>externalDocs</code>, <code>example</code> and <code>deprecated</code> — plus permission for arbitrary further keywords.</p>\n\n<p>That permission collides with Ajv's strict mode, which fails compilation on unknown keywords by design, since the alternative is that a mistyped keyword is ignored and the schema quietly weakens. Declare the OAS vocabulary rather than switching strictness off:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// tests/contract/from-openapi.ts\nimport { readFileSync } from 'node:fs';\nimport YAML from 'yaml';\nimport Ajv2020 from 'ajv/dist/2020';\nimport addFormats from 'ajv-formats';\n\nconst ajv = new Ajv2020({ allErrors: true });\naddFormats(ajv); // supplies int32, int64, binary, byte — the OAS format values\n\n// Declared as known-and-ignorable. If you want Ajv to actually enforce tagged\n// unions, drop 'discriminator' from this list and pass discriminator: true,\n// which defaults to false.\najv.addVocabulary(['discriminator', 'xml', 'externalDocs', 'example', 'deprecated']);\n\nconst doc = YAML.parse(readFileSync('openapi.yaml', 'utf8'));\n\n// Component refs are JSON Pointers into the OpenAPI document. Re-root them so\n// the component map can be registered as one ordinary schema resource.\nconst components = JSON.parse(\n  JSON.stringify({ $defs: doc.components.schemas }).replaceAll(\n    '#/components/schemas/',\n    '#/$defs/',\n  ),\n);\n\nexport const schemaFor = (name: string) =&gt;\n  ajv.compile({ ...components, $ref: `#/$defs/${name}` });</code></pre>\n\n<p>OpenAPI 3.0 needs a conversion step first: that version defines its Schema Object as \"an extended subset of JSON Schema Specification Draft Wright-00\", with <code>nullable: true</code> standing in for a <code>null</code> type. Ajv understands <code>nullable</code> and, behind an option, <code>discriminator</code> as OpenAPI extensions, but you cannot mix draft 2020-12 and earlier drafts inside one Ajv instance, so a 3.0 document wants its own instance built from the default draft-07 export.</p>\n\n<p>There is a limit to this technique that is worth stating rather than discovering. If the provider generates its OpenAPI document from the same annotations that produce the response, a rename updates the document and the test, and the pair stay agreeable while your client breaks. Pin the version of the spec your consumer was written against, and treat a diff in it as a review item, not as an automatic upgrade.</p>\n\n<h2 id=\"closing-composed-schemas\">Composition quietly reopens a closed schema</h2>\n\n<p>Provider specs love <code>allOf</code>. A base <code>Order</code> extended by <code>SettledOrder</code> is the idiomatic OpenAPI way to express that, and it breaks <code>additionalProperties</code> outright. The reference documentation states the constraint precisely: <code>additionalProperties</code> \"only recognizes properties declared in the same subschema as itself\". Everything contributed by a branch of <code>allOf</code> is, from the outer schema's point of view, additional.</p>\n\n<pre class=\"language-json\"><code class=\"language-json\">// Wrong: nothing can satisfy this. \"settledAt\" is required by the outer\n// schema and rejected by the inner one, which never heard of it.\n{\n  \"allOf\": [\n    {\n      \"type\": \"object\",\n      \"properties\": { \"id\": { \"type\": \"string\" }, \"status\": { \"type\": \"string\" } },\n      \"required\": [\"id\", \"status\"],\n      \"additionalProperties\": false\n    }\n  ],\n  \"properties\": { \"settledAt\": { \"type\": \"string\", \"format\": \"date-time\" } },\n  \"required\": [\"settledAt\"]\n}</code></pre>\n\n<pre class=\"language-json\"><code class=\"language-json\">// Right: unevaluatedProperties collects what the subschemas successfully\n// validated and rejects only what nothing accounted for.\n{\n  \"allOf\": [\n    {\n      \"type\": \"object\",\n      \"properties\": { \"id\": { \"type\": \"string\" }, \"status\": { \"type\": \"string\" } },\n      \"required\": [\"id\", \"status\"]\n    }\n  ],\n  \"properties\": { \"settledAt\": { \"type\": \"string\", \"format\": \"date-time\" } },\n  \"required\": [\"settledAt\"],\n  \"unevaluatedProperties\": false\n}</code></pre>\n\n<p>The keyword arrived in draft 2019-09, which is the practical reason to run Ajv's 2020 class for this work even when your schemas use nothing else from the newer drafts. Teams that hit the broken version usually respond by deleting <code>additionalProperties</code>, and land back at an open schema without noticing they have given up the only check that catches a stray field.</p>\n\n<h2 id=\"schema-versus-pact\">Where Pact answers a different question</h2>\n\n<p>Schema validation asks whether one response matches a published description. Consumer-driven contract testing asks whether a provider still satisfies every consumer that depends on it, and Pact's documentation is careful about the boundary. Each interaction records \"a minimal expected response — describing the parts of the response the consumer wants the provider to return\", and provider verification \"passes if each request generates a response that contains at least the data described in the minimal expected response\".</p>\n\n<p>Read that literally and the honest comparison falls out. Pact does not fail on an added field either; that is deliberate, and its authoring guidance says to \"only make assertions about things that will affect the consumer if they change\". So <code>additionalProperties: false</code> in your own test suite remains the only mechanism here that notices a field arriving unannounced — and it is a check on your understanding of the API, not a constraint you are entitled to impose on the provider.</p>\n\n<p>What Pact adds is the direction of authority. The expectations come from real consumer code paths across all consumers, so a provider learns before deploying that a field it considered internal is being read by two teams. Its docs also mark out what it deliberately does not do: \"a contract test does not check for side effects\", and functional questions such as whether the order was actually persisted belong to the provider's own tests.</p>\n\n<p>Held together, the split is simple. Schema checks give you fast, browser-free verification that a live environment still answers in the shape you were promised, and they run against staging today with no coordination. Pact gives you a broker, a versioned contract, and a can-I-deploy answer, at the cost of both teams adopting it. The first is a test you can add this afternoon; the second is a process change.</p>\n\n<h2 id=\"close-your-schemas-today\">Apply this now</h2>\n\n<p>Open the schemas your contract suite already uses and grep them for <code>additionalProperties</code>. For each one that does not have it, add <code>\"additionalProperties\": false</code>, run the suite against staging, and keep the output. That run is the evidence worth capturing: every failure it produces is a field your service is sending today that your contract does not describe, which is the drift you have been carrying unmeasured.</p>\n\n<p>Then work through the failures in two piles. Fields that consumers actually read go into <code>properties</code> and into <code>required</code>, with <code>[\"number\", \"null\"]</code>-style types where the value can be empty. Fields nobody reads get left out and stay a deliberate failure until someone decides. Where a schema uses <code>allOf</code>, swap in <code>unevaluatedProperties: false</code> instead, and confirm the swap by adding a junk key to a fixture and watching the test go red.</p>\n\n<h2 id=\"contract-schema-faqs\">Frequently asked questions</h2>\n\n<h3 id=\"faq-close-every-schema\">Should every schema close itself with <code>additionalProperties: false</code>?</h3>\n\n<p>In a consumer's test suite, yes — that is where you want to be told about anything unexpected. In a schema you publish as the definition of your own API, no: closing it stops any client from tolerating fields you add later, and Pact's guidance against over-constraining a provider applies for the same reason. The distinction is whether the schema is a detector or a promise.</p>\n\n<h3 id=\"faq-browser-needed\">Do these tests need a browser installed?</h3>\n\n<p>No. The <code>request</code> fixture issues HTTP from Node without loading a page, which is why the documented use cases include preparing server-side state before a browser test and checking server-side post-conditions afterwards. Keeping them in a separate project with its own <code>testDir</code> lets the contract suite run on pipelines where browsers are not provisioned.</p>\n\n<h3 id=\"faq-unknown-format-throw\">Why does Ajv throw \"unknown format\" on a schema that worked before?</h3>\n\n<p>From version 7 onwards Ajv ships none of the formats defined by the JSON Schema specification; they live in the <code>ajv-formats</code> package. Unknown formats throw during schema compilation by default, which is a feature — the alternative is <code>\"format\": \"date-tiem\"</code> passing everything. Call <code>addFormats(ajv)</code>, or register the individual names with <code>addFormat</code>.</p>\n\n<h3 id=\"faq-null-vs-missing\">Is a field set to <code>null</code> the same as a missing field?</h3>\n\n<p>No, and conflating them is the most common way a <code>required</code> list gets hollowed out. A <code>null</code> value is present and has type <code>\"null\"</code>, so it satisfies <code>required</code> and fails <code>{\"type\": \"string\"}</code>. Express it as <code>{\"type\": [\"string\", \"null\"]}</code> and keep the name in <code>required</code>, rather than dropping the name to make the failure go away.</p>\n\n<h3 id=\"faq-schema-storage\">Where should the schemas live — with the tests or with the API?</h3>\n\n<p>With the consumer, under version control, even when they are generated from the provider's OpenAPI document. A schema fetched fresh from the provider at run time cannot detect drift, because it changes at the same moment the response does. Regenerate deliberately, commit the diff, and let review decide whether the change was intentional.</p>\n\n<h2 id=\"primary-sources-contract\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://playwright.dev/docs/api-testing\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — API testing</a>: the <code>request</code> fixture respecting <code>baseURL</code> and <code>extraHTTPHeaders</code>, its equivalence to <code>apiRequest.newContext()</code>, and browser-free usage</li>\n<li><a href=\"https://playwright.dev/docs/api/class-testoptions\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — TestOptions</a>: <code>baseURL</code> resolution rules, <code>extraHTTPHeaders</code>, and overriding options for a single file with <code>test.use()</code></li>\n<li><a href=\"https://playwright.dev/docs/api/class-apiresponse\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — APIResponse</a>: <code>json()</code>, <code>headers()</code>, <code>status()</code>, and <code>dispose()</code> releasing a body held in memory</li>\n<li><a href=\"https://playwright.dev/docs/api/class-apiresponseassertions\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — APIResponseAssertions</a>: <code>toBeOK()</code> covering the 200–299 status range and nothing more</li>\n<li><a href=\"https://playwright.dev/docs/test-assertions\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Assertions</a>: passing a custom failure message as the second argument to <code>expect</code></li>\n<li><a href=\"https://json-schema.org/understanding-json-schema/reference/object\" target=\"_blank\" rel=\"noopener noreferrer\">JSON Schema — object</a>: additional properties allowed by default, <code>additionalProperties: false</code>, <code>required</code> being independent of <code>properties</code>, <code>null</code> versus absence, the same-subschema restriction under <code>allOf</code>, and <code>unevaluatedProperties</code></li>\n<li><a href=\"https://ajv.js.org/strict-mode.html\" target=\"_blank\" rel=\"noopener noreferrer\">Ajv — Strict mode</a>: unknown keywords and unknown formats failing schema compilation, <code>addVocabulary</code>, and <code>strictRequired</code></li>\n<li><a href=\"https://ajv.js.org/options.html\" target=\"_blank\" rel=\"noopener noreferrer\">Ajv — Options</a>: the option defaults table, including <code>allErrors: false</code>, <code>verbose: false</code>, <code>strictSchema: true</code> and <code>discriminator: false</code></li>\n<li><a href=\"https://ajv.js.org/api.html\" target=\"_blank\" rel=\"noopener noreferrer\">Ajv — API reference</a>: <code>compile</code>, the <code>ErrorObject</code> fields, and the keyword-specific <code>params</code> including <code>missingProperty</code> and <code>additionalProperty</code></li>\n<li><a href=\"https://ajv.js.org/guide/getting-started.html\" target=\"_blank\" rel=\"noopener noreferrer\">Ajv — Getting started</a>: compilation cost versus validation cost, and <code>errors</code> being overwritten on every call</li>\n<li><a href=\"https://ajv.js.org/json-schema.html\" target=\"_blank\" rel=\"noopener noreferrer\">Ajv — JSON Schema</a>: the draft 2020-12 class import, drafts not mixing in one instance, and support for the OpenAPI <code>nullable</code> and <code>discriminator</code> keywords</li>\n<li><a href=\"https://github.com/ajv-validator/ajv-formats\" target=\"_blank\" rel=\"noopener noreferrer\">ajv-formats</a>: <code>addFormats(ajv)</code> and the format list, including the OpenAPI values <code>int32</code>, <code>int64</code>, <code>byte</code> and <code>binary</code></li>\n<li><a href=\"https://spec.openapis.org/oas/v3.1.0.html\" target=\"_blank\" rel=\"noopener noreferrer\">OpenAPI Specification 3.1.0</a>: the Schema Object as a superset of JSON Schema draft 2020-12, and the base vocabulary keywords the dialect adds</li>\n<li><a href=\"https://spec.openapis.org/oas/v3.0.4.html\" target=\"_blank\" rel=\"noopener noreferrer\">OpenAPI Specification 3.0.4</a>: the Schema Object as an extended subset of Draft Wright-00, and <code>nullable</code> in place of a <code>null</code> type</li>\n<li><a href=\"https://docs.pact.io/getting_started/how_pact_works\" target=\"_blank\" rel=\"noopener noreferrer\">Pact — How Pact works</a>: minimal expected responses, and provider verification passing on \"at least\" the described data</li>\n<li><a href=\"https://docs.pact.io/consumer/contract_tests_not_functional_tests\" target=\"_blank\" rel=\"noopener noreferrer\">Pact — Contract tests vs functional tests</a>: contract tests not checking side effects, and the table of which test owns which assertion</li>\n<li><a href=\"https://docs.pact.io/consumer\" target=\"_blank\" rel=\"noopener noreferrer\">Pact — Writing consumer tests</a>: asserting only on what affects the consumer if it changes</li>\n</ul>\n"}