E2E with Playwright Routes, API + UI (AutomationInTesting)

Master API interception, route mocking, and hybrid API+UI testing for robust automation

🕒 Teaching Flow

1. Story Intro (5 min)

👉 "Playwright doesn't just wait for APIs → it can intercept and replace them. Imagine your API is slow, unstable, or not ready yet → you can fake responses and still test your UI. This is critical for QA in early sprints."
API Interception Analogy:
Think of API interception like a restaurant where you can replace the kitchen (backend) with a fake kitchen that serves exactly what you want. The waiter (UI) still works normally, but now you control what food (data) gets served. This is perfect for testing when the real kitchen is broken or not ready yet.

Website & API Recap (10 min)

AutomationInTesting.online - Our test website with Hotel Booking UI + APIs

🏨 Available API Endpoints:

  • GET /api/room → List all available rooms
  • GET /api/room/{id} → Get specific room details
  • POST /api/booking → Create new room booking

📋 Room API Response Example:

{
  "rooms": [
    {
      "roomid": 1,
      "roomName": "101",
      "type": "Single",
      "accessible": true,
      "image": "/images/room1.jpg",
      "description": "Aenean porttitor mauris sit amet lacinia molestie...",
      "features": ["TV", "WiFi", "Safe"],
      "roomPrice": 100
    }
  ]
}
Why This Matters:
  • Real-world application with actual APIs
  • Perfect for learning API + UI integration
  • Stable environment for consistent testing
  • Multiple endpoints to practice different scenarios

Normal Flow (Without Intercept)

📂 tests/normal-flow.spec.js

import { test, expect } from "@playwright/test";

test("Get rooms from API and display - Normal Flow", async ({ page }) => {
  // Step 1: Navigate to the website
  await page.goto("https://automationintesting.online/");
  
  // Step 2: Wait for the page to fully load
  await page.waitForLoadState("networkidle");
  
  // Step 3: Wait for rooms to load (they load via API call)
  await page.waitForSelector(".room");
  
  // Step 4: Get all room information
  const rooms = await page.locator(".room").allTextContents();
  
  // Step 5: Log and verify rooms
  console.log("Rooms found:", rooms);
  expect(rooms.length).toBeGreaterThan(0);
  
  // Step 6: Verify specific room details
  const roomText = rooms.join(" ");
  expect(roomText).toContain("101"); // Room number from API
  expect(roomText).toContain("Single"); // Room type
});
What Happens in Normal Flow:
  1. Browser requests real room API data from /api/room
  2. API returns actual room information (roomid, roomName, type, price, etc.)
  3. UI displays the real room data on the website
  4. Test validates what's actually on the server

Intercepting with `page.route()` (Fake Response)

📂 tests/intercept-fake-response.spec.js

import { test, expect } from "@playwright/test";

test("Intercept rooms API with fake response", async ({ page }) => {
  // Step 1: Create fake room data
  const fakeResponse = {
    rooms: [
      {
        roomid: 1,
        roomName: "101",
        type: "Single",
        accessible: true,
        image: "/images/room1.jpg",
        description: "This is a fake room for testing",
        features: ["TV", "WiFi", "Safe"],
        roomPrice: 100
      },
      {
        roomid: 2,
        roomName: "102",
        type: "Double",
        accessible: true,
        image: "/images/room2.jpg",
        description: "Another fake room for testing",
        features: ["TV", "Radio", "Safe"],
        roomPrice: 150
      }
    ]
  };

  // Step 2: Set up route interception
  // Why "**/room"? The ** means "match any path that contains /room"
  // This will intercept both /api/room and /api/room/1
  await page.route("**/room", async route => {
    console.log("🚫 Intercepted API call:", route.request().url());
    console.log("🔍 Request method:", route.request().method());
    
    // Step 3: Get original response (optional - for learning)
    const originalResponse = await page.request.fetch(route.request());
    console.log("📡 Original response status:", originalResponse.status());
    
    // Step 4: Replace with fake response
    await route.fulfill({
      status: 200,
      body: JSON.stringify(fakeResponse),
      headers: { 
        "Content-Type": "application/json",
        "Access-Control-Allow-Origin": "*"
      }
    });
  });

  // Step 5: Navigate to website
  await page.goto("https://automationintesting.online/");
  
  // Step 6: Wait for rooms to load
  await page.waitForSelector(".room");
  
  // Step 7: Verify fake data is displayed
  const roomText = await page.locator(".room").first().textContent();
  console.log("📋 Room shown on UI:", roomText);
  
  // Step 8: Assert fake data appears
  expect(roomText).toContain("101");
  expect(roomText).toContain("Single");
  expect(roomText).toContain("This is a fake room for testing");
});
💡 Concept Flow Explained:
  1. Browser → asks for GET /api/room
  2. Playwright → intercepts the request using **/room pattern
  3. QA → injects fake room response data
  4. Browser → renders fake room data on UI
  5. Test → validates fake room data appears correctly
