
The bug I keep coming back to is the one where nothing on the screen looks wrong. The button works, the toast appears, the page refreshes, and everyone in the room relaxes. Then I read the actual record, the subscription state, or the API response, and the product did not do the thing it just told the user it had done.
A duplicate row shows up in an admin table and it reads as a display problem. A badge goes missing from a profile and it reads as a rendering glitch. A leaderboard shows the wrong user at the top and it reads as the frontend sorting the list wrong. So the fix gets scoped as a frontend ticket, someone tweaks the component, the screen looks right again, and everyone moves on.
Sometimes that is exactly right. The data underneath was correct, the UI was rendering it badly, and correcting the UI was the whole fix.
But often enough, the screen was telling the truth. There genuinely are two rows for the same user, the badge really did fail to write, and the leaderboard is showing exactly what the database returned.
When that happens, a presentation fix does not solve the bug. It hides it. The user stops seeing the mess while the system keeps producing the same bad state underneath, where reports, billing, permissions, and every future feature quietly inherit it.
These bugs look like screen bugs for one boring reason: the screen is where you noticed them. That is also the trap. It is easy to test the screen and miss the system, to fix the pixels and leave the records wrong.
The skill I want you to build is knowing when a screen bug is really a data bug, and what to do once you suspect it. That instinct is worth more than any single test you will write, because it is the difference between making a symptom disappear and making the cause impossible. Test the system, not just the screen.
Why a presentation-only fix is so tempting
Presentation fixes are quick, visible, and satisfying. You group the duplicate rows in the UI and they vanish, you hide one of two conflicting counts, you force a refresh to clear stale data. The ticket closes, the screenshot looks clean, and the demo goes well.
None of that is wrong when the source data is correct. If the database holds one accurate record and the UI is rendering it badly, then fixing the UI is exactly the fix.
The trouble is when the same patch gets applied on top of data that is genuinely broken. Now the product looks healthy and behaves as if it is sick.
The duplicate account the screen used to expose is still there, and it will surface again the moment someone runs a billing export, counts active users for an investor update, or builds a feature that joins on that table and suddenly returns two rows where it expected one.
The most dangerous version is when the UI is patched on purpose to hide the issue. The frontend starts de-duplicating the list before it renders, or quietly swallows the second of two conflicting counts, so the screen looks correct while the system can still happily write invalid data.
That is a mask. The records are still wrong, the next system that reads them inherits the mess, and the bug is now harder to find, because the one surface that used to expose it has been told to look away. A UI mask does not count as a root-cause fix while the system can still create the invalid state.
So the question I ask on any data-shaped bug is a simple one.
Are we fixing what the user sees, or what the system believes?
Those are two different repairs, and only one of them holds.
Find the source of truth first
Before you can tell a display bug from a data bug, you have to know where the real answer lives. For every data-heavy feature, find the source of truth: the system that owns the canonical value, the one everything else is supposed to agree with.
It might be a database table, an API response, an event stream, a cache, a third-party provider, a local store on the device, or some combination that hands off between them.
Once you know where truth lives, a vague bug becomes a precise comparison. Read the value the user is shown, then read the same value at the source, and see whether they match.
If the source is right and the screen is wrong, you have a presentation bug, and the UI ticket was correct all along. If the source is wrong, the bug lives below the presentation layer, and no amount of frontend polish will reach it.
That single comparison redirects far more bugs to the right team than any amount of staring at the screen.
The patterns worth being suspicious of
Some bugs almost always turn out to be data problems that only look like display bugs. These are the ones that survive a clean test account, because a fresh account never exercises the messy history that creates them.
Keep a short mental list and reach for it whenever a screen bug feels too tidy:
- Duplicate accounts that appear after a reinstall or a second sign-up attempt
- Several profiles tied to a single device, or one profile spanning two real people
- Subscription state that says active in the app and expired in the backend, or the reverse
- A purchase that completed but never granted the entitlement it paid for
- A button stuck in the wrong state, enabled when it should be disabled, because it is reading a status the backend never updated
- Old, pre-logout state still showing after a reinstall, because the local store was never cleared
- A missing badge or a wrong name that turns out to be the value the database actually returned, not a render glitch
- Duplicate events after a retry, so one action gets counted twice in analytics
- Dashboard totals that do not add up to the detail rows beneath them
- Soft-deleted records that still show up in active workflows because a query forgot the
deleted_at IS NULLfilter - Records created without a required relationship, like an order with no customer attached
Notice how many of these are about identity, money, or counting. Those three areas are where data bugs do the most damage, and where a clean-looking screen is the least trustworthy.
Underneath all of them sit the same few qualities the data community has been naming for years. The DAMA Dimensions of Data Quality describe data health in terms like accuracy (does the value match the real world), completeness (is everything that should be present actually there), consistency (do two systems agree), and uniqueness (is each real-world thing represented exactly once).
When you find yourself debugging a duplicate or a mismatch, you are usually staring at a uniqueness or consistency failure, and naming it that way helps you describe the bug to the people who own the data.
Test identity like it is the whole product
Identity is where I have seen the most expensive data bugs hide, because a product almost never identifies a user by one clean key. It might key off an email, a device identifier, an account provider, an anonymous guest id, an install id, or a third-party login, and the rules for when two of those count as the same person are often never written down.
When the rules are fuzzy, the data goes fuzzy with it, and you end up with two accounts for someone who only ever meant to have one.
So when you test a feature that touches identity, walk it through the full set of journeys, not just the happy first sign-up:
- A brand-new user signing up for the first time
- A returning user signing back in
- A guest who uses the product before creating an account, then creates one
- A guest who upgrades to a real account, carrying their history with them
- The same person reinstalling the app
- The same account signed in on a second device
- A device changing hands or being wiped
- A full logout and login cycle
- A deleted account, and what happens if that person comes back
- A social login that switches provider or changes its underlying email
Each of those is a chance for the system to either recognise the same human or accidentally mint a new one. The product owes you a clear answer to one question: what makes a user the same user across sessions and devices?
Once that rule exists, the database and the application can both enforce it. Until it exists, you are testing against a guess, and so is the code.
The reinstall and second-device cases are the ones a clean test account will never surface, and they are exactly where the unglamorous questions pay off. What build is this, what environment, what device, what data was already on it.
Old, pre-logout state that survives a reinstall, or a profile that splits in two when the same person signs in on a new phone, almost never shows up until you ask what happens with real data on a real device with a real history behind it.
None of that is glamorous, and that is the point. The boring questions are the ones that prevent the expensive mistakes.
This is the gut-check I bring to any feature that touches identity, money, or counting:
The questions I ask before I trust a clean-looking screen. Run the feature through these before you write a single test case:
- Does the stored record actually match what the screen just told the user?
- What did the API return, not just what the page rendered?
- What happens downstream, to the report, the entitlement, the next job, after this action?
- What happens with real production-shaped data, not the clean demo row?
- And the one that catches the quiet bugs: did it do what it claimed, or only look like it did?
Let the database make the bad state impossible
Application-level checks are useful, and they are also not enough on their own. Code that reads a row, decides no duplicate exists, and then writes a new one can be beaten by two requests arriving at the same instant. Both read, both see nothing, both write, and now there are two.
Retries, offline sync, and a user on two devices all create that same window. This is the classic race condition, and it is exactly why uniqueness belongs in the place that can settle it atomically.
For the data integrity rules that genuinely matter, ask whether the system has database-level guardrails behind the application logic. PostgreSQL’s constraint documentation covers the everyday tools, and they read almost like a checklist of the bugs above:
CREATE TABLE accounts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL,
device_id text NOT NULL,
status text NOT NULL CHECK (status IN ('active', 'suspended', 'deleted')),
deleted_at timestamptz,
-- one account per email, enforced by the database, not by hopeful code
CONSTRAINT accounts_email_unique UNIQUE (email)
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account_id bigint NOT NULL REFERENCES accounts (id), -- no orphan orders
amount numeric NOT NULL CHECK (amount > 0) -- no zero or negative charges
);
Each constraint maps to a bug it kills outright:
- A unique constraint stops the duplicate account that two simultaneous sign-ups would otherwise create.
- A foreign key stops an order from existing without a customer.
- A not-null requirement stops a half-written record.
- A check constraint stops a status the application never intended.
You will not always be the person who writes these, but you are very well placed to ask whether they exist, because you are the one who keeps seeing the bugs they would have prevented. If a duplicate is supposed to be impossible, the database should usually be the thing that makes it impossible.
Test what happens when an action runs twice
A large share of data bugs are born from partial failure, where the same operation runs more than once because something interrupted it the first time:
- The request succeeds on the server but the response times out, so the client retries and the work happens again
- A user taps the pay button twice because the spinner lagged
- The network drops right after a payment is authorised
- A background sync wakes up and replays a payload it already sent
- A queue redelivers an event nobody marked as handled
In every one of these the system performs the same operation more than once, and unless it was built to expect that, you get a duplicate where there should be one.
The defence is idempotency, which simply means that doing the same operation again produces the same result rather than a second copy.
The payment world has standardised on idempotency keys for exactly this reason. Stripe’s official guidance is to attach a unique key, usually a version 4 UUID, to a request that creates something, so that a retry carrying the same key returns the original result instead of charging the card again.
The same pattern has been written up as an IETF Idempotency-Key header draft for methods like POST and PATCH, which tells you this is a shared convention and not one vendor’s trick.
Your job is to actually exercise the double-action path, not to assume the code handles it. So write the tests that name the bad outcome you are trying to prevent:
- Verify that creating the same record twice produces exactly one row
- Verify that submitting the same payment token twice charges the customer once
- Verify that replaying the same sync payload leaves the data unchanged after the first apply
- Verify that processing the same event id again is a no-op
import { test, expect } from '@playwright/test'
test('verify that submitting the same payment token twice charges once', async ({ request }) => {
const key = crypto.randomUUID()
const body = { amount: 4500, token: 'tok_visa' }
const headers = { 'Idempotency-Key': key }
const first = await request.post('/api/charges', { data: body, headers })
const second = await request.post('/api/charges', { data: body, headers })
// both calls succeed, but they describe the same charge, not two
const a = await first.json()
const b = await second.json()
expect(a.id).toBe(b.id)
// and the source of truth agrees: one charge, not two
const list = await request.get(`/api/charges?token=tok_visa`)
expect((await list.json()).data).toHaveLength(1)
})
The shape of that test is the point. It does the thing twice on purpose, then checks the source of truth, not just the screen, to confirm the system stayed honest.
That last assertion against the backend is what separates a real idempotency test from one that only proves the UI did not crash.
Make your evidence go below the screen
When someone tells me a data bug is fixed, my first instinct is to look below the screen. For a data bug, a screenshot is the weakest evidence you can bring, because it shows the symptom and says nothing about the cause.
A screenshot of two duplicate rows does not tell anyone whether there are two database records or one record rendered twice, and those need completely different fixes. The strongest bug report on a data issue points straight at the source of truth.
So gather evidence that lives where the truth lives:
- A query that shows the duplicate, with counts:
SELECT email, COUNT(*) AS copies
FROM accounts
WHERE deleted_at IS NULL
GROUP BY email
HAVING COUNT(*) > 1;
- Before-and-after row counts that prove the fix removed the bad state, not just the display of it
- The two conflicting values side by side, the API response next to the table row
- The event log showing the same event id processed twice
- The result of running the migration or constraint on a copy of the data, so you know it actually holds
- A production-safe read that confirms the same condition is not lurking in real data
This does not mean every tester has to become a database administrator. It means getting comfortable asking for source-of-truth proof and, where you have safe read access, fetching it yourself.
A bug report that includes a GROUP BY ... HAVING COUNT(*) > 1 showing live duplicates is impossible to argue with, and it routes the work to the right layer immediately.
If you want a structure to drop that evidence into, my bug report template gives you the fields to fill, and the deeper walkthrough on writing these up lives in from bug report to triage. The mechanics of capturing the request, the trace ID, and the logs that sit behind the query are their own skill, and I cover them in observability for QA (coming soon).
On a large retail platform I tested, the screen looked fine and the data underneath was not. A promotion that should have applied once was showing up twice in a basket evaluation, so the discount looked doubled on the saved order even though the cart page rendered cleanly. I did not argue it from the UI. I went to the source, grouped the applied offers for that basket by offer id, and kept only the rows that came back more than once:
SELECT offer_id, COUNT(*) AS applied_count
FROM applied_offers
WHERE basket_id = 'BASKET-123'
GROUP BY offer_id
HAVING COUNT(*) > 1
ORDER BY applied_count DESC;
offer_id | applied_count
-----------------+---------------
OFFER-RUNG-200 | 2
Two rows came back for the same offer id, which is impossible for a promotion that is meant to apply a single time. That one query turned a he-said-she-said about a screenshot into a defect nobody could wave away, and it routed the fix to the offers layer instead of a cosmetic patch on the cart page.
A presentation fix and a data fix protect different things
It helps to see plainly what each kind of fix actually buys you, because they are not interchangeable and the difference is easy to forget under deadline pressure.
A frontend patch can make the current screen correct. Only a fix at the source can keep the data honest for everything else that reads it.
| What it protects | Presentation-only fix | Source-of-truth fix |
|---|---|---|
| The current screen looks right | ✓ | ✓ |
| Reports and exports stay correct | ✗ | ✓ |
| Billing and entitlements stay correct | ✗ | ✓ |
| Analytics counts stay correct | ✗ | ✓ |
| Future features that read the data are safe | ✗ | ✓ |
| The bad state can no longer be created | ✗ | ✓ |
| Ships fast today | ✓ | ~ |
✓ protects it ~ sometimes, with more work ✗ does not
The only column where the presentation fix wins is speed, and that win is borrowed against every other row.
Sometimes shipping the fast fix first and scheduling the real one is the right call, made on purpose. The failure is making it by accident, closing the ticket on the green screenshot, and never coming back to the data.
A checklist for the next suspicious bug
When a UI issue smells like it might be a data problem, run it through these questions before you accept any fix:
- What is the source of truth for this value?
- Is the source data itself correct, or only the display of it?
- Can I reproduce the bad state on purpose, by repeating the action or replaying the input?
- Is there a database guardrail that should have prevented it, and is it present?
- Does the system handle duplicate-key and retry cases idempotently?
- Are the related tables consistent with each other?
- Does existing bad data need a cleanup, separate from the code fix?
- If a user hits this in production, can support actually repair the record, or is the bad state stuck?
- What automated test now protects this path, and does it assert against the source, not just the screen?
- What production check or monitor would catch this if it returned?
To stop this from being a one-time heroic dig that nobody repeats, hang an owner and a finish line on the parts that decide whether the bug can come back:
- Owner: QA. Prove the bad state at the source, not just the screen. Done when: the ticket carries a
GROUP BY ... HAVING COUNT(*) > 1(or equivalent) result, not only a screenshot, and an automated test asserts against the source of truth. - Owner: Dev. Make the bad state impossible to create. Done when: the unique, foreign-key, or check constraint is in place and the retry path is idempotent.
- Owner: EM. Decide whether existing bad data needs cleanup. Done when: a scheduled query watches the column that should be unique and trips an alert if a duplicate reappears.
That last point is worth holding onto. The data world calls it data observability: watching production for freshness, volume, and schema changes so that a duplicate or a sudden count drop trips an alert instead of waiting for a user to notice.
You do not need a heavy platform to start. A scheduled query that fails the build when COUNT(*) > 1 appears on a column that should be unique is a real, cheap safety net, and it is the kind of guard that turns a recurring bug into a one-time fix.
Quality is whether the system can still create the bug
Users live in the UI, but products run on data. A screen can look immaculate while the records behind it are wrong, and that product is not healthy, it is just quiet. This is the heart of testing the whole product, weaving quality through every layer rather than checking only the one the user happens to see.
So the habit I want you to leave with is to never let “I cannot see the bug anymore” be the end of the investigation. That sentence describes the screen.
If I were reviewing the pull request that fixed it, I would not sign off just because the demo looks clean. I would ask the better question, the one that separates good testers from the people who close tickets: can the system still create this bug?
When the answer is no, because the data is corrected, the constraint is in place, the operation is idempotent, and a test and a monitor are standing guard, that is when you have actually fixed it. That is also when you can ship with evidence instead of crossed fingers. Everything before that is just a cleaner view of the same problem.
If you want to go deeper on the layers underneath the screen, my guide on API and microservices testing lives in exactly this territory.





Comments 0
Share your thoughts, ask questions, or add to the conversation.