E2E TESTING

Ecosistema completo para organización y gestión de contenido con interfaces espaciales

E2E Testing Guide - User Management Module

Guide for implementing End-to-End tests with Playwright for the user-management module.

Table of Contents


Overview

E2E tests validate complete user workflows in a real browser environment:

Testing Stack:


Setup

Install Dependencies

npm install -D @playwright/test
npx playwright install

Playwright Configuration

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:4200',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
  webServer: {
    command: 'npx nx serve gograb-space',
    url: 'http://localhost:4200',
    reuseExistingServer: !process.env.CI,
  },
});

Test Scenarios

Critical User Journeys

1. User List and Search

Given: User is on /users page
When: User searches for "john"
Then: Only users matching "john" are displayed

2. Create New User

Given: User clicks "Create User" button
When: User fills form with valid data
And: User clicks "Save"
Then: New user appears in list
And: Success message is shown

3. Edit Existing User

Given: User clicks edit icon for a user
When: User updates first name to "Jane"
And: User clicks "Save"
Then: User name is updated in list
And: Success message is shown

4. Delete User

Given: User clicks delete icon for a user
When: User confirms deletion
Then: User is removed from list
And: Success message is shown

5. Bulk Operations

Given: User selects multiple users
When: User clicks "Activate Selected"
Then: All selected users status changes to "active"
And: Success message shows count

6. Filter and Pagination

Given: User is on /users page
When: User filters by status "active"
And: User navigates to page 2
Then: Only active users from page 2 are shown

Page Object Model

User List Page

// e2e/pages/user-list.page.ts
import { Page, Locator } from '@playwright/test';

export class UserListPage {
  readonly page: Page;
  readonly searchInput: Locator;
  readonly createButton: Locator;
  readonly userCards: Locator;
  readonly statusFilter: Locator;
  readonly roleFilter: Locator;
  readonly paginationNext: Locator;
  readonly paginationPrev: Locator;
  readonly bulkSelectAll: Locator;
  readonly bulkActionButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.searchInput = page.getByPlaceholder('Search users...');
    this.createButton = page.getByRole('button', { name: 'Create User' });
    this.userCards = page.locator('lib-user-card');
    this.statusFilter = page.getByLabel('Status');
    this.roleFilter = page.getByLabel('Role');
    this.paginationNext = page.getByRole('button', { name: 'Next' });
    this.paginationPrev = page.getByRole('button', { name: 'Previous' });
    this.bulkSelectAll = page.getByLabel('Select all users');
    this.bulkActionButton = page.getByRole('button', { name: 'Bulk Actions' });
  }

  async goto() {
    await this.page.goto('/users');
  }

  async search(query: string) {
    await this.searchInput.fill(query);
    await this.searchInput.press('Enter');
  }

  async filterByStatus(status: string) {
    await this.statusFilter.selectOption(status);
  }

  async filterByRole(role: string) {
    await this.roleFilter.selectOption(role);
  }

  async getUserCardByEmail(email: string) {
    return this.page.locator(`lib-user-card:has-text("${email}")`);
  }

  async clickEditForUser(email: string) {
    const card = await this.getUserCardByEmail(email);
    await card.getByRole('button', { name: 'Edit' }).click();
  }

  async clickDeleteForUser(email: string) {
    const card = await this.getUserCardByEmail(email);
    await card.getByRole('button', { name: 'Delete' }).click();
  }

  async selectUsersForBulk(emails: string[]) {
    for (const email of emails) {
      const card = await this.getUserCardByEmail(email);
      await card.getByRole('checkbox').check();
    }
  }

  async performBulkAction(action: string) {
    await this.bulkActionButton.click();
    await this.page.getByRole('menuitem', { name: action }).click();
  }

  async goToNextPage() {
    await this.paginationNext.click();
  }

  async goToPreviousPage() {
    await this.paginationPrev.click();
  }

  async getUserCount() {
    return await this.userCards.count();
  }
}

User Form Page

// e2e/pages/user-form.page.ts
import { Page, Locator } from '@playwright/test';