Why Use "**/room" Pattern?
  • ** = matches any path before "/room"
  • Intercepts: /api/room, /api/room/1, /api/room/2
  • Flexible: One pattern catches multiple endpoints
  • Specific: Only intercepts room-related API calls
Why Use Fake Responses?
  • Unstable APIs: When backend is down or slow
  • Development Phase: When APIs aren't ready yet
  • Consistent Testing: Same data every time
  • Edge Cases: Test specific scenarios easily

Using API + UI Together (Create Booking)

📂 tests/hybrid-api-ui.spec.js

import { test, expect } from "@playwright/test";

test("Create room booking via API, validate in UI - Hybrid Test", async ({ request, page }) => {
  // Step 1: Create room booking via API
  console.log("🚀 Step 1: Creating room booking via API...");
  
  const apiResponse = await request.post("https://automationintesting.online/api/booking", {
    data: {
      roomid: 2,
      firstname: "QA",
      lastname: "Student",
      depositpaid: false,
      bookingdates: {
        checkin: "2025-09-23",
        checkout: "2025-09-27"
      },
      email: "qa.student@test.com",
      phone: "98765 45678"
    },
  });

  // Step 2: Verify API response
  expect(apiResponse.ok()).toBeTruthy();
  expect(apiResponse.status()).toBe(201);
  
  const responseBody = await apiResponse.json();
  console.log("✅ Booking created:", responseBody);
  
  // Step 3: Navigate to UI
  console.log("🌐 Step 2: Navigating to UI...");
  await page.goto("https://automationintesting.online/");
  await page.waitForLoadState("networkidle");

  // Step 4: Wait for rooms to load
  await page.waitForSelector(".room");
  
  // Step 5: Validate room information in UI
  console.log("🔍 Step 3: Validating room data in UI...");
  const roomText = await page.locator(".room").allTextContents();
  const allRoomsText = roomText.join(" ");
  
  console.log("📋 All rooms text:", allRoomsText);
  
  // Step 6: Assert room details in UI
  expect(allRoomsText).toContain("102"); // Room number from API
  expect(allRoomsText).toContain("Double"); // Room type
  expect(allRoomsText).toContain("150"); // Room price
  
  console.log("✅ Hybrid test completed successfully!");
});

📋 Expected Booking API Response:

{
  "bookingid": 123,
  "roomid": 2,
  "firstname": "QA",
  "lastname": "Student",
  "depositpaid": false,
  "bookingdates": {
    "checkin": "2025-09-23",
    "checkout": "2025-09-27"
  },
  "email": "qa.student@test.com",
  "phone": "98765 45678"
}
✅ This is Hybrid Testing:
  • Create booking via API (fast and reliable)
  • Check UI displays room data correctly
  • Stable + Fast - best of both worlds
  • Real-world scenario - how users actually interact

Advanced Route Interception Patterns

Multiple Route Interception

📂 tests/multiple-routes.spec.js

import { test, expect } from "@playwright/test";

