The green Saved toast answers one narrow question: did the React application render a success message?
It does not prove that the database committed the update. It does not prove that the audit event was written. It does not prove that a notification job reached its queue.
For consequential workflows, test three postconditions:
| Surface | Evidence |
|---|---|
| Browser | The user saw the expected state |
| API | The durable record contains the change |
| Side-effect sink | The expected asynchronous event appeared |
Example: changing an account role
The UI action remains in the browser. Setup and postcondition checks use HTTP.
it('persists a role change and emits an audit event', () => {
const subjectId = 'user-role-42'
cy.request('POST', '/test/fixtures/users', {
id: subjectId,
role: 'viewer',
})
cy.visit(`/admin/users/${subjectId}`)
cy.findByLabelText('Role').select('editor')
cy.findByRole('button', { name: 'Save changes' }).click()
cy.findByRole('status').should('contain.text', 'Saved')
cy.request(`/api/users/${subjectId}`)
.its('body.role')
.should('eq', 'editor')
waitForEvent(subjectId, 'role.changed').should('deep.include', {
from: 'viewer',
to: 'editor',
})
})
The visible assertion detects broken user feedback. The API assertion detects an optimistic UI that never committed. The audit assertion detects a missing downstream obligation.
Poll with a budget
The audit event may be produced by a queue worker. Checking once immediately creates a race; waiting an arbitrary five seconds makes every run slow.
Use bounded polling:
function waitForEvent(
subjectId: string,
action: string,
attemptsLeft = 8,
): Cypress.Chainable<Record<string, unknown>> {
if (attemptsLeft === 0) {
throw new Error(`No ${action} event found for ${subjectId}`)
}
return cy
.request(`/test/audit-events?subjectId=${subjectId}`)
.then(({ body }) => {
const event = body.events.find(
(candidate: { action: string }) => candidate.action === action,
)
if (event) return event
return cy
.wait(500)
.then(() => waitForEvent(subjectId, action, attemptsLeft - 1))
})
}
This example has a four-second observation window. Choose the real value from the workflow's latency expectation. If the product promises that the audit event is available within two seconds, a test that waits thirty seconds hides a regression.
Give every test a correlation key
Parallel tests can create similar events. Querying for “the latest role.changed event” may find another test's data and pass incorrectly.
The fixture should carry a unique subject or correlation value:
const correlationId = `cy-${Cypress.spec.name}-${Date.now()}`
cy.request('POST', '/test/fixtures/users', {
id: subjectId,
role: 'viewer',
correlationId,
})
Then query by both subject and correlation ID. Clean up the fixture afterward or let an isolated ephemeral environment reset it.
Keep the worker visible in Docker
For local and CI reproducibility, run the React application and asynchronous worker as separate services instead of hiding both in one process:
services:
app:
build: .
command: npm run start:test
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
audit-worker:
build: .
command: npm run audit-worker:test
cypress:
image: "${CYPRESS_IMAGE:?pin a full tag or digest}"
environment:
CYPRESS_baseUrl: http://app:3000
depends_on:
app:
condition: service_healthy
Pin CYPRESS_IMAGE to an approved full Cypress image tag or digest. A moving browser or Node version makes before/after comparisons ambiguous. Also remember that ordinary depends_on controls start order, not readiness; use a health check or an explicit wait strategy before Cypress begins.
Observe facts, not implementation accidents
The inspection boundary should express the product promise without coupling the test to a worker's internal table layout.
| Fragile observation | More durable observation |
|---|---|
| Query a queue's private row schema | Ask for the event by correlation ID and type |
| Assert an exact worker timestamp | Assert it falls inside the agreed window |
| Read an internal retry counter | Assert one externally visible consequence |
| Find the latest event globally | Match test-owned identifiers |
This is still integration testing, so some coupling is intentional. The goal is to couple to the contract—“a role change produces this audit fact”—rather than to incidental storage details.
Keep inspection endpoints narrow
A /test/audit-events endpoint is useful only when it is intentionally designed:
- unavailable in the public production surface;
- protected by a scoped test identity;
- returns only the fields required for verification;
- filters by test-owned identifiers;
- has retention and cleanup behavior;
- never exposes unrelated customer data.
An alternative is a cy.task() that queries a test database or message sink from the Node process. The principle is the same: observe the consequence through an authorized, controlled boundary.
What this does not test
Finding an audit row does not prove that every eventual consumer processed it. Finding a queued email does not prove that an external provider delivered it. Choose the observation point that matches the promise you want to protect.
For a high-risk action, one test may need two side-effect boundaries: for example, the outbox event was committed and the downstream sandbox provider accepted it. Keep those assertions in a small representative suite; running every downstream integration for every UI scenario would be slow and expensive.
This pattern also does not replace service-level tests. Its purpose is to connect one representative browser action to the system state and side effect that give the action meaning.
The next time a test stops at Saved, ask which other witness could disagree.
Top comments (3)
The bounded poll proves presence, and the three-surface split is the right shape. Two things I would add after reading the code.
First, polling cannot prove absence. For the case where a role change must not emit an event (a rejected save, a no-op update), eight polls at 500 ms finding nothing only says nothing arrived in four seconds. Either assert on the sink's own count before and after for that correlation ID, or have the worker write an explicit "processed, no event" marker so you wait on something positive.
Second, the correlation ID is
cy-${spec}-${Date.now()}, which repeats if Cypress retries a test within the same millisecond-granularity window or two specs share a name across projects. Cypress'sCypress.currentRetryplus a random suffix removes that. Alsocy.requestitself takes time, so the real window is longer than 8 x 500 ms; if the product promises two seconds, measure from the click using timestamps in the audit event and assert the difference, rather than relying on the attempt count.Does the audit event carry the actor as well as the subject? A role change logged without who made it passes this test and still fails an audit.
The three-layer postcondition model makes the distinction between an optimistic UI and a completed workflow very clear. The correlation key is the part that keeps this reliable under parallel CI: making it visible in the audit-event query as well as the fixture prevents a fresh event from another test from satisfying the assertion by accident.
tr.ee/dev-to
Some comments have been hidden by the post's author - find out more