export class UserFormPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly usernameInput: Locator;
  readonly firstNameInput: Locator;
  readonly lastNameInput: Locator;
  readonly roleSelect: Locator;
  readonly statusSelect: Locator;
  readonly saveButton: Locator;
  readonly cancelButton: Locator;
  readonly errorMessages: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.usernameInput = page.getByLabel('Username');
    this.firstNameInput = page.getByLabel('First Name');
    this.lastNameInput = page.getByLabel('Last Name');
    this.roleSelect = page.getByLabel('Primary Role');
    this.statusSelect = page.getByLabel('Status');
    this.saveButton = page.getByRole('button', { name: 'Save' });
    this.cancelButton = page.getByRole('button', { name: 'Cancel' });
    this.errorMessages = page.locator('.error-message');
  }

  async goto() {
    await this.page.goto('/users/create');
  }

  async gotoEdit(userId: string) {
    await this.page.goto(`/users/${userId}/edit`);
  }

  async fillForm(data: {
    email?: string;
    username?: string;
    firstName?: string;
    lastName?: string;
    role?: string;
    status?: string;
  }) {
    if (data.email) await this.emailInput.fill(data.email);
    if (data.username) await this.usernameInput.fill(data.username);
    if (data.firstName) await this.firstNameInput.fill(data.firstName);
    if (data.lastName) await this.lastNameInput.fill(data.lastName);
    if (data.role) await this.roleSelect.selectOption(data.role);
    if (data.status) await this.statusSelect.selectOption(data.status);
  }

  async save() {
    await this.saveButton.click();
  }

  async cancel() {
    await this.cancelButton.click();
  }

  async getValidationErrors() {
    return await this.errorMessages.allTextContents();
  }
}

User Detail Page

// e2e/pages/user-detail.page.ts
import { Page, Locator } from '@playwright/test';

export class UserDetailPage {
  readonly page: Page;
  readonly userName: Locator;
  readonly userEmail: Locator;
  readonly userStatus: Locator;
  readonly userRoles: Locator;
  readonly editButton: Locator;
  readonly deleteButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.userName = page.locator('.user-name');
    this.userEmail = page.locator('.user-email');
    this.userStatus = page.locator('lib-user-status-badge');
    this.userRoles = page.locator('.role-badge');
    this.editButton = page.getByRole('button', { name: 'Edit' });
    this.deleteButton = page.getByRole('button', { name: 'Delete' });
  }

  async goto(userId: string) {
    await this.page.goto(`/users/${userId}`);
  }

  async edit() {
    await this.editButton.click();
  }

  async delete() {
    await this.deleteButton.click();
  }

  async getRoleNames() {
    return await this.userRoles.allTextContents();
  }
}

Example Tests

User List Tests

// e2e/user-list.spec.ts
import { test, expect } from '@playwright/test';
import { UserListPage } from './pages/user-list.page';

test.describe('User List', () => {
  let userListPage: UserListPage;

  test.beforeEach(async ({ page }) => {
    userListPage = new UserListPage(page);
    await userListPage.goto();
  });

  test('should display users', async () => {
    const count = await userListPage.getUserCount();
    expect(count).toBeGreaterThan(0);
  });

  test('should search users', async ({ page }) => {
    await userListPage.search('john');

    // Wait for search results
    await page.waitForTimeout(500);

    const count = await userListPage.getUserCount();
    expect(count).toBeGreaterThan(0);

    // Verify all results contain 'john'
    const cards = userListPage.userCards;
    const texts = await cards.allTextContents();
    texts.forEach(text => {
      expect(text.toLowerCase()).toContain('john');
    });
  });

  test('should filter by status', async ({ page }) => {
    await userListPage.filterByStatus('active');

    await page.waitForTimeout(500);

    const statusBadges = page.locator('lib-user-status-badge');
    const count = await statusBadges.count();

    for (let i = 0; i < count; i++) {
      const badge = statusBadges.nth(i);
      await expect(badge).toHaveAttribute('data-status', 'active');
    }
  });

  test('should paginate', async ({ page }) => {
    const initialCount = await userListPage.getUserCount();

    await userListPage.goToNextPage();
    await page.waitForTimeout(500);

    const secondPageCount = await userListPage.getUserCount();
    expect(secondPageCount).toBeGreaterThan(0);

    // Users should be different
    // (more complex verification could compare actual user IDs)
  });
});

User CRUD Tests

// e2e/user-crud.spec.ts
import { test, expect } from '@playwright/test';
import { UserListPage } from './pages/user-list.page';
import { UserFormPage } from './pages/user-form.page';