test("Intercept multiple room API endpoints", async ({ page }) => {
  // Step 1: Intercept GET /api/room (all rooms)
  await page.route("**/api/room", async route => {
    if (route.request().method() === 'GET' && !route.request().url().includes('/api/room/')) {
      const fakeRooms = {
        rooms: [
          { 
            roomid: 1, 
            roomName: "101", 
            type: "Single", 
            roomPrice: 100,
            features: ["TV", "WiFi"]
          },
          { 
            roomid: 2, 
            roomName: "102", 
            type: "Double", 
            roomPrice: 150,
            features: ["TV", "Radio"]
          }
        ]
      };
      
      await route.fulfill({
        status: 200,
        body: JSON.stringify(fakeRooms),
        headers: { "Content-Type": "application/json" }
      });
    } else {
      // Let other methods (POST) pass through
      await route.continue();
    }
  });

  // Step 2: Intercept specific room by ID
  await page.route("**/api/room/1", async route => {
    const fakeRoom = {
      roomid: 1,
      roomName: "101",
      type: "Single",
      accessible: true,
      image: "/images/room1.jpg",
      description: "This is a fake single room for testing",
      features: ["TV", "WiFi", "Safe"],
      roomPrice: 100
    };
    
    await route.fulfill({
      status: 200,
      body: JSON.stringify(fakeRoom),
      headers: { "Content-Type": "application/json" }
    });
  });

  await page.goto("https://automationintesting.online/");
  await page.waitForSelector(".room");
  
  const roomText = await page.locator(".room").textContent();
  expect(roomText).toContain("101");
  expect(roomText).toContain("Single");
});

Conditional Route Interception

📂 tests/conditional-routes.spec.js

import { test, expect } from "@playwright/test";

test("Conditional route interception based on request", async ({ page }) => {
  await page.route("**/room**", async route => {
    const url = route.request().url();
    const method = route.request().method();
    
    console.log(`🔍 Intercepted: ${method} ${url}`);
    
    // Different responses based on request type
    if (method === 'GET' && url.includes('/api/room/')) {
      // Single room request (e.g., /api/room/1)
      const singleRoom = {
        roomid: 1,
        roomName: "101",
        type: "Single",
        accessible: true,
        image: "/images/room1.jpg",
        description: "This is a single room for testing",
        features: ["TV", "WiFi", "Safe"],
        roomPrice: 100
      };
      
      await route.fulfill({
        status: 200,
        body: JSON.stringify(singleRoom),
        headers: { "Content-Type": "application/json" }
      });
    } else if (method === 'GET') {
      // All rooms request (e.g., /api/room)
      const allRooms = {
        rooms: [
          { 
            roomid: 1, 
            roomName: "101", 
            type: "Single", 
            roomPrice: 100,
            features: ["TV", "WiFi"]
          },
          { 
            roomid: 2, 
            roomName: "102", 
            type: "Double", 
            roomPrice: 150,
            features: ["TV", "Radio"]
          }
        ]
      };
      
      await route.fulfill({
        status: 200,
        body: JSON.stringify(allRooms),
        headers: { "Content-Type": "application/json" }
      });
    } else {
      // Let other methods pass through
      await route.continue();
    }
  });

  await page.goto("https://automationintesting.online/");
  await page.waitForSelector(".room");
  
  const roomText = await page.locator(".room").textContent();
  expect(roomText).toContain("101");
  expect(roomText).toContain("102");
});

Fail + Debug with Trace Viewer (10 min)

Configure Trace Recording

📂 playwright.config.js

import { defineConfig } from "@playwright/test";

export default defineConfig({
  use: {
    // Debugging configuration
    trace: "retain-on-failure", // Keep trace files for failed tests
    screenshot: "only-on-failure", // Take screenshots only when tests fail
    video: "retain-on-failure", // Record videos only for failed tests
    
    // Additional debugging options
    headless: false, // Run in headed mode to see what's happening
    slowMo: 1000, // Slow down operations by 1 second
  },
  
  // Test configuration
  retries: 2, // Retry failed tests twice
  timeout: 30000, // 30 second timeout per test
  
  // Reporter configuration
  reporter: [
    ['html'], // HTML report with screenshots and videos
    ['json', { outputFile: 'test-results.json' }], // JSON report
  ],
});

