Skip to main content
Back to Blog
API Testing
2026-08-08

API Testing Partial Update PATCH Semantics Without Ambiguity

Master API testing partial update PATCH semantics with runnable tests for omission, null, arrays, validation, authorization, idempotency, and concurrency.

API Testing Partial Update PATCH Semantics Without Ambiguity

API testing partial update PATCH semantics starts by proving what each input state means. For every mutable field, test omission, an explicit value, null, an invalid value, and an unauthorized change. Then verify both the response and the persisted resource. A PATCH endpoint is correct only when it changes the requested fields and preserves everything the request did not target.

The media type defines the update language. application/merge-patch+json treats an object as a merge document and gives null deletion semantics. application/json-patch+json sends an ordered list of operations such as add, remove, replace, and test. A custom application/json body can work, but its omission, null, array, and nesting rules must be specified by the API. Tests should never infer those rules from the word PATCH alone.

This guide uses TypeScript, Express-style handlers, Vitest, and Supertest examples. The workflow is portable: publish the patch dialect, build a field-state matrix, assert atomicity and authorization, and exercise stale concurrent writes with validators such as ETags.

Start with the protocol contract

HTTP PATCH is defined by RFC 5789 at https://www.rfc-editor.org/rfc/rfc5789. The request carries instructions for modifying an existing resource. Unlike PUT, it does not inherently mean "send the complete replacement." Unlike POST, its target and intent are specifically an update to the identified resource.

PATCH does not prescribe one JSON shape. The Content-Type tells the server how to interpret the document. Two standardized JSON formats have materially different behavior:

FormatMedia typeDocument shapeImportant semantic
JSON Merge Patchapplication/merge-patch+jsonObject-like replacement fragmentnull removes an object member
JSON Patchapplication/json-patch+jsonOrdered array of operationsPaths and operation order matter
API-specific partial objectOften application/jsonDefined by that APIOmission and null need explicit rules

JSON Merge Patch is defined by RFC 7396 at https://www.rfc-editor.org/rfc/rfc7396. JSON Patch is defined by RFC 6902 at https://www.rfc-editor.org/rfc/rfc6902. Tests should send the declared media type, because a server that accepts the right shape under the wrong type may create interoperability problems for clients and gateways.

The Accept-Patch response header can advertise supported patch document media types. Test it if discovery is part of the contract, often on an OPTIONS response. Do not assume every framework creates this header automatically.

Turn each mutable field into a state matrix

Suppose a user profile has mutable displayName, bio, timezone, and tags fields. Begin with a fully populated baseline. Each test changes one dimension and reloads the resource. This makes preservation failures visible.

Request state for bioPossible intended meaningRequired assertion
Member omittedLeave current value unchangedStored bio remains identical
Non-empty stringReplace current valueStored bio equals input
Empty stringStore empty text or reject by ruleResponse and storage match documented rule
Explicit nullClear, remove, or rejectExact null policy is enforced
Wrong typeReject documentNo field changes persist

Repeat that reasoning for objects and arrays. Arrays are especially important under merge patch: the supplied array replaces the target array as a whole. Merge Patch does not provide an operation that appends one array item. A custom API that appends must say so and should use a request shape that makes the operation clear.

Use a builder that gives every test a known baseline:

export type Profile = {
  id: string;
  displayName: string;
  bio: string | null;
  timezone: string;
  tags: string[];
  revision: number;
};

export function profileFixture(overrides: Partial<Profile> = {}): Profile {
  return {
    id: 'usr_42',
    displayName: 'Asha Rao',
    bio: 'Quality engineer',
    timezone: 'Asia/Kolkata',
    tags: ['api', 'automation'],
    revision: 7,
    ...overrides,
  };
}

This baseline makes a subtle destructive bug easy to spot. If patching displayName resets tags to an empty array, the response and reload assertions fail. A fixture containing only the changed field would miss that data loss.

Implement omission without confusing it with undefined

JavaScript creates a trap: reading an absent property and reading a property whose value is undefined both produce undefined. JSON cannot transmit undefined, but request objects can be transformed before they reach domain code. Use an own-property check when presence is significant.

The following API-specific partial updater allows bio: null to clear the bio, prohibits null for the required fields, and replaces tags as a complete array. It rejects unknown members rather than silently ignoring misspellings.

import type { Profile } from './profile-fixture';

type PatchBody = Record<string, unknown>;

const allowedKeys = new Set(['displayName', 'bio', 'timezone', 'tags']);

