DEV Community

Cover image for The UI Said 'Saved.' Did the Audit Log Agree?
Raju Dandigam
Raju Dandigam

Posted on

The UI Said 'Saved.' Did the Audit Log Agree?

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',
  })
})
Enter fullscreen mode Exit fullscreen mode

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))
    })
}
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Reference

Top comments (10)

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗵𝗻 •

Great article, Raju! The idea that a green “Saved” message is only one witness really stood out to me. Verifying the durable state and the expected side effect makes the test far more meaningful, especially when asynchronous workers are involved. I also liked the practical points about bounded polling and correlation IDs to avoid flaky or falsely passing tests. As an AgentInspect contributor, it’s great to see the same evidence-first thinking applied to UI testing here.

Collapse
 
raju_dandigam profile image
Raju Dandigam •

@johnnylemonny Thanks, John. The "one witness" framing is the key: browser state, durable API state, and the side-effect sink should be independently observable. The agent-tracing analogue is distinguishing a proposed tool call from an executed call and a verified external effect. Which audit boundary has been hardest to make reliable in your work?

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗵𝗻 •

Definitely the boundary between executed tool call and verified external effect, especially when third-party APIs or async background jobs sit on the other side.

Getting a 200 OK or "queued" receipt tells you the request crossed the wire, not that the downstream state actually mutated as intended. In agentic workflows (and distributed systems in general), the hardest part has been enforcing an explicit "read-after-write" verification probe against the actual sink rather than trusting the tool's immediate return payload as ground truth.

Thread Thread
 
raju_dandigam profile image
Raju Dandigam •

@johnnylemonny Exactly. I'd model queued as accepted, not completed, and correlate a bounded read-after-write probe with the original command. If the probe expires, record outcome_unknown so retry logic cannot quietly duplicate the external effect. That distinction is valuable in agent traces as well as Cypress tests.

Collapse
 
arhancanli profile image
Arhan Canli •

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's Cypress.currentRetry plus a random suffix removes that. Also cy.request itself 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.

Collapse
 
raju_dandigam profile image
Raju Dandigam •

@arhancanli You're right on both points. The bounded poll proves presence, not absence, and Date.now() alone is too weak under retries; I'd add Cypress.currentRetry() plus a random suffix. The audit event should carry actor ID as well as subject ID, action, and correlation ID, and the test should assert them together. For a no-op, I'd wait for an explicit processed marker rather than infer success from silence.

Collapse
 
alexshev profile image
Alex Shev •

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.

Collapse
 
raju_dandigam profile image
Raju Dandigam •

@alexshev Exactly—the correlation key has to survive into the sink query, not just the setup fixture. Otherwise parallel CI can hand the test someone else's role.changed event and give a false green. I'd also assert the event's actor and before/after roles so a correctly correlated but incomplete audit row cannot pass.

Collapse
 
runfoundry profile image
Foundry •

The third row is the one that burned us, in a different shape. We run an agent that posts video from real iPhones, and the app's own "posted" screen was our green toast. An audit against the public profile pages found 159 of 165 planned posts actually live, and every one of the six misses was a run that had reported success.

Two things from that which map onto your table. First, the sink has to be read by someone who is not the actor. We had comments on a forum that the posting account could load as live while everyone else saw them as removed, so our postcondition now runs from a browser with no session. Second, "not found within the budget" wants its own result, separate from fail. We keep "unknown" as a third state, because retrying an action whose first attempt may have landed is how you get duplicates.

Do you treat the audit timeout as a hard fail, or re-query later before deciding?

Collapse
 
raju_dandigam profile image
Raju Dandigam •

@runfoundry Your unauthenticated-browser check is a strong fourth witness, especially when the actor's session can see content others cannot. In the example, timeout fails the test, but I would not auto-retry the business action from that failure; operationally I'd mark the outcome unknown, re-query the public sink later with the same correlation key, and retry only once absence is established. How do you bound that unknown window before escalating?

Some comments may only be visible to logged-in visitors. Sign in to view all comments. Some comments have been hidden by the post's author - find out more