Create Failing Test for Debugging

📂 tests/debug-failing-test.spec.js

import { test, expect } from "@playwright/test";

test("Failing test for debugging with trace", async ({ page }) => {
  // Step 1: Set up route interception
  await page.route("**/booking", async route => {
    // Intentionally return invalid data to cause failure
    const invalidResponse = {
      bookings: [
        {
          firstname: "Debug",
          lastname: "Test",
          roomid: "invalid", // This might cause UI issues
          checkin: "invalid-date", // Invalid date format
          checkout: "invalid-date"
        }
      ]
    };
    
    await route.fulfill({
      status: 200,
      body: JSON.stringify(invalidResponse),
      headers: { "Content-Type": "application/json" }
    });
  });

  // Step 2: Navigate to website
  await page.goto("https://automationintesting.online/");
  await page.waitForLoadState("networkidle");
  
  // Step 3: Wait for bookings (this might fail)
  await page.waitForSelector(".bookings", { timeout: 5000 });
  
  // Step 4: This assertion will likely fail
  const bookingText = await page.locator(".bookings").textContent();
  expect(bookingText).toContain("ValidBooking"); // This will fail
  
  // Step 5: Take screenshot for debugging
  await page.screenshot({ path: 'debug-screenshot.png' });
});

Run and Debug

# Run the failing test
npx playwright test tests/debug-failing-test.spec.js --trace on

# Open trace viewer to debug
npx playwright show-trace test-results/chromium/debug-failing-test.spec.js-trace.zip

# Open HTML report
npx playwright show-report
What to Look for in Trace Viewer:
  • Network Tab: See intercepted requests and responses
  • Console Tab: Check for JavaScript errors
  • Timeline: See exactly when the test failed
  • DOM Snapshot: View page state at failure point

Visual Test for Intercepted Data (10 min)

📂 tests/visual-intercepted-data.spec.js

import { test, expect } from "@playwright/test";

test("Visual Snapshot of fake booking data", async ({ page }) => {
  // Step 1: Create fake booking response
  const fakeResponse = {
    bookings: [
      { 
        firstname: "Visual", 
        lastname: "Check", 
        roomid: 2, 
        checkin: "2025-09-20", 
        checkout: "2025-09-22",
        totalprice: 200,
        depositpaid: true,
        additionalneeds: "WiFi + Breakfast"
      },
      {
        firstname: "Test",
        lastname: "User",
        roomid: 3,
        checkin: "2025-09-25",
        checkout: "2025-09-27",
        totalprice: 300,
        depositpaid: false,
        additionalneeds: "Parking"
      }
    ]
  };

  // Step 2: Intercept API call
  await page.route("**/booking", route =>
    route.fulfill({
      status: 200,
      body: JSON.stringify(fakeResponse),
      headers: { "Content-Type": "application/json" }
    })
  );

  // Step 3: Navigate and wait for data
  await page.goto("https://automationintesting.online/");
  await page.waitForSelector(".bookings");
  
  // Step 4: Wait for animations to complete
  await page.waitForTimeout(1000);
  
  // Step 5: Take visual snapshot
  await expect(page).toHaveScreenshot("fake-booking-visual.png");
  
  // Step 6: Also take snapshot of just the bookings section
  const bookingsSection = page.locator(".bookings");
  await expect(bookingsSection).toHaveScreenshot("bookings-section-only.png");
});

