Blog
How to test Next.js architecture, failures and user workflows
Reliable Next.js testing is not limited to component snapshots and successful API responses. A production test strategy must protect architectural boundaries, failure handling, server and client behavior, and the complete workflows users depend on.
A passing component test does not prove that the application works
A Next.js application may have hundreds of passing unit tests and still fail during the workflow that matters to a customer.
A button can render correctly while:
- the server returns data in an unexpected shape,
- the session expires during submission,
- a route throws while rendering,
- cached data remains stale after a mutation,
- a request is sent twice,
- a redirect loses the original destination,
- an error boundary hides useful recovery actions,
- a browser-only API fails during server rendering,
- a loading state never completes,
- an optimistic update displays data that was not saved.
The test strategy must therefore protect more than components.
A production Next.js application should be tested across three connected dimensions:
Architecture
→ Are responsibilities and dependency directions correct?
Failures
→ Does the application behave safely when dependencies fail?
User workflows
→ Can a user complete an important goal through the real interface?Testing these dimensions separately is useful.
Testing how they interact is what protects the application.
Start with the behavior the business depends on
Teams often begin by asking:
Which percentage of test coverage should we reach?A better question is:
Which failures would prevent a user from completing an important task?Consider an online ordering workflow:
Open product
→ add item to basket
→ open checkout
→ enter delivery details
→ submit order
→ authorize payment
→ display confirmationThe business does not care whether each React component has an isolated snapshot.
It cares whether:
- the correct product reaches the basket,
- prices cannot be changed by the browser,
- the order is not submitted twice,
- payment failure is recoverable,
- an expired session does not lose all entered data,
- a successful payment produces one order,
- confirmation displays the correct order reference.
The workflow should determine the test strategy.
A useful Next.js testing map
A practical test architecture can look like this:
Static checks
│
├── TypeScript
├── linting
└── import and architecture rules
│
▼
Domain and application tests
│
├── business calculations
├── state transitions
└── use-case orchestration
│
▼
Component tests
│
├── interaction
├── validation
├── loading
└── recoverable errors
│
▼
Contract and integration tests
│
├── backend responses
├── authentication
├── route handlers
└── cache invalidation
│
▼
Browser workflow tests
│
├── navigation
├── complete forms
├── redirects
└── critical journeys
│
▼
Production verification
├── logs
├── metrics
├── traces
└── synthetic checksEach layer answers a different question.
Using only browser tests makes failures slow to diagnose.
Using only unit tests leaves integration defects unprotected.
A CI pipeline should run fast deterministic checks on every change, isolate browser-test data, and use slower cross-service workflows as risk-based release gates rather than optional diagnostics.
Test architecture before it becomes difficult to change
Architecture tests verify where code is allowed to depend.
A maintainable Next.js project may use boundaries similar to:
app
→ route composition and framework integration
features
→ user-facing use cases
domain
→ business types and rules
application
→ orchestration and ports
infrastructure
→ API clients, storage and provider adapters
ui
→ reusable presentation componentsThe exact folders are less important than dependency direction.
A feature should not require a React component to know:
- raw database structures,
- provider SDK response objects,
- authentication cookie internals,
- payment-provider error codes,
- cache implementation details.
A conceptual dependency rule is:
UI
→ application contract
→ domain contract
← infrastructure adapterInfrastructure implements the contract.
The application layer does not depend on the provider.
Example: isolate the checkout dependency
Instead of calling an external API directly from a component:
export function CheckoutForm(): JSX.Element {
async function submit(): Promise {
await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify(formValues),
});
}
return {/* ... */};
}define a project-owned contract:
export type CheckoutInput = Readonly;
export type CheckoutResult =
| Readonly
| Readonly
| Readonly;
export interface CheckoutGateway {
submit(input: CheckoutInput): Promise;
}An infrastructure adapter owns the HTTP details:
export class HttpCheckoutGateway implements CheckoutGateway {
public constructor(
private readonly baseUrl: string,
private readonly fetcher: typeof fetch,
) {}
public async submit(
input: CheckoutInput,
): Promise {
const response = await this.fetcher(
`${this.baseUrl}/orders`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
'idempotency-key': input.idempotencyKey,
},
body: JSON.stringify(input),
},
);
if (!response.ok) {
throw await mapCheckoutFailure(response);
}
return validateCheckoutResult(await response.json());
}
}Tests can now replace the gateway without replacing browser networking globally.
The adapter can have focused contract tests.
The component can have behavior tests.
The complete workflow can still be covered by a browser test.
Architecture tests should enforce actual boundaries
Depending on the project’s existing tooling, architecture rules can be checked through:
- lint import restrictions,
- dependency analysis,
- TypeScript project references,
- package boundaries,
- focused source-code tests.
Useful rules include:
Domain code must not import React or Next.js.
Application use cases must not import provider SDKs.
UI components must not import infrastructure implementations directly.
Feature modules must not access another feature’s internal files.
Server-only modules must not be imported into client bundles.
Route files should compose dependencies rather than contain complete business workflows.Do not create rules merely because they sound clean.
Every rule should prevent an actual class of defect.
Test observable behavior, not internal implementation
A fragile component test asserts:
The component called setState three times.A useful test asserts:
The submit button becomes unavailable while the request is running.
A failed request displays a recoverable error.
The user’s entered values remain present.
A successful result navigates to the confirmation page.Tests should survive internal refactoring when user-visible behavior remains unchanged.
Avoid asserting:
- private helper calls,
- hook implementation details,
- exact component tree depth,
- complete HTML snapshots for complex screens,
- framework-generated class names.
Assert outcomes and stable accessibility roles.
Test server and client responsibilities separately
Next.js applications frequently combine server-rendered data with client interactions.
A useful separation is:
Server responsibility
→ authentication
→ initial data loading
→ authorization
→ stable page composition
Client responsibility
→ immediate interaction
→ local validation
→ pending state
→ retry
→ progressive updatesThe server test should verify that unauthorized users do not receive protected data.
The component test should verify that a user sees a useful state when a client-side mutation fails.
The browser test should verify the complete behavior.
Do not force every condition through an expensive browser test.
Failure cases must be part of the design
Most tests begin with successful responses:
gateway.submit.mockResolvedValue({
status: 'completed',
orderId: 'order-123',
confirmationNumber: 'ORD-2026-123',
});Production incidents usually begin elsewhere:
The API timed out.
The user clicked twice.
The response was valid JSON but had an invalid shape.
The session expired.
The basket changed.
The payment provider accepted payment but the redirect failed.
The cache showed old data.A workflow should have an explicit failure matrix.
Example checkout failure matrix
Failure Expected application behavior
────────────────────────────────────────────────────────────────────
Product request fails Show retry without crashing the route
Basket is empty Redirect or display a clear empty state
Basket changed on server Refresh basket and request confirmation
Session expired Ask user to sign in and preserve return path
Checkout request times out Show unknown-state message; do not resubmit blindly
Payment is rejected Keep order context and allow another method
Response schema is invalid Show safe failure; report contract violation
Duplicate submit Reuse idempotency key and prevent duplicate order
Confirmation request fails Allow confirmation recovery by order reference
Stale cached basket Invalidate or refresh after accepted mutationEvery important row should be covered at the cheapest reliable test layer.
Test loading as a state, not as a screenshot
Loading behavior should answer:
- What is still usable?
- What is disabled?
- Can the user navigate away?
- Does layout remain stable?
- What happens after a slow response?
- Is there a timeout or retry path?
- Does the screen announce progress accessibly?
A component test may verify:
it('prevents duplicate checkout submission while pending', async () => {
const deferred = createDeferred();
const submit = vi.fn(() => deferred.promise);
render();
await userEvent.click(
screen.getByRole('button', { name: /place order/i }),
);
expect(
screen.getByRole('button', { name: /place order/i }),
).toBeDisabled();
await userEvent.click(
screen.getByRole('button', { name: /place order/i }),
);
expect(submit).toHaveBeenCalledTimes(1);
deferred.resolve({
status: 'completed',
orderId: 'order-123',
confirmationNumber: 'ORD-123',
});
});Adapt the syntax to the project’s current test runner.
The example uses React Testing Library with Vitest-style mocks; a project using Jest should preserve the same behavioral assertions rather than changing strategy only for the runner.
The important assertion is that a slow response does not create a duplicate submission.
Test recoverable failures explicitly
A failure message is not sufficient when the user cannot recover.
Example:
it('keeps entered delivery data after a recoverable failure', async () => {
const submit = vi
.fn()
.mockRejectedValueOnce(new NetworkUnavailable())
.mockResolvedValueOnce({
status: 'completed',
orderId: 'order-123',
confirmationNumber: 'ORD-123',
});
render();
await userEvent.type(
screen.getByLabelText(/delivery note/i),
'Leave with reception',
);
await userEvent.click(
screen.getByRole('button', { name: /place order/i }),
);
expect(
await screen.findByRole('alert'),
).toHaveTextContent(/could not submit/i);
expect(
screen.getByLabelText(/delivery note/i),
).toHaveValue('Leave with reception');
await userEvent.click(
screen.getByRole('button', { name: /try again/i }),
);
expect(submit).toHaveBeenCalledTimes(2);
});This protects the user’s effort, not only the error message.
Distinguish rejected business results from technical failures
These outcomes are not equivalent:
Payment provider unavailable
Basket contains an unavailable item
User has no permission
Backend returned malformed dataUse explicit result types and error categories.
A business rejection can be rendered as a normal workflow result:
if (result.status === 'rejected') {
return showCheckoutCorrection(result.reason);
}A technical failure may require:
- retry,
- logging,
- incident metrics,
- fallback behavior.
Do not convert every failure into a generic Something went wrong.
Generic messages may be appropriate for security, but internal classification should remain precise.
Validate backend responses at the boundary
TypeScript types do not validate runtime JSON.
This code is unsafe:
const result = await response.json() as CheckoutResult;The assertion only instructs the compiler.
It does not verify the response.
Use the project’s existing runtime validation approach:
const payload: unknown = await response.json();
return checkoutResultSchema.parse(payload);or an equivalent project-owned validator.
Test:
- missing properties,
- incorrect enum values,
- incorrect numbers,
- unexpected nullable values,
- unsupported additional data when relevant,
- incompatible backend versions.
A contract violation should fail close to the adapter rather than causing an unrelated component error later.
Test route-level failures
A route may fail before the interactive component is rendered.
Examples include:
- authentication lookup failure,
- unavailable backend service,
- invalid route parameter,
- missing record,
- authorization rejection,
- data loader timeout,
- unexpected response schema.
Test the route behavior using the project’s existing approach.
Verify the intended result:
Missing record
→ not-found experience
Unauthorized user
→ login or access-denied behavior
Temporary backend failure
→ recoverable route error
Invalid route parameter
→ controlled response, not an unhandled exceptionUse the framework’s current error, loading and not-found conventions already present in the project.
Do not create custom error infrastructure when the application already has an established pattern.
Error boundaries should support recovery
An error boundary should not merely display:
Something went wrong.It should answer:
- What failed?
- Is the failure temporary?
- Can the operation be retried?
- Is navigation still available?
- Was user input preserved?
- Does retry create a duplicate action?
- Is the failure reported safely?
A useful route-level test verifies:
Initial request throws
→ error UI appears
→ user activates retry
→ second request succeeds
→ expected page content appearsDo not assert internal framework reset functions when the same behavior can be verified through the interface.
Authentication must be tested during the workflow
Testing only the initial protected-page redirect is insufficient.
Sessions can expire while the user is:
- editing a long form,
- waiting for payment,
- uploading a file,
- completing several onboarding steps,
- keeping the application open in another tab.
A realistic test should cover:
User opens authenticated checkout
→ enters data
→ session expires
→ submission returns unauthorized
→ user is asked to authenticate
→ application preserves a safe return destination
→ workflow can continue without duplicate submissionDo not preserve secrets or unsafe request bodies in redirect URLs.
The correct recovery strategy depends on the sensitivity of the data.
Authorization tests belong on the server boundary
Hiding a button is not authorization.
Test that unauthorized requests are rejected even when the request is constructed manually.
For example:
User from tenant A requests order from tenant B
→ server rejects access
→ protected data is not included in response
→ event is logged according to project policyThe browser test may verify the visible behavior.
A lower-level integration test should verify the actual authorization boundary.
Test navigation and redirects
Redirect defects commonly appear when:
- locale prefixes are lost,
- query parameters disappear,
- callback URLs are encoded incorrectly,
- an authentication redirect loops,
- a successful mutation redirects to stale data,
- browser history behaves unexpectedly.
For critical workflows, verify:
- destination URL,
- preserved locale,
- preserved safe return path,
- behavior of Back,
- direct access to the confirmation route,
- refresh after success.
Do not test every link through a browser.
Focus browser tests on workflow transitions.
Cache behavior requires dedicated tests
Caching can make a correct mutation appear broken.
Example:
User updates delivery address
→ backend saves it
→ application redirects to address page
→ cached page displays the previous addressA test should verify the externally visible contract:
After an accepted mutation, the next relevant read displays the new value.Depending on the project architecture, this may require:
- cache invalidation,
- tag invalidation,
- path or route revalidation,
- cache-key versioning,
- bypassing cache for user-specific data.
Use the cache mechanism actually configured in the project.
Do not test framework internals.
Test that stale data is not shown where freshness is required.
Avoid global cache assumptions
The same data can have different freshness requirements:
Public product description
→ may tolerate short staleness
Available stock during checkout
→ requires current server validation
User profile preference
→ should normally reflect an accepted update immediately
Final payment status
→ must be confirmed by the authoritative backendTests should reflect business requirements rather than a universal “cache is good” or “cache is bad” rule.
Test optimistic updates against rejection
Optimistic interfaces improve responsiveness, but they create additional states.
Example:
User changes item quantity from 1 to 3
→ UI displays 3 immediately
→ server reports only 2 are availableExpected behavior must be defined:
Revert to 1
or
show 2 as accepted maximum
or
ask the user to confirm the adjusted basketTest:
- optimistic state,
- accepted response,
- rejected response,
- rollback,
- error announcement,
- interaction during pending state.
Do not leave rollback behavior implicit.
Browser tests should cover workflows, not implementation details
A browser test, for example with Playwright, should describe the user’s goal:
test('customer can complete checkout after correcting changed stock', async ({
page,
}) => {
await page.goto('/en/products/example-product');
await page.getByRole('button', {
name: /add to basket/i,
}).click();
await page.getByRole('link', {
name: /checkout/i,
}).click();
await page.getByLabel(/delivery address/i)
.selectOption('address-1');
await page.getByRole('button', {
name: /place order/i,
}).click();
await expect(
page.getByRole('alert'),
).toContainText(/quantity has changed/i);
await page.getByRole('button', {
name: /accept changes/i,
}).click();
await expect(page).toHaveURL(/\/orders\/[^/]+\/confirmation/);
await expect(
page.getByRole('heading', {
name: /order confirmed/i,
}),
).toBeVisible();
});Adapt routes, selectors and syntax to the actual application.
Prefer accessible selectors over CSS implementation details.
Control backend state deliberately
Browser tests become unreliable when they depend on uncontrolled shared data.
Use an established test-data approach:
- API test setup,
- deterministic fixtures,
- isolated test database,
- dedicated factories,
- approved database reset strategy.
The test should know:
- product stock,
- user permissions,
- basket state,
- backend failure mode,
- expected order result.
Do not depend on production-like accounts shared by several tests.
Do not run destructive setup against a real environment.
Simulate failures at controlled boundaries
Useful failure-injection points include:
API adapter
Authentication provider
Payment gateway
Data loader
File upload service
Feature-flag provider
Queue status endpointAvoid broad interception that accidentally mocks unrelated requests.
A test should make the intended failure obvious:
await checkoutApi.respondOnce({
status: 409,
body: {
code: 'basket_changed',
},
});Then assert the corresponding recovery behavior.
Do not mock the complete application
Over-mocking creates tests that confirm the mocks agree with the implementation.
A component test may replace its gateway.
A route integration test should use more real routing and serialization behavior.
A browser workflow test should exercise the real frontend and a controlled backend environment.
Select the smallest boundary that still proves the intended risk.
Use contract tests between Next.js and the backend
When Next.js consumes a Symfony, Laravel or another backend API, both sides must agree on:
- HTTP status,
- authentication,
- request shape,
- response shape,
- error codes,
- nullable values,
- date formats,
- pagination,
- idempotency,
- versioning.
Contract testing can be based on:
- an API schema,
- shared fixtures,
- captured approved examples,
- provider and consumer tests,
- direct integration tests.
A useful test verifies more than a 200 response:
it('maps a completed checkout response', async () => {
const fetcher = createFetchStub({
status: 201,
json: {
status: 'completed',
orderId: 'order-123',
confirmationNumber: 'ORD-123',
},
});
const gateway = new HttpCheckoutGateway(
'https://backend.test',
fetcher,
);
await expect(
gateway.submit({
basketId: 'basket-1',
deliveryAddressId: 'address-1',
idempotencyKey: 'checkout-attempt-1',
}),
).resolves.toEqual({
status: 'completed',
orderId: 'order-123',
confirmationNumber: 'ORD-123',
});
});Also test the provider’s real error shapes.
Duplicate submissions require idempotency
Disabling a button reduces accidental duplicates but does not provide complete protection.
Duplicates can still come from:
- browser retries,
- network proxies,
- user refresh,
- multiple tabs,
- repeated queue delivery,
- delayed responses.
For consequential mutations, the backend should support an idempotency key or equivalent business constraint.
The frontend test should verify:
One user attempt
→ one stable idempotency key
→ retries reuse the same key
→ a new intentional attempt receives a new keyThe backend remains responsible for enforcing idempotency.
Test race conditions deliberately
A common race occurs when the user changes selection while an earlier request remains pending.
Example:
User searches for “symfony”
→ request A starts
→ user changes query to “nextjs”
→ request B starts
→ request B completes
→ request A completes laterThe page must not replace the newer result with stale data.
Test out-of-order completion:
it('does not display an older search response', async () => {
const first = createDeferred();
const second = createDeferred();
const search = vi
.fn()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
render();
await userEvent.type(
screen.getByRole('searchbox'),
'symfony',
);
await userEvent.clear(
screen.getByRole('searchbox'),
);
await userEvent.type(
screen.getByRole('searchbox'),
'nextjs',
);
second.resolve(resultFor('nextjs'));
expect(
await screen.findByText('Next.js result'),
).toBeVisible();
first.resolve(resultFor('symfony'));
expect(
screen.queryByText('Symfony result'),
).not.toBeInTheDocument();
});Use cancellation, request identity or state management appropriate to the implementation.
Hydration and environment differences need attention
Code can work during client navigation but fail on a direct page request.
Common causes include:
- accessing
windowduring server execution, - using current time differently on server and client,
- locale formatting differences,
- random values during rendering,
- browser storage access before mounting,
- server and client feature flags disagreeing.
Protect important pages with tests that include:
Direct navigation
Client-side navigation
Refresh
Back and Forward
Different locale
Authenticated and anonymous stateDo not suppress hydration warnings without finding the mismatch.
Test empty, partial and unexpected data
Production data is rarely as complete as fixtures suggest.
Test:
- empty lists,
- missing optional images,
- deleted related records,
- nullable profile fields,
- unsupported locale,
- partial permissions,
- unknown enum values,
- older backend records,
- very long text,
- zero and maximum values.
A page should not crash because one optional relation is missing.
At the same time, required business data should not silently become optional merely to satisfy a test.
Accessibility belongs in workflow testing
A workflow is incomplete when it works only with a mouse.
Component and browser tests should verify relevant behavior for:
- accessible names,
- form labels,
- focus movement,
- keyboard activation,
- validation announcements,
- error summaries,
- modal focus,
- loading announcements.
For a failed form submission, verify that the user can locate the error and continue.
Do not rely only on visual screenshots.
Visual tests are useful but limited
Visual regression testing can protect:
- major layout changes,
- missing responsive sections,
- broken loading skeletons,
- overflow,
- incorrect themes.
It does not prove:
- authorization,
- correct API data,
- idempotency,
- successful submission,
- accessible naming,
- retry behavior.
Use visual tests as one layer, not as a replacement for behavioral assertions.
Test responsive workflow behavior
A checkout that works on desktop may fail on mobile because:
- the submit button is hidden below a fixed element,
- the keyboard covers form actions,
- an error summary is outside the viewport,
- a dialog cannot scroll,
- address selection requires hover,
- the navigation closes unexpectedly.
Use a small number of representative viewport tests for critical workflows.
Do not duplicate every browser test across many dimensions.
Select combinations based on risk.
Keep browser tests focused and independent
A browser test should not depend on a previous browser test creating data.
Prefer:
Arrange controlled state
→ execute one workflow
→ assert its result
→ clean up through established test infrastructureAvoid:
Test 1 creates account
→ Test 2 expects that account
→ Test 3 modifies the same accountShared sequential workflows make failures difficult to diagnose.
Protect the test suite from false confidence
A test can pass for the wrong reason.
Examples:
- request interception pattern never matched,
- assertion checked a stale element,
- test used cached authentication unexpectedly,
- form submission did not occur,
- browser test skipped because of environment configuration,
- error was hidden by a broad exception handler,
- test accepted any status below 500.
Use assertions that prove the important action occurred.
For example:
Verify request payload
Verify resulting route
Verify stored backend state
Verify visible confirmationNot every test needs all four, but critical workflows should include more than one signal.
Production behavior completes the testing strategy
Pre-release tests cannot reproduce every combination of:
- browser,
- network,
- data volume,
- provider failure,
- deployment timing,
- cache state,
- session duration.
Production verification should include:
- route error rate,
- failed API calls,
- workflow completion rate,
- checkout abandonment points,
- authentication redirect loops,
- client-side exceptions,
- backend contract violations,
- latency percentiles,
- cache effectiveness,
- duplicate-action detection.
A useful metric is not only:
Checkout API returned 201.It is:
User opened checkout
→ submitted order
→ reached confirmed order stateMeasure business workflow completion where privacy and monitoring policy allow it.
Add correlation identifiers
A workflow can cross:
Browser
→ Next.js server
→ backend API
→ payment provider
→ message queueWithout a correlation identifier, one failure appears as several unrelated logs.
Use an established request or workflow identifier that can be propagated safely.
Do not expose sensitive internal identifiers unnecessarily.
Tests can verify that the identifier is:
- created or accepted at the correct boundary,
- sent to the backend,
- included in safe logs,
- returned where appropriate,
- not regenerated unpredictably during one workflow.
Use a risk-based test matrix
Not every page deserves the same depth.
Classify workflows:
Critical
→ payment, authentication, permissions, irreversible changes
Important
→ content publishing, account settings, document upload
Supporting
→ filtering, optional preferences, informational pagesA critical workflow may require:
- unit tests,
- component failure tests,
- adapter contract tests,
- authorization tests,
- browser success test,
- browser recovery test,
- production monitoring.
A supporting feature may need focused component and integration coverage only.
The objective is not identical coverage.
The objective is proportional protection.
A practical implementation sequence
Phase 1: Map the workflow
Document:
- starting point,
- user goal,
- server dependencies,
- client interactions,
- final authoritative state.
Phase 2: Create the failure matrix
List:
- validation failures,
- authorization failures,
- dependency failures,
- timeout states,
- duplicate actions,
- stale data,
- partial success.
Define expected recovery for each.
Phase 3: Confirm architectural boundaries
Identify:
- application contracts,
- infrastructure adapters,
- server-only modules,
- client components,
- runtime response validation.
Add enforceable import rules where the project already supports them.
Phase 4: Add lower-level tests
Test:
- domain rules,
- use-case result types,
- response mapping,
- failure classification,
- idempotency-key behavior.
Phase 5: Add component behavior tests
Test:
- validation,
- pending state,
- duplicate prevention,
- recoverable errors,
- retained user input,
- optimistic rollback.
Phase 6: Add integration tests
Test:
- authentication,
- authorization,
- route responses,
- API contracts,
- cache invalidation,
- redirects.
Phase 7: Add critical browser workflows
Cover:
- one successful journey,
- one important recovery journey,
- one authorization or session-expiry case where risk justifies it.
Phase 8: Add production signals
Measure:
- workflow completion,
- technical failure rate,
- recovery success,
- latency,
- contract violations,
- duplicate actions.
What code review should focus on
When reviewing a Next.js change, ask:
- Is server-only data imported by client code?
- Does the component call infrastructure directly?
- Is runtime API data validated?
- Are authorization checks enforced on the server?
- What happens when the dependency times out?
- Can the user retry safely?
- Can the mutation be submitted twice?
- Is the idempotency key stable across retries?
- Is accepted data invalidated or refreshed?
- Can stale requests overwrite newer state?
- Does direct navigation behave like client navigation?
- Are loading, empty and error states accessible?
- Does the test protect behavior or implementation details?
- Is the complete important workflow covered somewhere?
- Will production monitoring reveal a workflow failure?
Rules for AI-assisted development
An AI coding assistant should not be asked only:
Add tests for this page.Give it explicit responsibilities:
Inspect the page, application contract, API adapter and existing tests.
Identify:
- success states,
- loading states,
- business rejections,
- technical failures,
- authentication changes,
- duplicate submission risks,
- cache invalidation,
- complete user workflow.
Use the existing test runner and browser framework.
Do not add packages.
Do not replace project-owned interfaces with direct fetch calls.
Do not assert private implementation details.
Add the smallest test layer that proves each risk.Before accepting AI-generated tests, verify that they can fail for the intended defect.
A test that cannot fail when the behavior is broken is documentation, not protection.
Senior engineering conclusion
Testing a Next.js application is not a competition to create the largest number of tests.
The purpose is to create evidence that the architecture remains understandable, failures remain controlled and users can complete important work.
A mature strategy normally protects:
- dependency direction,
- server and client responsibilities,
- runtime API contracts,
- authentication and authorization,
- loading and error recovery,
- idempotent mutations,
- caching and freshness,
- race conditions,
- critical browser journeys,
- production workflow completion.
The strongest test suite does not attempt to execute every possible combination through the browser.
It places each risk at the lowest reliable layer and connects those layers through a small number of realistic user workflows.
That is what makes the application safe to refactor.
It also makes AI-assisted development safer. An AI tool can generate components, adapters and tests quickly, but the project must already define the boundaries, failure expectations and workflows that generated code is required to preserve.
A successful request is not the same as a successful user workflow.
A rendered page is not the same as a protected architecture.
A passing test suite is valuable only when it fails for the problems the business actually needs to prevent.
