The most surprising thing about Playwright database testing is that you’re not actually testing Playwright at all; you’re using Playwright as a high-level orchestrator to test your application’s data layer.
Let’s see it in action. Imagine a simple e-commerce app where users can add items to a cart. We want to ensure that when a user adds an item, the database correctly reflects this.
First, we need a way to interact with the database. Playwright itself doesn’t do this, so we’ll use a Node.js library like pg for PostgreSQL.
// db.js
import pg from 'pg';
const pool = new pg.Pool({
user: 'testuser',
host: 'localhost',
database: 'ecommerce_test',
password: 'password',
port: 5432,
});
export async function query(text, params) {
const res = await pool.query(text, params);
return res.rows;
}
export async function seedProduct(id, name, price) {
await query('INSERT INTO products (id, name, price) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET name = $2, price = $3', [id, name, price]);
}
export async function getCartItemCount(userId, productId) {
const rows = await query('SELECT quantity FROM cart_items WHERE user_id = $1 AND product_id = $2', [userId, productId]);
return rows.length > 0 ? rows[0].quantity : 0;
}
Now, in our Playwright test, we’ll use this db.js module to prepare the database before our test runs and then verify the state after our application interacts with it.
// tests/cart.spec.js
import { test, expect } from '@playwright/test';
import { seedProduct, getCartItemCount } from '../db.js';
const USER_ID = 123;
const PRODUCT_ID_A = 456;
const PRODUCT_ID_B = 789;
test.beforeEach(async () => {
// Seed the database with products BEFORE each test
await seedProduct(PRODUCT_ID_A, 'Wireless Mouse', 25.99);
await seedProduct(PRODUCT_ID_B, 'Mechanical Keyboard', 79.50);
});
test('user can add an item to the cart', async ({ page }) => {
// Navigate to the product page for the mouse
await page.goto(`http://localhost:3000/products/${PRODUCT_ID_A}`);
// Get the initial cart count from the database
const initialCartCount = await getCartItemCount(USER_ID, PRODUCT_ID_A);
expect(initialCartCount).toBe(0);
// Click the "Add to Cart" button
await page.click('button:has-text("Add to Cart")');
// Wait for some potential UI feedback or network request completion (optional but good practice)
await page.waitForLoadState('networkidle');
// Verify the cart count directly from the database
const finalCartCount = await getCartItemCount(USER_ID, PRODUCT_ID_A);
expect(finalCartCount).toBe(1);
// Also, verify another product is NOT in the cart
const otherProductCount = await getCartItemCount(USER_ID, PRODUCT_ID_B);
expect(otherProductCount).toBe(0);
});
This setup allows us to isolate the test to the application’s logic for handling cart additions, independent of UI rendering issues or external factors. We’re using Playwright for its browser automation capabilities (navigating, clicking) and our custom db.js module for direct database interaction. The beforeEach hook ensures a clean, predictable state for every test.
The database is the single source of truth here. When you interact with your application (e.g., clicking "Add to Cart"), you’re not just looking at a UI element change; you’re triggering a process that must result in a specific, verifiable change in the database. Playwright, in this context, is the convenient tool that allows you to simulate user actions and then immediately query the state of that source of truth.
Here’s the mental model:
- Test Setup (Seeding): Before any user actions, ensure the database has the necessary prerequisite data. This is often done in
beforeEachorbeforeAllhooks. You’re injecting the "state" your test needs. - User Action Simulation: Use Playwright to perform the actions a user would take in the browser (navigate, click, fill forms).
- State Verification: Immediately after the user action, use your database connection to query the database directly. Assert that the data reflects the expected outcome of the user’s action.
- Isolation: Because you’re verifying against the database, you’re less concerned with the nuances of UI updates or asynchronous JavaScript rendering. You’re testing the core business logic that modifies data.
- Teardown (Optional but Recommended): For more complex scenarios or to prevent test pollution, you might also add
afterEachhooks to clean up specific database records created during the test.
The real power comes when you combine this with data that shouldn’t change. For example, you could test that clicking "Add to Cart" for Product A doesn’t increment the quantity of Product B in the cart, as shown in the example above. This ensures your application’s data modification logic is precise. You’re not just checking for the positive case; you’re also validating that unintended side effects are avoided.
This approach makes your tests more robust because database transactions are typically more atomic and predictable than front-end rendering. If the test fails, you know the problem lies in the application’s backend logic or its interaction with the database, not in a flaky animation or a CSS selector.
What many people miss is that the page.waitForLoadState('networkidle') in the example isn’t strictly necessary for the database verification itself, but it’s crucial for ensuring the application’s action that modifies the database has had a chance to complete. Sometimes, the "add to cart" button click triggers an asynchronous API call that takes a moment. Waiting for networkidle gives that call time to finish before you query the database.
The next step is to integrate this into a CI/CD pipeline, ensuring your database state is managed correctly in different environments.