test.describe('User CRUD Operations', () => {
  test('should create new user', async ({ page }) => {
    const userListPage = new UserListPage(page);
    const userFormPage = new UserFormPage(page);

    await userListPage.goto();
    await userListPage.createButton.click();

    // Fill form
    const timestamp = Date.now();
    await userFormPage.fillForm({
      email: `test${timestamp}@example.com`,
      username: `testuser${timestamp}`,
      firstName: 'Test',
      lastName: 'User',
      role: 'user',
      status: 'active'
    });

    await userFormPage.save();

    // Verify redirect to list
    await expect(page).toHaveURL('/users');

    // Verify user appears in list
    const userCard = await userListPage.getUserCardByEmail(`test${timestamp}@example.com`);
    await expect(userCard).toBeVisible();
  });

  test('should edit user', async ({ page }) => {
    const userListPage = new UserListPage(page);
    const userFormPage = new UserFormPage(page);

    await userListPage.goto();

    // Edit first user
    await userListPage.clickEditForUser('[email protected]');

    // Update first name
    await userFormPage.firstNameInput.clear();
    await userFormPage.firstNameInput.fill('Jane');
    await userFormPage.save();

    // Verify redirect
    await expect(page).toHaveURL('/users');

    // Verify update
    const userCard = await userListPage.getUserCardByEmail('[email protected]');
    await expect(userCard).toContainText('Jane');
  });

  test('should delete user', async ({ page }) => {
    const userListPage = new UserListPage(page);

    await userListPage.goto();

    const initialCount = await userListPage.getUserCount();

    // Delete user
    await userListPage.clickDeleteForUser('[email protected]');

    // Confirm deletion
    await page.getByRole('button', { name: 'Confirm' }).click();

    // Wait for deletion
    await page.waitForTimeout(500);

    // Verify user removed
    const newCount = await userListPage.getUserCount();
    expect(newCount).toBe(initialCount - 1);

    // Verify specific user not found
    const userCard = userListPage.page.locator('lib-user-card:has-text("[email protected]")');
    await expect(userCard).not.toBeVisible();
  });
});

Form Validation Tests

// e2e/user-form-validation.spec.ts
import { test, expect } from '@playwright/test';
import { UserFormPage } from './pages/user-form.page';

test.describe('User Form Validation', () => {
  let userFormPage: UserFormPage;

  test.beforeEach(async ({ page }) => {
    userFormPage = new UserFormPage(page);
    await userFormPage.goto();
  });

  test('should show error for invalid email', async () => {
    await userFormPage.emailInput.fill('invalid-email');
    await userFormPage.emailInput.blur();

    const errors = await userFormPage.getValidationErrors();
    expect(errors).toContain('Invalid email format');
  });

  test('should show error for empty required fields', async () => {
    await userFormPage.save();

    const errors = await userFormPage.getValidationErrors();
    expect(errors.length).toBeGreaterThan(0);
  });

  test('should show error for duplicate email', async () => {
    await userFormPage.fillForm({
      email: '[email protected]',
      username: 'newuser',
      firstName: 'New',
      lastName: 'User',
      role: 'user'
    });

    await userFormPage.save();

    const errors = await userFormPage.getValidationErrors();
    expect(errors).toContain('Email already exists');
  });
});

Bulk Operations Tests

// e2e/bulk-operations.spec.ts
import { test, expect } from '@playwright/test';
import { UserListPage } from './pages/user-list.page';

test.describe('Bulk Operations', () => {
  let userListPage: UserListPage;

  test.beforeEach(async ({ page }) => {
    userListPage = new UserListPage(page);
    await userListPage.goto();
  });

  test('should activate multiple users', async ({ page }) => {
    // Select users
    await userListPage.selectUsersForBulk([
      '[email protected]',
      '[email protected]',
      '[email protected]'
    ]);

    // Perform bulk activate
    await userListPage.performBulkAction('Activate Selected');

    // Wait for operation
    await page.waitForTimeout(500);

    // Verify success message
    const successMessage = page.locator('.success-message');
    await expect(successMessage).toContainText('3 users activated');

    // Verify status changed
    for (const email of ['[email protected]', '[email protected]', '[email protected]']) {
      const card = await userListPage.getUserCardByEmail(email);
      const statusBadge = card.locator('lib-user-status-badge');
      await expect(statusBadge).toHaveAttribute('data-status', 'active');
    }
  });

  test('should deactivate multiple users', async ({ page }) => {
    await userListPage.selectUsersForBulk(['[email protected]', '[email protected]']);
    await userListPage.performBulkAction('Deactivate Selected');

    await page.waitForTimeout(500);

    const successMessage = page.locator('.success-message');
    await expect(successMessage).toContainText('2 users deactivated');
  });

  test('should delete multiple users with confirmation', async ({ page }) => {
    await userListPage.selectUsersForBulk(['[email protected]', '[email protected]']);
    await userListPage.performBulkAction('Delete Selected');

    // Confirm deletion
    await page.getByRole('button', { name: 'Confirm' }).click();

    await page.waitForTimeout(500);

    // Verify users removed
    const card1 = userListPage.page.locator('lib-user-card:has-text("[email protected]")');
    const card2 = userListPage.page.locator('lib-user-card:has-text("[email protected]")');

    await expect(card1).not.toBeVisible();
    await expect(card2).not.toBeVisible();
  });
});

