Database Testing — Connecting UI to the Source of Truth
A green UI test means the browser rendered correctly. It doesn't mean the data was saved. Learn how to connect Playwright to MySQL/Postgres and verify that frontend actions actually persist in the backend.
📖 The Story: The Lying UI
An e-commerce company was testing their new "Quick Signup" form. The Playwright test filled out the name, email, and password, clicked "Submit", and asserted that the "Welcome to the app" message appeared. The test passed. They deployed to production.
The next day, the marketing team complained that no new welcome emails were being sent. The engineering team investigated and found a silent bug: a recent database migration had added a NOT NULL constraint to the `marketing_opt_in` column. The frontend forgot to send that field.
The frontend showed the success message instantly (optimistic UI), but the backend threw a database error and failed to save the user. The Playwright test passed because it only checked the UI. The UI lied.
The QA team updated the test. After asserting the UI success message, they added a database query: SELECT * FROM users WHERE email = 'test@test.com'. They asserted that the row existed. The next time the migration broke, the test failed instantly. The UI couldn't lie anymore.
If the data isn't in the database, the feature doesn't work.
🎯 Why Database Testing?
Source of Truth
The UI can lie with optimistic updates. The database is the ultimate source of truth.
Hidden Fields
Verify fields the UI can't see, like created_at, is_deleted, or password hashes.
Relationships
Ensure clicking "Add to Cart" created the correct foreign key relationships in the DB.
Direct Cleanup
Delete test data directly via SQL after tests, avoiding slow UI deletion flows.
🌍 Real World Example
You are testing a "Delete Account" feature. When clicked, the UI hides the user's profile. But did the backend hard-delete the row, or did it just set is_active = false (soft delete)? The UI looks identical either way. By querying the database, you can assert exactly what happened, ensuring compliance with GDPR or data retention policies.
🧒 Explain Like I'm 10
Imagine you order a toy online. The website shows a green checkmark saying "Order Placed!" That's the UI.
But to be absolutely sure the toy is coming, you go to the warehouse (the database) and look at the shipping manifest to see if your name is written on a box. If it's not on the manifest, you aren't getting the toy, no matter what the website says.
🎓 Professional Explanation
Because Playwright tests run in a Node.js environment, you have full access to npm packages. You can install database drivers like mysql2 or pg and query the database directly from your test file. The enterprise pattern is to create a custom Playwright Fixture that manages the database connection pool. This allows tests to execute queries, validate state, and perform teardown without instantiating a new connection every time.
📊 The Full Stack Test
Validating the Entire Stack
1. UI Action
Playwright interacts with the browser, filling forms and clicking buttons.
2. API Request
The frontend sends data to the backend server.
3. DB Query
Playwright queries the DB directly to verify the server actually saved it.
🔌 Step 1: Install the Database Driver
Install the appropriate Node.js driver for your database. For MySQL, use mysql2. For PostgreSQL, use pg.
npm install --save-dev mysql2
⚙️ Step 2: Create a DB Fixture
Instead of creating a connection in every test, create a custom fixture. This ensures the connection is reused and properly closed.
import { test as base } from '@playwright/test'; import mysql from 'mysql2/promise'; // Define the fixture type type Fixtures = { db: mysql.Pool; }; // Extend the base test export const test = base.extend<Fixtures>({ db: [async ({}, use) => { // Create a connection pool const pool = mysql.createPool({ host: 'localhost', user: 'root', password: 'password', database: 'test_db' }); // Provide the pool to the tests await use(pool); // Close the pool after all tests in the worker finish await pool.end(); }, { scope: 'worker' }] // Reuse across all tests in this worker }); export { expect } from '@playwright/test';
Scope: 'worker': This is crucial. You don't want to open a new DB connection for every single test. By setting the scope to worker, the connection pool is created once per CPU core and shared among all tests running on that worker.
✅ Step 3: Validating UI Actions vs DB
Now, let's test the "Quick Signup" flow. We'll fill the form in the UI, submit it, and then query the database to verify the record was saved.
import { test, expect } from '../fixtures'; test('user signup saves to database', async ({ page, db }) => { const testEmail = `test_${Date.now()}@test.com`; // 1. UI Action: Fill out the form await page.goto('/signup'); await page.getByLabel('Email').fill(testEmail); await page.getByLabel('Password').fill('password123'); await page.getByRole('button', { name: 'Submit' }).click(); // 2. UI Assertion: Verify success message await expect(page.getByText('Welcome')).toBeVisible(); // 3. DB Validation: Query the database const [rows] = await db.query( 'SELECT * FROM users WHERE email = ?', [testEmail] ); // 4. Assert the DB row exists and matches expect((rows as any[]).length).toBe(1); expect(rows[0].email).toBe(testEmail); expect(rows[0].is_active).toBe(1); // Check hidden fields! });
Parameterized Queries: Always use ? placeholders for values. Never concatenate strings to build SQL queries in your tests, as this leads to SQL injection vulnerabilities and syntax errors with quotes.
🧹 Step 4: Database Cleanup (Teardown)
Test data will bloat your database. Use afterEach to clean up the specific records created by the test.
test.afterEach(async ({ db }) => { // Delete all test users created during the run await db.query("DELETE FROM users WHERE email LIKE '%@test.com'"); });
⚠️ Common Mistakes
Mistake 1: Testing Production Database
Never, ever point your test suite at your production database. Tests mutate data. Always use a dedicated QA or staging database. Store the DB credentials in a .env file specific to the test environment.
Mistake 2: Race Conditions (Querying too fast)
// ❌ BAD: Querying the DB immediately after the UI click await page.click('Submit'); const [rows] = await db.query('SELECT ...'); // Might be empty! // ✅ GOOD: Wait for the API response first, then query DB const responsePromise = page.waitForResponse('**/api/signup'); await page.click('Submit'); await responsePromise; // API has acknowledged the save const [rows] = await db.query('SELECT ...'); // Safe to query
Even if the UI shows success, the backend might still be processing the database write. Wait for the network response to complete before querying the DB to avoid flaky "row not found" errors.
✅ Best Practices
- Use a connection pool. Creating a new connection per test is slow. Pools reuse connections.
- Use parameterized queries (
?). Protect your test database from accidental SQL injection. - Clean up with
afterEachorafterAll. Never leave test data behind. - Wait for network responses. Don't query the DB until the API confirms the action is done.
- Verify hidden fields. The real value of DB testing is checking fields the UI can't see (e.g.,
updated_at, boolean flags).
🏗️ Senior Engineer Deep Dive
Parallelism and Database Deadlocks
When running tests in parallel, multiple workers might try to write to the same table simultaneously. If your tests rely on specific counts (e.g., "there should be 5 users"), parallel workers will cause race conditions. The solution is to ensure every test creates unique data (using timestamps or UUIDs) and only queries for its own data, rather than checking global counts.
Transactions for Cleanup
An advanced cleanup pattern is to wrap your test in a database transaction (BEGIN). Instead of actually committing data to the DB, you do a ROLLBACK at the end of the test. This leaves the database 100% pristine without needing complex DELETE queries. However, this requires modifying your application code to use the test transaction connection, which can be complex.
💼 Interview Questions
Show Answer
mysql2 or pg to connect to and query the database directly from your test scripts.Show Answer
scope: 'worker'. This creates a database connection pool once per worker process, sharing it across all tests running on that worker, and closes the pool when the worker terminates.Show Answer
page.waitForResponse() to wait for the backend API to acknowledge the save before executing my SELECT query. Additionally, I ensure parallel tests create unique data to avoid deadlocks.Show Answer
SELECT is_deleted FROM users WHERE id = ?. I would assert that the row still exists but is_deleted is true (soft delete), or I would assert the row no longer exists (hard delete), depending on the business requirement.🏋️ Practical Exercise
Verify a Todo Saved
- Find a simple Todo app online (or build one).
- Install
mysql2orpg. - Write a test that adds a Todo item via the UI.
- After the UI updates, query the database to find the Todo by its text.
- Assert that the
completedcolumn in the DB isfalse. - Delete the Todo in the
afterEachhook.
🚀 Mini Project
🏗️ The Soft Delete Validator
- Write a test that logs in and navigates to "Account Settings".
- Click "Delete My Account".
- Assert the UI logs you out and shows the homepage.
- Query the database using the user's ID.
- Assert that the user row still exists, but the
deleted_attimestamp is no longer NULL (proving it was a soft delete, not a hard delete).
📝 Quiz
Test your understanding. Click an option to check your answer.
page.queryDB()mysql2 or pg.scope: 'test'scope: 'worker'scope: 'global'scope: 'page'worker scope creates one pool per CPU core, shared among all tests on that worker, which is highly efficient.?) in DB testing?page.waitForTimeout(5000)page.waitForResponse() to wait for the API to finish before querying.is_deleted or created_at) the UI cannot see.playwright.config.ts file.afterEach or afterAll hook.afterEach ensure cleanup happens even if the test fails.mysql2pgmongodbpostgres-playwrightpg package is the standard PostgreSQL driver for Node.js.❓ FAQ
Q: Can I use an ORM like Prisma or TypeORM in my tests?
A: Yes! If your project already uses an ORM, you can import your ORM client directly into your Playwright tests instead of writing raw SQL. This keeps your types consistent.
Q: Does DB testing work with NoSQL databases like MongoDB?
A: Yes. You just install the mongodb npm package and connect using the same fixture pattern, querying collections instead of tables.
📦 Summary
🎯 Key Takeaways
- The UI can lie. The database is the source of truth.
- Use Node.js drivers (
mysql2,pg) to connect to the DB. - Create a DB fixture with
scope: 'worker'to reuse connection pools. - Wait for API responses before querying the DB to avoid race conditions.
- Verify hidden fields like
is_deletedorrole_id. - Always clean up test data in
afterEachhooks.