export function applyProfilePatch(current: Profile, body: PatchBody): Profile {
  for (const key of Object.keys(body)) {
    if (!allowedKeys.has(key)) throw new Error(`unknown field: ${key}`);
  }

  const next: Profile = { ...current, tags: [...current.tags] };

  if (Object.hasOwn(body, 'displayName')) {
    if (typeof body.displayName !== 'string' || body.displayName.trim() === '') {
      throw new Error('displayName must be a non-empty string');
    }
    next.displayName = body.displayName;
  }

  if (Object.hasOwn(body, 'bio')) {
    if (body.bio !== null && typeof body.bio !== 'string') {
      throw new Error('bio must be a string or null');
    }
    next.bio = body.bio as string | null;
  }

  if (Object.hasOwn(body, 'timezone')) {
    if (typeof body.timezone !== 'string') throw new Error('timezone must be a string');
    next.timezone = body.timezone;
  }

  if (Object.hasOwn(body, 'tags')) {
    if (!Array.isArray(body.tags) || !body.tags.every((tag) => typeof tag === 'string')) {
      throw new Error('tags must be an array of strings');
    }
    next.tags = [...body.tags];
  }

  next.revision += 1;
  return next;
}

This is a custom partial-object contract, not a generic JSON Merge Patch implementation. That distinction belongs in documentation and tests. If the endpoint claims application/merge-patch+json, implement all relevant RFC behavior, including object recursion and null member removal, or use a reviewed implementation.

Unit tests should prove preservation and explicit clearing:

import { describe, expect, it } from 'vitest';
import { applyProfilePatch } from './apply-profile-patch';
import { profileFixture } from './profile-fixture';

describe('applyProfilePatch', () => {
  it('changes one member and preserves omitted members', () => {
    const current = profileFixture();
    const next = applyProfilePatch(current, { displayName: 'Asha R.' });

    expect(next).toMatchObject({
      displayName: 'Asha R.',
      bio: 'Quality engineer',
      timezone: 'Asia/Kolkata',
      tags: ['api', 'automation'],
      revision: 8,
    });
  });

  it('distinguishes clearing from omission', () => {
    const current = profileFixture();
    expect(applyProfilePatch(current, {}).bio).toBe('Quality engineer');
    expect(applyProfilePatch(current, { bio: null }).bio).toBeNull();
  });
});

Verify the wire behavior with Supertest

Unit coverage of the merge function is not enough. Middleware may reject the content type, strip a field, coerce a value, or serialize the response incorrectly. Wire-level tests should construct the application with a disposable repository and assert a subsequent GET or direct repository read.

The following test assumes createApp accepts a repository whose get and save methods are used by the PATCH route. It sends a custom JSON partial document, matching the function above.

import request from 'supertest';
import { beforeEach, describe, expect, it } from 'vitest';
import { createApp } from './app';
import { MemoryProfileRepository } from './memory-profile-repository';
import { profileFixture } from './profile-fixture';

describe('PATCH /profiles/:id', () => {
  let repository: MemoryProfileRepository;

  beforeEach(() => {
    repository = new MemoryProfileRepository([profileFixture()]);
  });

  it('updates displayName without erasing other fields', async () => {
    const app = createApp({ profiles: repository });

    const response = await request(app)
      .patch('/profiles/usr_42')
      .set('Content-Type', 'application/json')
      .send({ displayName: 'Asha R.' })
      .expect(200)
      .expect('Content-Type', /json/);

    expect(response.body).toMatchObject({
      id: 'usr_42',
      displayName: 'Asha R.',
      bio: 'Quality engineer',
      timezone: 'Asia/Kolkata',
      tags: ['api', 'automation'],
    });

    const stored = await repository.get('usr_42');
    expect(stored).toEqual(expect.objectContaining(response.body));
  });
});

The Supertest Node API testing guide covers application construction and response assertions in more depth. For PATCH, the extra obligation is to assert untouched stored members, not just the changed response member.

Choose and document the success response. An API may return 200 OK with the updated representation or 204 No Content without a body. Tests should match the documented choice. They should not insist that all PATCH APIs return one universal status.

Prove JSON Merge Patch behavior recursively

Merge Patch is compact for object-shaped resources. If a member in the patch contains an object and the corresponding target member is an object, processing recurses. Other supplied values replace the target value. A null member removes the named member from an object.

Consider this original document and patch:

{
  "displayName": "Asha Rao",
  "preferences": {
    "theme": "dark",
    "digest": "weekly"
  },
  "tags": ["api", "automation"]
}
{
  "preferences": {
    "digest": "daily"
  },
  "tags": ["contract"],
  "displayName": null
}