Best Practices

1. Use Page Object Model

Keep tests clean by separating page logic:

// ✅ Good
const userListPage = new UserListPage(page);
await userListPage.goto();
await userListPage.search('john');

// ❌ Bad
await page.goto('/users');
await page.getByPlaceholder('Search...').fill('john');

2. Wait for Elements

Always wait for elements to be ready:

// ✅ Good
await expect(element).toBeVisible();
await element.click();

// ❌ Bad
await element.click(); // May fail if element not ready

3. Use Descriptive Test Names

// ✅ Good
test('should display validation error when email format is invalid', async () => {});

// ❌ Bad
test('email test', async () => {});

4. Clean Up Test Data

test.afterEach(async ({ page }) => {
  // Clean up test users created during test
  await cleanupTestUsers();
});

5. Use Fixtures for Common Setup

// e2e/fixtures.ts
import { test as base } from '@playwright/test';
import { UserListPage } from './pages/user-list.page';

export const test = base.extend<{
  userListPage: UserListPage;
}>({
  userListPage: async ({ page }, use) => {
    const userListPage = new UserListPage(page);
    await userListPage.goto();
    await use(userListPage);
  },
});

6. Run Tests in Parallel

// playwright.config.ts
export default defineConfig({
  fullyParallel: true,
  workers: 4, // Run 4 tests concurrently
});

7. Take Screenshots on Failure

test.afterEach(async ({ page }, testInfo) => {
  if (testInfo.status !== testInfo.expectedStatus) {
    await page.screenshot({
      path: `screenshots/${testInfo.title}-${Date.now()}.png`
    });
  }
});

CI/CD Integration

GitHub Actions

# .github/workflows/e2e.yml
name: E2E Tests

on: [push, pull_request]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Build application
        run: npx nx build gograb-space

      - name: Run E2E tests
        run: npx playwright test

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: playwright-report
          path: playwright-report/

Run Tests Locally

# Run all E2E tests
npx playwright test

# Run specific test file
npx playwright test e2e/user-crud.spec.ts

# Run in headed mode (see browser)
npx playwright test --headed

# Run in debug mode
npx playwright test --debug

# Run in specific browser
npx playwright test --project=chromium

# Generate test report
npx playwright show-report

Performance Testing

Measure Page Load Time

test('user list should load within 2 seconds', async ({ page }) => {
  const startTime = Date.now();
  await page.goto('/users');
  await page.waitForLoadState('networkidle');
  const loadTime = Date.now() - startTime;

  expect(loadTime).toBeLessThan(2000);
});

Monitor API Response Times

test('API calls should complete within 1 second', async ({ page }) => {
  let apiCallTime = 0;

  page.on('response', response => {
    if (response.url().includes('/api/users')) {
      apiCallTime = response.timing().responseEnd;
    }
  });

  await page.goto('/users');
  await page.waitForLoadState('networkidle');

  expect(apiCallTime).toBeLessThan(1000);
});

Accessibility Testing

import { injectAxe, checkA11y } from 'axe-playwright';

test('user list should be accessible', async ({ page }) => {
  await page.goto('/users');
  await injectAxe(page);
  await checkA11y(page);
});

Troubleshooting

Common Issues

Issue: Tests timing out

// Solution: Increase timeout
test('slow test', async ({ page }) => {
  test.setTimeout(60000); // 60 seconds
  // ...
});

Issue: Flaky tests

// Solution: Add proper waits
await expect(element).toBeVisible();
await page.waitForLoadState('networkidle');

Issue: Element not found

// Solution: Use more specific selectors
const button = page.getByRole('button', { name: 'Save', exact: true });

Resources