test("Visual test with different fake data", async ({ page }) => {
  // Different fake data for comparison
  const differentFakeResponse = {
    bookings: [
      { 
        firstname: "Different", 
        lastname: "Data", 
        roomid: 1, 
        checkin: "2025-10-01", 
        checkout: "2025-10-05"
      }
    ]
  };

  await page.route("**/booking", route =>
    route.fulfill({
      status: 200,
      body: JSON.stringify(differentFakeResponse),
      headers: { "Content-Type": "application/json" }
    })
  );

  await page.goto("https://automationintesting.online/");
  await page.waitForSelector(".bookings");
  await page.waitForTimeout(1000);
  
  // This will create a different baseline
  await expect(page).toHaveScreenshot("different-fake-booking.png");
});
Visual Testing with Intercepted Data:
  • Consistent Screenshots: Same fake data = same visual output
  • Predictable Results: No random data affecting visual comparisons
  • Edge Case Testing: Test specific UI states easily
  • Regression Prevention: Catch visual changes in UI components

🧑‍💻 Interactive Questions & Answers

1. What's the difference between `route.fulfill()` and `route.continue()`?

Answer:
  • route.fulfill(): Replaces the request with your own response
  • route.continue(): Lets the original request proceed normally
  • Use fulfill() when: You want to mock/fake the response
  • Use continue() when: You want to let the real API handle the request
  • Example:
    // Mock the response
    await route.fulfill({
      status: 200,
      body: JSON.stringify({ fake: "data" })
    });
    
    // Let real API handle it
    await route.continue();

2. Why call `page.request.fetch(route.request())` before fulfilling?

Answer:
  • Get Original Response: See what the real API would return
  • Debugging: Compare real vs fake responses
  • Partial Mocking: Modify only specific fields from real response
  • Learning: Understand the real API structure
  • Example:
    await page.route("**/booking", async route => {
      // Get real response first
      const realResponse = await page.request.fetch(route.request());
      const realData = await realResponse.json();
      
      // Modify only specific fields
      realData.bookings[0].firstname = "Modified";
      
      // Return modified response
      await route.fulfill({
        body: JSON.stringify(realData)
      });
    });

3. How does mocking API help when backend is unstable?

Answer:
  • Consistent Testing: Same data every time, no random failures
  • Faster Execution: No waiting for slow or down APIs
  • Isolated Testing: Test UI logic without backend dependencies
  • Development Phase: Test UI before backend is ready
  • Edge Cases: Test specific scenarios easily
  • Example:
    // When backend is down, mock successful response
    await page.route("**/booking", route => {
      route.fulfill({
        status: 200,
        body: JSON.stringify({ bookings: [] })
      });
    });
    
    // Test continues normally even if backend is down

4. Can we mock partial response fields instead of full response?

Answer:
  • Yes, absolutely! You can modify only specific fields
  • Get Real Response: Fetch original data first
  • Modify Fields: Change only what you need to test
  • Return Modified: Send back the modified response
  • Example:
    await page.route("**/booking", async route => {
      // Get real response
      const realResponse = await page.request.fetch(route.request());
      const realData = await realResponse.json();
      
      // Modify only specific fields
      if (realData.bookings && realData.bookings.length > 0) {
        realData.bookings[0].firstname = "Modified Name";
        realData.bookings[0].totalprice = 999;
      }
      
      // Return modified response
      await route.fulfill({
        body: JSON.stringify(realData)
      });
    });

5. What happens if your fake response doesn't match UI schema?

Answer:
  • UI Errors: JavaScript errors if UI expects specific fields
  • Display Issues: Missing or broken UI elements
  • Test Failures: Assertions might fail unexpectedly
  • Debugging Needed: Use trace viewer to see what went wrong
  • Best Practice: Match the real API structure exactly
  • Example:
    // Bad: Missing required fields
    const badResponse = { firstname: "John" }; // Missing lastname, roomid, etc.
    
    // Good: Complete response structure
    const goodResponse = {
      bookings: [{
        firstname: "John",
        lastname: "Doe",
        roomid: 1,
        checkin: "2025-09-20",
        checkout: "2025-09-25",
        totalprice: 150,
        depositpaid: true,
        additionalneeds: "WiFi"
      }]
    };

6. How do you handle authentication in route interception?

