Blog
A testing strategy for Symfony and React: fast feedback, stability and maintenance costs
Choose Symfony and React tests by risk, shorten feedback loops and refactor with confidence while keeping CI and test maintenance costs under control.
Tests should help you change the application
Consider a change to an article editor: a draft may be saved without a title, but publication requires complete content. Symfony enforces the rule; React explains it to the user. Running the entire browser suite after every edit will eventually tell you whether a journey broke. It is an expensive way to discover that one condition in a policy is wrong.
The opposite shortcut is just as costly: test that condition in isolation and assume the editor, API and database must work together. A deliberate strategy gives each risk a suitable test and leaves enough coverage across boundaries to catch mistakes in how the parts connect.
The benefit is practical: shorter interruptions while coding, clearer failures during refactoring and fewer regressions reaching users. There is still a maintenance bill. Tests need data, environments and review, and their failures consume engineering time. This article is about controlling that bill while preserving useful confidence. The feature examples are hypothetical, not reports of GiSoft customer projects.
Feedback speed is an architectural decision
The useful measure is the time from making a change to understanding whether it is safe. Execution time is only part of it. Queueing for a CI runner, preparing a database and investigating a vague failure all extend the loop. When feedback arrives after a developer has moved to another task, they must reconstruct the context as well.
A changed calculation should usually get a quick answer from static analysis and focused unit tests. A repository change needs the relevant database integration tests. Browser coverage can then verify the affected journey. Running everything first spends the highest setup cost before asking the simplest questions.
Measure these costs in your own repository. Static analysis is not invariably fast, and a small integration test can be cheaper than a heavily mocked unit test. Track time to the first actionable failure as well as the complete pipeline duration. Faster checks are valuable when they detect the relevant defect, not merely because they finish quickly.
Select a test by what could break
Teams use different names for suites. A useful distinction is which real parts execute and which are replaced. Start with a concrete failure, then choose the least expensive test that can actually observe it:
Possible failure First useful test boundary
Wrong business calculation Unit: inputs and result
Incorrect Doctrine query Integration: real test database
Missing API permission Functional: request and outcome
UI loses input after error Component: user interaction
Changed response format Contract: producer and consumer
Broken complete journey Browser: selected real flow
Unavailable deployment Smoke: deployed entry pointThis is a starting point, not a fixed allocation. A permission policy may deserve extensive unit coverage plus an HTTP test proving that the route invokes it. Higher-level tests add confidence in the connections; they need not repeat every low-level variation.
Static checks belong early in that strategy. PHPStan and TypeScript catch incompatible types, unsafe nullable assumptions and broken declared interfaces without exercising a user journey. Linting and explicit import rules can catch some client/server boundary mistakes. Architecture checks can enforce an agreed dependency direction, such as keeping HTTP types out of business policies. They only detect the rules and code they actually analyse; none proves runtime business behaviour.
In Symfony, keep decisions close and infrastructure real
Unit tests are a good fit for value objects, calculations, policies, mappers and project-owned validation rules. A plain object gives a short path from input to failure. It normally needs neither the kernel nor the database.
This Pest example checks an illustrative PublishPostPolicy. Its assumed method accepts a title and body and rejects either when blank after trimming. The policy implementation and import are omitted; it is not a Symfony API or an existing project helper. The syntax fits the repository’s Pest 3/PHPUnit 11 stack:
it('requires content before publication', function (
string $title,
string $body,
bool $allowed,
): void {
$policy = new PublishPostPolicy();
expect($policy->canPublish(title: $title, body: $body))
->toBe($allowed);
})->with([
['', 'Article text', false],
['Title', '', false],
[' ', 'Article text', false],
['Title', 'Article text', true],
]);The valid case matters: a policy that refuses every publication would otherwise pass. These cases concern content readiness, not the caller’s permission to publish. Authorisation remains a separate server responsibility.
Integration tests earn their cost when the boundary itself can fail: Doctrine mappings and DQL, container wiring, serializer configuration, cache adapters or Messenger routing. Exercise the relevant real infrastructure. Mocking a query builder cannot tell you whether its joins return duplicate articles. Use an isolated test database with a suitable engine and schema; prioritise queries whose mistakes would matter.
Functional API tests send requests through Symfony and check routing, methods, credentials, authorisation, decoding, validation, the public response and selected effects. For publication, establish three outcomes: anonymous access is rejected according to the API contract, a recognised caller without permission cannot change the record, and an authorised caller can. Do not infer authentication solely from 403; entry points and application configuration determine concrete responses. Symfony’s test client does not exercise the deployed proxy or a real browser.
The GiSoft article How to test Symfony APIs with Pest develops those HTTP examples in detail. Here, the strategic choice is to prove the rule cheaply, then prove that the endpoint applies it. A serializer test or repository test cannot replace that connection.
In React, test what the user can do and recover from
Pure functions, reducers, formatters and state transitions also belong in unit tests. Component tests become useful when the behaviour depends on rendering and interaction: validation messages, loading, disabled controls, recoverable failures and successful results.
The repository already uses Vitest, React Testing Library and user-event, with jest-dom matchers in test setup. The following assumes an illustrative DraftEditor, not a component supplied by the article. It accepts initialTitle and an asynchronous saveDraft({ title }) callback, and renders the English labels used here:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, it, vi } from 'vitest';
import { DraftEditor } from './DraftEditor';
it('preserves edited text after a failed save', async () => {
const user = userEvent.setup();
const saveDraft = vi.fn().mockRejectedValue(new Error('Unavailable'));
render(<DraftEditor initialTitle="Release notes" saveDraft={saveDraft} />);
const title = screen.getByRole('textbox', { name: 'Title' });
await user.clear(title);
await user.type(title, 'Revised notes');
await user.click(screen.getByRole('button', { name: 'Save draft' }));
expect(await screen.findByRole('alert'))
.toHaveTextContent('Could not save');
expect(screen.getByRole('textbox', { name: 'Title' }))
.toHaveValue('Revised notes');
expect(screen.getByRole('button', { name: 'Save draft' })).toBeEnabled();
expect(saveDraft).toHaveBeenCalledWith({ title: 'Revised notes' });
});This checks a specific recovery promise: after saving fails, the user still has the edited text and can submit again. It does not inspect hook state, a private helper or the component’s nesting. Queries by role and accessible name usually describe intent more clearly than CSS selectors tied to layout. They do not by themselves establish accessibility compliance.
Use a controlled pending promise for a separate loading test, then settle it explicitly. A disabled button can prevent another click in this interface; it does not establish backend idempotency. Likewise, hiding a publish button cannot enforce server authorisation. Large page snapshots tend to obscure these distinctions; small snapshots can still help when the rendered output itself is the contract.
Check the contract between Symfony and Next.js
Contract tests check whether the producer and consumer agree on observable data: statuses, required and nullable fields, date formats, error codes, pagination and authentication assumptions. They may use a schema, provider verification or response examples checked against both sides. A frontend fixture invented independently of the backend only proves agreement with that fixture.
TypeScript describes what compiled code expects. Casting decoded JSON to an interface does not validate the payload. At the API adapter boundary, check valid data and a meaningful incompatible response using the project’s existing runtime validation approach. If Symfony changes publishedAt from an ISO string to a number, the consumer should either handle the agreed change or fail predictably. Schema validation alone will not catch a correctly shaped response containing another user’s data.
Next.js adds an execution boundary. A client-side transition, a full page load and server rendering need not obtain credentials or cached data in the same way. A server-side fetch does not automatically inherit the browser’s incoming cookies or Authorization header. Test the intended credential propagation, without forwarding arbitrary headers. Keep pure mapping logic easy to test and verify server-specific behaviour in a suitable Next.js runtime.
Support for rendering Server Components in unit-test tools depends on the toolchain. The Next.js 16 Vitest guide identifies asynchronous Server Components as unsupported and recommends E2E coverage for them. A passing client-component test is therefore no evidence about that server path. The separate GiSoft article How to test Next.js architecture, failures and user workflows covers those execution and recovery boundaries more fully.
Reserve browser tests for the connections that matter
Browser/E2E tests can verify that navigation, rendering, credentials and backend behaviour work together. Their diagnostic scope is also broad: one failure may originate in a selector, an expired session, missing data, the network or the service itself. They are especially valuable for a critical purchase, sign-in or publishing journey, with a selected recovery case where failure is costly.
Use the browser runner already adopted by the project. Playwright is one option, but this frontend’s package manifest does not currently declare a configured Playwright suite. Tool choice should not force a second testing stack into the application just to follow an example.
Be explicit about replacements. A browser test with every API response mocked verifies the browser-facing flow, not the actual Symfony integration. Keep at least the relevant critical path connected to controlled backend data. Use provider doubles in ordinary CI and separate deliberate checks against a provider’s test environment; real payments and uncontrolled external services do not belong in routine runs.
Smoke tests answer a smaller deployment question: does the deployed entry point respond, and can a selected safe read or essential dependency check complete? They complement the application suite and operational monitoring. They do not establish that every permission or business rule works.
Regression budgets can protect a risky list endpoint against growing query counts, excessive response size or an ignored pagination limit. Derive bounds from representative data and isolate setup work from measurement. A bounded query count is not a latency benchmark. Leave detailed N+1 and performance diagnosis to focused tests and profiling rather than making every browser test measure timing.
Design CI around useful results, not a ceremonial order
A reasonable pipeline starts with syntax, types and other inexpensive checks, followed by focused unit/component coverage, infrastructure and API tests, then selected browser journeys. Independent jobs can run in parallel. Some integration checks may be cheap enough to run immediately; a shared library change may justify broader coverage from the start.
Watch both wall-clock duration and total runner usage. Parallelising everything can shorten the wait while increasing infrastructure costs or contention for a shared database. Give workers separate data, cache namespaces and ports where needed. Cache dependencies using the relevant lockfiles; do not reuse mutable test state as a speed optimisation.
During development, run the tests close to the change. Before merging, use coverage appropriate to its impact: selecting tests only by changed filenames can miss effects through shared code, schemas and configuration. Retain broader checks at a suitable gate. For failed browser jobs, preserve traces and useful logs without secrets. A retry can help diagnose an intermittent failure; it must not silently turn an unreliable test into a trustworthy result.
Track queue time, setup time, slow suites, intermittent failures and investigation effort. Improve the part that consumes time before buying more runners or deleting valuable assertions. There is no universal CI duration or unit-to-E2E ratio that establishes a sound strategy.
Treat maintenance as part of the design
A test creates value when it fails for a meaningful change and helps explain the problem. Repeated failures caused by unrelated markup or shared fixtures consume time and teach people to distrust the suite. That cost belongs in architecture decisions, not just in a later cleanup budget.
Keep fixtures small and explicit about the state under test: owner, role, publication status and language. Use deterministic identifiers and an injected clock where time matters. Isolate mutable state and avoid dependence on execution order. Helpers should remove repetition without hiding authentication, database writes or the reason a test expects success.
Replace a dependency when the test needs a controlled response from it. Keep it real when its behaviour is the risk. Excessive mocking can make a service test pass while production wiring or persistence is broken. It also ties tests to call order and collaborator structure. Prefer observable results; assert an internal call count only when that interaction is itself a requirement.
Avoid buying the same evidence repeatedly. For a price calculation, unit tests cover rounding and boundary values; an API test checks the result’s amount and currency mapping; a browser test proves the customer can complete the purchase. Repeating every rounding case through the browser adds setup and diagnosis costs with little extra coverage of the connections.
When refactoring, tests for stable behaviour should mostly survive changes to classes or component composition. If behaviour intentionally changes, update its contract and tests together. Delete or move a redundant test after identifying what still protects the risk. An intermittently failing test needs an owner and a repair deadline; disabling it indefinitely only conceals the gap.
Apply the strategy to one publishing feature
For the hypothetical editor, the first plan can be small:
- Policy: unit cases for missing content, whitespace and publishable content. No HTTP setup is needed.
- Data and access: an integration test for selecting the correct article and translation; API cases for denial without mutation and permitted publication with persisted state.
- Editor: component cases for pending submission, validation, preserved input after failure and success. An adapter test checks the response and error mapping used by these states.
- Connected journey: a browser case signs in, edits and publishes, then confirms the authoritative result through a fresh read. Include a full reload if server rendering is relevant; add a recovery journey only where it protects a distinct risk.
- Release: a safe smoke check confirms that the deployed route is reachable. Add a cache or query budget test only if the feature introduces that particular risk.
For a mature application, begin with the behaviour you are about to change. Capture an unprotected critical journey if necessary, then move detailed variations closer to the rule as you understand the code. You do not need a wholesale rewrite of the suite or an arbitrary coverage target before making progress.
The next test should answer a named uncertainty. Put it where that uncertainty can be resolved reliably, and judge the result by how quickly a developer can make and diagnose a change—not by the number of tests added.
Technical references
- Symfony 7.4 testing: https://symfony.com/doc/7.4/testing.html
- Testing Library user interactions: https://testing-library.com/docs/user-event/intro/
- Next.js testing with Vitest and its current limitations: https://nextjs.org/docs/app/guides/testing/vitest
- TypeScript type assertions and runtime limits: https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions
