A React support console displays Refund approved. The Cypress test sees the banner and passes.The customer still has no refund.The failure is not necessarily in the assertion. The assertion observed exactly what it was written to observe: the browser rendered a successful state. The application, however, spans more than the browser. A write may fail after an optimistic update. A queue may accept a message that no worker processes. An audit event may never be emitted.For workflows with real consequences, I find it useful to treat the system as three independent witnesses.UI: Question it answers: What did the user see?; Typical assertion: Banner, route, enabled stateDurable state: Question it answers: What did the system commit?; Typical assertion: REST or GraphQL responseSide effect: Question it answers: What happened afterward?; Typical assertion: Audit entry, queued notification, webhookAgreement among all three is stronger evidence than any one surface alone.Spend browser time only on the behavior under testSuppose the behavior is approving a refund. Logging in, creating an order, and moving it into an eligible state are prerequisites; they are not the behavior being evaluated. Repeating those operations through the UI makes the test longer and adds unrelated failure points.Cypress's cy.request() lets the same spec establish those prerequisites over HTTP. Its cookie jar is synchronized with the browser, so an API login can carry into the following cy.visit().describe('refund approval', () => { beforeEach(() => { cy.env(['supportUser', 'supportPassword']).then( ({ supportUser, supportPassword }) => { cy.session( ['support-user', supportUser], () => { cy.request('POST', '/api/auth/login', { username: supportUser, password: supportPassword, }) }, { cacheAcrossSpecs: true, validate: () => { cy.request('/api/auth/me').its('status').should('eq', 200) }, }, ) }, ) cy.request('POST', '/test/fixtures/orders', { id: 'order-refund-17', total: 84.5, status: 'refund_eligible', }) }) it('commits the refund and records its audit event', () => { cy.visit('/support/orders/order-refund-17') cy.findByRole('button', { name: 'Approve refund' }).click() cy.findByRole('status').should('contain.text', 'Refund approved') cy.request('/api/orders/order-refund-17/refund') .its('body') .should('include', { status: 'approved', amount: 84.5 }) waitForAuditEvent('order-refund-17').should('include', { action: 'refund.approved', actorType: 'support_user', }) }) }) The test is still end-to-end. It simply uses the cheapest trustworthy surface for each responsibility:API for setup.Browser for the user-critical decision.API for durable and downstream results.Poll asynchronous work, but put a boundary around itAudit entries and notifications may be created by a worker after the request returns. A single immediate assertion is therefore a race. An unbounded recursive retry is worse: it can conceal a stalled system until the CI job times out.Use an explicit attempt or time budget and preserve the last observed state in the error:function waitForAuditEvent(subjectId: string, attemptsLeft = 8) { if (attemptsLeft === 0) { throw new Error(`No refund.approved audit event for ${subjectId}`) } return cy .request(`/test/audit-events?subjectId=${subjectId}`) .then(({ body }) => { const match = body.events.find( (event: { action: string }) => event.action === 'refund.approved', ) if (match) return match return cy .wait(500) .then(() => waitForAuditEvent(subjectId, attemptsLeft - 1)) }) } Eight attempts at 500 milliseconds is a four-second observation window. That number should come from the service-level expectation of the workflow, not from whatever value happens to make a flaky test pass.In a real system, I also include a correlation identifier in the fixture and query. Without one, a test may accidentally find an event produced by a different parallel test.GraphQL needs two success checksIf the durable-state API is GraphQL, HTTP status alone is weak evidence. GraphQL commonly returns 200 while including execution errors in the response body.cy.request('POST', '/graphql', { query: ` query Refund($orderId: ID!) { refund(orderId: $orderId) { status amount currency } } `, variables: { orderId: 'order-refund-17' }, }).then(({ status, body }) => { expect(status).to.eq(200) expect(body).not.to.have.property('errors') expect(body.data.refund).to.deep.include({ status: 'APPROVED', amount: 84.5, currency: 'USD', }) }) This verifies the transport, the GraphQL execution result, and the business state separately.Three Cypress mechanics that mattercy.request() is convenient partly because it does not behave like application traffic in the browser.Node sends the request: Consequence: It does not appear in the browser's Network panelBrowser CORS rules do not apply: Consequence: It cannot prove that the browser is allowed to call that origincy.intercept() cannot intercept it: Consequence: Use cy.intercept() for requests made by the applicationCookies synchronize with the browser: Consequence: API login can establish the browser sessionNon-2xx/3xx fails by default: Consequence: Set failOnStatusCode: false when testing an expected errorThis distinction prevents a common testing mistake. A direct cy.request() can prove that an endpoint works, but it cannot prove that frontend code called it with the correct headers. For that, observe the application request with cy.intercept() or validate the resulting server state after the UI action.Use Docker to reproduce the system boundary, not to fake productionThis pattern is easiest to trust when the browser, API, worker, and test runner start from a repeatable environment. A small Docker Compose stack can keep those process boundaries visible: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))"] interval: 2s timeout: 2s retries: 30 worker: build: . command: npm run worker:test cypress: image: "${CYPRESS_IMAGE:?use an approved full tag or digest}" working_dir: /e2e volumes: - ./:/e2e environment: CYPRESS_baseUrl: http://app:3000 depends_on: app: condition: service_healthy Pin CYPRESS_IMAGE to a full Cypress Docker image tag or digest rather than latest or a moving short tag. That keeps Node and browser changes from entering the comparison unnoticed. The worker remains a separate service, so a missing side effect cannot be hidden by running everything in one process.Compose is still not a production replica. It gives the test a controlled topology. It also makes one frequent mistake explicit: startup order is not readiness. The health check prevents Cypress from racing an application container that exists but cannot serve requests yet.Do not turn production internals into a public test APIThe example uses /test/fixtures and /test/audit-events endpoints. That does not mean such endpoints should be publicly reachable.Reasonable options include:exposing them only in an isolated test environment;requiring a narrowly scoped test credential;implementing database setup through cy.task() in the Node process;querying a test message sink rather than a production email provider;deleting fixtures by a per-test correlation ID.The point is observability, not bypassing authorization.What this pattern does not proveThis single workflow does not replace service contract tests, queue load tests, browser security tests, or production monitoring. It also does not prove that every downstream consumer handled the event—only the consumer or sink you chose to inspect.It does provide something narrower and valuable: a reviewable claim that one representative user action produced the expected presentation, durable state, and selected side effect.That is the reusable rule:Use the browser to prove the user journey, the API to prove committed state, and a bounded observation surface to prove asynchronous consequences.When those witnesses disagree, the test has found more than a red selector. It has found a system boundary worth debugging.ReferencesAPI Testing in Cypresscy.request() APIcy.session() APIcy.env() and secret-handling guidanceCypress Docker images for CI
Building End-to-End Cypress Tests Across Browser, API, and Worker Boundaries
Full Article
Original Source
Read the full article at Hackernoon →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.