Answer:
  • Check Headers: Verify authentication tokens in request headers
  • Mock Auth Responses: Return fake auth data when needed
  • Bypass Auth: Skip authentication for testing purposes
  • Example:
    await page.route("**/booking", async route => {
      const headers = route.request().headers();
      const authToken = headers['authorization'];
      
      if (!authToken) {
        // Return unauthorized response
        await route.fulfill({
          status: 401,
          body: JSON.stringify({ error: "Unauthorized" })
        });
      } else {
        // Return fake booking data
        await route.fulfill({
          status: 200,
          body: JSON.stringify({ bookings: [] })
        });
      }
    });

7. How do you test error scenarios with route interception?

Answer:
  • Mock Error Responses: Return 404, 500, or other error status codes
  • Test Error Handling: Verify UI handles errors gracefully
  • Network Failures: Simulate network timeouts or failures
  • Example:
    // Test 404 error
    await page.route("**/booking", route => {
      route.fulfill({
        status: 404,
        body: JSON.stringify({ error: "Not found" })
      });
    });
    
    // Test 500 error
    await page.route("**/booking", route => {
      route.fulfill({
        status: 500,
        body: JSON.stringify({ error: "Internal server error" })
      });
    });
    
    // Test network timeout
    await page.route("**/booking", route => {
      route.abort('failed'); // Simulate network failure
    });

8. How do you organize route interception in large test suites?

Answer:
  • Utility Functions: Create reusable route setup functions
  • Test Fixtures: Use Playwright fixtures for common setups
  • Configuration Files: Store mock data in separate files
  • Example:
    // utils/route-helpers.js
    export async function setupBookingRoutes(page, mockData) {
      await page.route("**/booking", route => {
        route.fulfill({
          status: 200,
          body: JSON.stringify(mockData)
        });
      });
    }
    
    // In test file
    import { setupBookingRoutes } from '../utils/route-helpers.js';
    
    test("My test", async ({ page }) => {
      const mockData = { bookings: [] };
      await setupBookingRoutes(page, mockData);
      // ... rest of test
    });

🎯 Day 10 Homework Tasks

🟢 Beginner

Task 1

Intercept GET /api/room → show single fake room with your custom description and verify it appears in UI.

Task 2

Assert UI displays Playwright Test Room by intercepting the room API call and providing fake room data.

🟡 Intermediate

Task 3

Create a room booking via POST /api/booking → validate room data appears in UI (complete hybrid test with proper error handling).

Task 4

Fail test on purpose by providing invalid room mock data → debug with Trace Viewer and identify the issue.

🔴 Advanced

Task 5

Write test that intercepts GET /api/room and injects 5 fake rooms dynamically from an array with different room types and prices.

Task 6

Add visual snapshot test for the new fake rooms list and create a utility function for reusable route interception.

Best Practices & Tips

Route Interception Best Practices:
  • Always match the real API response structure exactly
  • Use meaningful fake data that represents real scenarios
  • Test both success and error scenarios with mocked responses
  • Clean up route interceptions after each test
  • Use descriptive names for mock data and responses
Common Pitfalls:
  • Don't forget to handle CORS headers in mocked responses
  • Avoid hardcoded mock data - use variables for reusability
  • Don't mock everything - let some real API calls pass through
  • Be careful with async/await in route handlers
  • Don't ignore error handling in route interception
Hybrid Testing Tips:
  • Use API calls for data setup and validation
  • Use UI testing for user interaction flows
  • Combine both approaches for comprehensive coverage
  • Test data consistency between API and UI
  • Use route interception to test edge cases and error scenarios

✅ Outcomes

  • Understand Playwright routes & API interception concepts
  • Fake API responses to test UI rendering with controlled data
  • Run hybrid API + UI validation tests
  • Debug failures with Trace Viewer + screenshots + videos
  • Add Visual Testing for intercepted responses
  • Handle authentication and error scenarios in route interception
  • Organize and maintain route interception in large test suites
  • Apply best practices for reliable and maintainable test automation