The result preserves preferences.theme, changes preferences.digest, replaces the entire tags array, and removes displayName. This raises an important modeling limitation: Merge Patch cannot distinguish "set this member to JSON null" from its null-as-removal instruction. If stored null is a meaningful state, use another operation shape or define a different API contract.

Test the semantics as a matrix:

TargetMerge patchExpected resultRisk covered
Nested preferences objectOne nested memberSibling preservedShallow-spread data loss
Existing object membernullMember removedNull treated as stored value
Existing arrayNew arrayWhole array replacedAccidental append or union
Any documentNon-object patchWhole target replacedAssumption that all patches are objects

That final row is often missed. RFC 7396 defines replacement behavior when the patch is not an object. An endpoint may constrain its accepted documents through resource rules, but then tests should expect a deliberate validation response rather than accidentally applying a shallow merge.

Exercise JSON Patch paths and operation order

JSON Patch is useful when clients need precise array operations, explicit removal, or optimistic assertions through the test operation. Each operation has an op and a path, with additional members depending on the operation. Paths use JSON Pointer syntax.

[
  { "op": "test", "path": "/revision", "value": 7 },
  { "op": "replace", "path": "/displayName", "value": "Asha R." },
  { "op": "add", "path": "/tags/-", "value": "contract" },
  { "op": "remove", "path": "/bio" }
]

The operations are applied in order, so changing their order can change the outcome. The special - array index addresses the position after the last element for an add operation. Do not write a homegrown parser unless the API's needs are tiny and the implementation is rigorously reviewed. JSON Pointer escaping and array bounds contain edge cases that mature libraries are better positioned to handle.

An endpoint test should prove media-type enforcement and the final complete state:

import request from 'supertest';
import { expect, it } from 'vitest';
import { app } from './test-app';

it('applies ordered JSON Patch operations', async () => {
  const operations = [
    { op: 'test', path: '/revision', value: 7 },
    { op: 'replace', path: '/displayName', value: 'Asha R.' },
    { op: 'add', path: '/tags/-', value: 'contract' },
    { op: 'remove', path: '/bio' },
  ];

  const response = await request(app)
    .patch('/profiles/usr_42')
    .set('Content-Type', 'application/json-patch+json')
    .send(operations)
    .expect(200);

  expect(response.body.displayName).toBe('Asha R.');
  expect(response.body.tags).toEqual(['api', 'automation', 'contract']);
  expect(response.body).not.toHaveProperty('bio');
});

Add negative cases for a missing path, invalid array index, unsupported operation, malformed pointer, failed test, and an operation that targets a protected member. Assert atomicity according to your documented contract. A partially applied operation list can leave a resource in a state no client requested.

Reject invalid patches atomically

Validation has at least three layers: document syntax, operation or field shape, and resulting resource invariants. A request can be valid JSON and still be an invalid patch document. A structurally valid patch can produce an invalid business state.

For example, an event may require startsAt < endsAt. Updating only startsAt must be checked against the current endsAt, not validated in isolation. Apply changes to a candidate copy, validate the complete candidate, then persist once inside an appropriate transaction.

FailureExampleExpected protection
Malformed JSONTruncated request bodyNo handler mutation
Unknown memberdispalyName typoReject, do not ignore silently
Wrong typetags: "api"No resource changes
Cross-field violationStart after endCandidate rejected atomically
Storage conflictUnique value already usedTransaction rolls back

A negative endpoint test should reload the record:

import request from 'supertest';
import { expect, it } from 'vitest';
import { app, profiles } from './test-app';

it('does not persist valid fields when another field is invalid', async () => {
  const before = await profiles.get('usr_42');

  await request(app)
    .patch('/profiles/usr_42')
    .send({ displayName: 'Changed', tags: 'not-an-array' })
    .expect(400);

  const after = await profiles.get('usr_42');
  expect(after).toEqual(before);
});

Whether validation uses 400 Bad Request or 422 Unprocessable Content is an API design choice with ecosystem conventions. Test the published contract consistently rather than presenting one as universally required. Keep error bodies stable enough for clients to identify the failing member without depending on human wording.

Test authorization at the field and path level

Route-level authorization answers whether the caller may attempt to update a profile. PATCH also needs field-level authorization. A normal user may edit displayName but not role, accountId, creditBalance, or server-managed timestamps.

Do not solve this by deleting forbidden fields from the body and continuing. Silent filtering tells the caller the request succeeded while hiding that part of it was ignored. It also makes mass-assignment vulnerabilities harder to notice. Reject the document and prove that no permitted sibling change persisted.

