What happened
An event form looked healthy from the outside. The user changed fields, clicked save, and saw a success toast.
But the saved data was not trustworthy. Address changes could keep the previous map coordinates. The edit form accepted weak input. The create form had validation state that could drift from the data being submitted. The test suite did not catch it because it mostly checked that the dialog closed or that a success screen appeared.
That is the dangerous part: the UI told the truth about the request finishing, not about the data being correct.
Root cause
The tests were proving the wrong contract.
They asserted:
- the form could be filled
- the save button could be clicked
- the success state appeared
- the dialog closed
- the updated title was visible somewhere on the page
Those are useful smoke checks, but they are not persistence checks.
For a real create/edit flow, the contract is bigger:
- the browser selected the intended autocomplete result
- the payload included the derived fields
- the server accepted only valid input
- the database stored the intended values
- the next read returns those values
- stale client cache did not hide a failed save
If the E2E test stops at the toast, a broken save path can still pass.
Why it was non-obvious
Forms often have derived state.
An address field might populate city, postal code, region, latitude, and longitude. A date field might generate opening-hour rows. A login dialog might resume a draft submit. An edit form might start with existing coordinates, then accidentally keep them after the address text changes.
The visible input is only one layer. The meaningful saved record is the combination of typed fields, derived fields, validation, API behavior, and refetch behavior.
That is why "I saw the new text on the card" can be misleading. The card may be optimistic, partially refreshed, or showing only one field while the broken field remains hidden.
The better E2E rule
For important forms, every save test should have a readback assertion.
After create:
await page.getByRole("button", { name: /create/i }).click();
await expect(page.getByRole("heading", { name: /created/i })).toBeVisible();
const response = await page.request.get("/api/items/mine");
const records = await response.json();
const saved = records.find((item) => item.name === uniqueName);
expect(saved.address).toBe("Selected Street 1");
expect(saved.latitude).toBeCloseTo(48.2, 4);
expect(saved.openingHours).toContain("09:00-18:00");
After edit:
await dialog.getByRole("button", { name: /save/i }).click();
await expect(dialog).not.toBeVisible();
const response = await page.request.get(`/api/items/${id}`);
const saved = await response.json();
expect(saved.name).toBe(updatedName);
expect(saved.address).toBe(updatedAddress);
expect(saved.longitude).toBeCloseTo(expectedLongitude, 4);
The important move is not the exact API path. It is the discipline: test the persisted record after the UI says success.
Make external services deterministic
Autocomplete and geocoding are classic sources of flaky tests. Do not depend on a live map provider in a form-save regression test.
Mock the search endpoint with a known result:
await page.route("**/api/geocode/search?**", async (route) => {
await route.fulfill({
json: [{
display_name: "Selected Street 1, City",
lat: "48.2000",
lon: "16.3000",
address: { road: "Selected Street", house_number: "1", city: "City" },
}],
});
});
Now the test verifies your app logic: dropdown selection, derived fields, payload, persistence, and readback.
Reusable rule
A toast proves that code reached a happy branch. It does not prove that the right data survived the round trip.
For any create/edit form with derived fields, cache, or autocomplete, write at least one E2E test that:
- uses deterministic external-service mocks
- submits through the real UI
- reads back through the API
- asserts the fields users cannot easily see
The hidden fields are where save bugs like to live.