import request from 'supertest';
import { expect, it } from 'vitest';
import { app, profiles } from './test-app';

it('rejects a protected member without applying allowed siblings', async () => {
  const before = await profiles.get('usr_42');

  await request(app)
    .patch('/profiles/usr_42')
    .set('Authorization', 'Bearer regular-user-test-token')
    .send({ displayName: 'Changed', role: 'admin' })
    .expect(403);

  expect(await profiles.get('usr_42')).toEqual(before);
});

For JSON Patch, inspect every path, including from on move and copy operations. Path authorization should use decoded JSON Pointer segments, not an unsafe string-prefix test. A path that visually starts like an allowed path may refer to a different escaped member.

Separate repeatability from idempotency

PATCH is not guaranteed to be idempotent by the HTTP method definition. A given patch format or operation may be idempotent. Replacing displayName with a fixed value produces the same resource when repeated. Appending to an array can add duplicates. An increment operation in a custom API changes the resource each time.

Patch instructionRepeated outcomeIdempotent?
Merge { "timezone": "UTC" }Same final timezoneYes for that state
JSON Patch replace fixed valueSame final value if path remains validGenerally yes
JSON Patch add to /tags/-Another item may be appendedNo
Custom increment by oneValue increases againNo

Tests should reflect the operation's declared behavior. Do not add a generic "send every PATCH twice" assertion and call failures server bugs. For non-idempotent operations exposed to automatic retries, consider an idempotency-key design appropriate to the API and test replay handling explicitly.

Even an idempotent state transition can produce non-idempotent side effects if implementation is careless. Repeating a request that sets emailVerified: true should not send the same welcome event repeatedly unless that is deliberate. Assert emitted events, audit records, and revision changes where they are contractual.

Protect against lost updates with conditional requests

Partial updates reduce the surface of a lost update but do not eliminate it. Two clients can read revision 7, calculate changes, and write in conflicting order. HTTP conditional requests let a client say which representation it modified. A common pattern returns an ETag on GET and requires If-Match on PATCH.

GET /profiles/usr_42 HTTP/1.1
Accept: application/json

HTTP/1.1 200 OK
ETag: "profile-7"
Content-Type: application/json

{"id":"usr_42","displayName":"Asha Rao","revision":7}

Client A patches with If-Match: "profile-7" and receives a new representation with a new ETag. Client B's request using the stale validator must fail without mutation. RFC 9110 defines conditional request semantics at https://www.rfc-editor.org/rfc/rfc9110.

import request from 'supertest';
import { expect, it } from 'vitest';
import { app } from './test-app';

it('rejects a stale conditional patch', async () => {
  const initial = await request(app).get('/profiles/usr_42').expect(200);
  const etag = initial.headers.etag;
  expect(typeof etag).toBe('string');

  await request(app)
    .patch('/profiles/usr_42')
    .set('If-Match', etag)
    .send({ timezone: 'UTC' })
    .expect(200);

  await request(app)
    .patch('/profiles/usr_42')
    .set('If-Match', etag)
    .send({ displayName: 'Stale writer' })
    .expect(412);

  const current = await request(app).get('/profiles/usr_42').expect(200);
  expect(current.body.timezone).toBe('UTC');
  expect(current.body.displayName).toBe('Asha Rao');
});

The repository update itself must compare the expected revision atomically. Checking in application code and issuing an unconditional update creates a race between the check and write. Tests that launch two requests together can expose the issue, but the durable fix belongs in the storage operation.

Keep response contracts and provider behavior aligned

PATCH contract coverage should describe supported media types, request schema, nullable fields, immutable fields, error shapes, response representation, and concurrency requirements. Schema validation alone cannot express all update semantics. An OpenAPI schema may distinguish required request members, but it will not prove that omitted persisted members were preserved.

Consumer-driven contract tests are useful when clients rely on a specific partial-update behavior. A consumer can capture the request media type, the minimal patch body, conditional header, and relevant response fields. The provider must still run stateful tests to prove persistence and atomicity. The Pact contract testing guide explains how consumer expectations and provider verification fit together.

When evolving a patch contract, treat these as potentially breaking changes:

  • Changing null from "clear" to "reject."
  • Switching array behavior from replacement to merge.
  • Rejecting a field that clients previously updated.
  • Changing from a representation response to no content.
  • Requiring a conditional header that was previously optional.
  • Changing the patch media type while keeping the same route.

Versioning discussions should consider behavior, not only JSON shape. A generated client cannot protect consumers from a semantic change that leaves the schema looking compatible.

Diagnose the disappearing nested settings failure

A realistic failure appears when a user patches { "preferences": { "digest": "daily" } } and the saved resource loses preferences.theme. The endpoint claims Merge Patch support. Unit tests pass because they only assert the changed digest value. Production users suddenly revert to the default theme.

Trace the update from wire to storage. Confirm the Content-Type. Log the parsed patch and original target in a safe test environment. Inspect the merge implementation. A shallow spread such as { ...current, ...patch } replaces the entire preferences object, but Merge Patch requires recursive object processing in this case. Then inspect persistence, because an ORM update can also replace a JSON column even when the in-memory candidate was merged correctly.

Add a regression test with a populated sibling at every nested level that matters. Assert the full stored preferences object after reloading it. If the server never intended RFC 7396 behavior, stop advertising application/merge-patch+json and document its custom object replacement rules. The fix is semantic alignment, not merely changing one expectation.

What people get wrong about PATCH testing

The biggest error is treating PATCH as "PUT with fewer required fields." That mindset validates only the supplied values and forgets preservation, null meaning, operation ordering, authorization by path, and resulting-resource invariants. Partial input does not mean partial validation of the final state.

Another mistake is asserting only the response. A handler can construct the correct response while the repository drops a nested value, fails to update, or commits part of an invalid patch. Reload the resource through an independent read path for persistence-critical tests.

Teams also overgeneralize idempotency. The method itself does not promise that every patch document is safe to repeat. Classify the actual operation, then test retry behavior and side effects. Finally, accepting unknown members for forward compatibility often hides client typos. If silent ignore is intentional, test and document it. For most operational APIs, a clear rejection is easier to debug.

Build a focused PATCH regression suite

A maintainable suite layers fast pure-function tests, route tests, repository integration tests, and a few concurrency scenarios. Parameterize repeated field-state checks, but preserve descriptive case names and full-state assertions.

Use this release checklist:

Coverage areaMinimum proofTypical hidden bug
Media typeSupported accepted, unsupported rejectedParser handles body under wrong semantics
PreservationOmitted scalar, object sibling, and array unchangedDefaults overwrite stored values
Null and emptyEach nullable field's ruleNull becomes omission through mapping
ValidationInvalid candidate leaves storage unchangedPartial transaction commit
AuthorizationProtected path rejects entire documentMass assignment
ConcurrencyStale validator cannot overwriteCheck-then-write race
Side effectsEvents emitted once as specifiedRetry duplicates notification

Give an AI coding agent the media type RFC, resource schema, mutable-field allowlist, and concurrency policy before asking it to generate tests. Ask for the field-state matrix first, then review whether the code reloads persisted state. Generated PATCH tests often check the changed key only, confuse Merge Patch with a shallow object spread, or use a valid JSON body with the wrong content type.

The resulting suite should answer precise questions: What does omission do? Can null be stored? Are arrays replaced or edited? Is the operation atomic? Can this caller modify this path? What happens when two revisions compete? Once those answers are executable, PATCH stops being a vague partial update and becomes a stable protocol.

Frequently Asked Questions

Does PATCH always mean JSON Merge Patch?

No. PATCH is an HTTP method, while the request media type defines the patch document format. An endpoint can support JSON Merge Patch, JSON Patch, or a documented domain-specific format. Send the matching Content-Type and test unsupported types. If an API accepts ordinary application/json, its documentation must define omission, null, nested objects, arrays, validation, and atomicity because those behaviors cannot be inferred from PATCH itself.

Should an empty PATCH request succeed?

That depends on the advertised patch format and API policy. An empty Merge Patch object normally leaves an object target unchanged, while an empty JSON Patch array performs no operations. An API may still reject no-op requests for business reasons. Test the documented choice, including whether a successful no-op changes the ETag, revision, audit log, or update timestamp. Those side effects should be deliberate and consistent rather than accidental consequences of always issuing a database update.

How do I test that omitted fields were preserved?

Create a baseline resource with distinctive non-default values in every relevant scalar, nested object, and array. Send a patch containing only one target change. Assert the complete response, then reload through the repository or GET endpoint and compare every untouched value with the baseline. Include nested siblings because shallow merges commonly erase them. Avoid fixtures filled with defaults, since an accidental reset can look identical to correct preservation.

When should a PATCH endpoint require If-Match?

Require a precondition when overwriting a change made from stale state would harm users or violate workflow rules. Return a validator such as an ETag with the representation, require the client to send it in If-Match, and make the storage update compare the expected version atomically. Test one successful update followed by a second request using the stale validator. The stale request must fail without modifying data or emitting success side effects.