Học Playwright tiếng Việt, Cộng đồng Playwright cho người Việt

Khóa học, Vọc Vạch Playwright

Playwright fixture – Phần 2: Ứng dụng thực tế, best practices

Tiếp nối Playwright fixture – Phần 1: định nghĩa, built-in fixture, custom fixture, hôm nay chúng ta cùng đi vào các nội dung nâng cao hơn: ứng dụng thực tế, best practices.

Ứng dụng thực tế

Dưới đây là các ứng dụng thực tế của fixture. Thay vì viết test rời rạc, ta xây dựng ecosystem có thể tái sử dụng, dễ maintain và scale theo nhu cầu dự án.

Page Object Fixture

Page Object Pattern gặp Fixture – đây chính là combo hoàn hảo! Thay vì khởi tạo Page Object rồi navigate trong từng test, ta để fixture lo việc đó.

Kết quả thì sao: test code gọn gàng, tập trung vào business logic.

// fixtures/page-objects.ts
import { HomePage } from './pages/HomePage';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';

export const test = base.extend<{
  homePage: HomePage;
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  loggedInDashboard: DashboardPage;
}>({
  homePage: async ({ page }, use) => {
    const homePage = new HomePage(page);
    await homePage.goto();
    await use(homePage);
  },
  
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await use(loginPage);
  },
  
  dashboardPage: async ({ page }, use) => {
    // Chỉ tạo object, không navigate
    await use(new DashboardPage(page));
  },
  
  loggedInDashboard: async ({ loginPage, dashboardPage }, use) => {
    // Combo fixture: auto login và navigate
    await loginPage.login('user@example.com', 'password');
    await dashboardPage.waitForLoad();
    await use(dashboardPage);
  }
});

// Sử dụng fixture
test('dashboard features', async ({ loggedInDashboard }) => {
  // Boom! Đã có dashboard ready to use
  await loggedInDashboard.createNewProject('My Project');
  await expect(loggedInDashboard.projectList).toContainText('My Project');
});

test('login flow', async ({ homePage, loginPage }) => {
  // HomePage đã được load sẵn
  await homePage.clickLogin();
  
  // LoginPage cũng ready luôn
  await loginPage.fillCredentials('user@test.com', 'password123');
  await loginPage.submit();
  
  await expect(loginPage.page).toHaveURL('/dashboard');
});

Lợi ích có thể thấy ngay: Page Object được khởi tạo tự động, navigation logic tập trung ở fixture thay vì rải rác trong test, và quan trọng nhất – test code chỉ cần focus vào cái mình muốn test thôi!

E-commerce Testing Scenario

Một trang thương mại điện tử cần test từ A đến Z: sản phẩm, giỏ hàng, thanh toán. Thay vì setup từng cái một trong mỗi test, ta tạo fixture ecosystem hoàn chỉnh:

// fixtures/ecommerce.ts
type Product = {
  id: string;
  name: string;
  price: number;
  stock: number;
};

type Cart = {
  items: Array<{ product: Product; quantity: number }>;
  total: number;
};

const test = base.extend<{
  productCatalog: Product[];
  shoppingCart: Cart;
  checkoutPage: CheckoutPage;
  orderAPI: OrderAPI;
}>({
  // Product catalog với test data có sẵn
  productCatalog: async ({ request }, use) => {
    const products: Product[] = [
      { id: '1', name: 'Laptop', price: 999, stock: 10 },
      { id: '2', name: 'Mouse', price: 29, stock: 100 },
      { id: '3', name: 'Keyboard', price: 79, stock: 50 }
    ];
    
    // Seed products vào API
    for (const product of products) {
      await request.post('/api/products', { data: product });
    }
    
    await use(products);
    
    // Dọn dẹp sau khi xong
    for (const product of products) {
      await request.delete(`/api/products/${product.id}`);
    }
  },
  
  // Shopping cart với session và helper methods
  shoppingCart: async ({ page, context }, use) => {
    // Tạo session cho cart
    await context.addCookies([{
      name: 'session_id',
      value: `test_${Date.now()}`,
      domain: 'localhost',
      path: '/'
    }]);
    
    const cart: Cart = {
      items: [],
      total: 0
    };
    
    // Helper methods để thao tác cart dễ dàng
    const cartHelpers = {
      ...cart,
      addItem: async (productId: string, quantity: number) => {
        await page.goto(`/api/cart/add`, {
          method: 'POST',
          body: JSON.stringify({ productId, quantity })
        });
      },
      checkout: async () => {
        await page.goto('/checkout');
      }
    };
    
    await use(cartHelpers);
    
    // Clear cart khi xong test
    await page.goto('/api/cart/clear', { method: 'POST' });
  }
});

Multi-user Testing với Shared Fixtures

Cần test tính năng chat real-time? Phải có nhiều user cùng lúc. Fixture giúp tạo và quản lý nhiều session một cách dễ dàng:

// fixtures/multi-user.ts
const test = base.extend<{
  createUserSession: (role: UserRole) => Promise<UserSession>;
  adminSession: UserSession;
  userSession: UserSession;
}>({
  // Factory pattern để tạo user sessions
  createUserSession: async ({ browser }, use) => {
    const sessions: UserSession[] = [];
    
    const factory = async (role: UserRole): Promise<UserSession> => {
      const user = await createTestUser(role);
      const context = await browser.newContext({
        storageState: {
          cookies: [{
            name: 'auth_token',
            value: user.authToken,
            domain: 'localhost',
            path: '/'
          }]
        }
      });
      
      const page = await context.newPage();
      const session = { user, page, context };
      sessions.push(session);
      
      return session;
    };
    
    await use(factory);
    
    // Cleanup tất cả sessions
    for (const session of sessions) {
      await session.context.close();
      await deleteTestUser(session.user.id);
    }
  }
});

// Test real-time interaction
test('chat giữa admin và user', async ({ 
  adminSession,
  userSession,
  chatRoom 
}) => {
  // Admin gửi message
  await adminSession.page.fill('.message-input', 'Hello!');
  await adminSession.page.press('.message-input', 'Enter');
  
  // User nhận được ngay lập tức
  await expect(userSession.page.locator('.message').last())
    .toContainText('Hello!');
});

Cross-browser Testing Setup

Responsive design phải test trên nhiều browser và device. Fixture giúp abstract away những complexity này:

// fixtures/cross-browser.ts
const test = base.extend<{
  mobilePage: Page;
  desktopPage: Page;
  crossBrowserTest: (callback: Function) => Promise<void>;
}>({
  mobilePage: async ({ browser }, use) => {
    const context = await browser.newContext({
      viewport: { width: 390, height: 844 },
      userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)',
      isMobile: true,
      hasTouch: true
    });
    
    const page = await context.newPage();
    await use(page);
    await context.close();
  }
});

// Test responsive design
test('layout responsive trên mobile và desktop', async ({ 
  mobilePage, 
  desktopPage 
}) => {
  // Mobile: menu hamburger
  await mobilePage.goto('/');
  await expect(mobilePage.locator('.mobile-menu')).toBeVisible();
  
  // Desktop: nav bar đầy đủ
  await desktopPage.goto('/');
  await expect(desktopPage.locator('.desktop-nav')).toBeVisible();
});

Best Practices

Naming Convention

Hãy đặt một cái tên fixture thật “kêu” và ngắn gọn. Việc này sẽ giúp đồng đội của chúng ta dễ hiểu, đoán được ý định của fixture một cách dễ dàng.

// ✅ Good naming
const test = base.extend({
  // Nouns cho resources
  database: async ({}, use) => { /* ... */ },
  apiClient: async ({}, use) => { /* ... */ },
  testUser: async ({}, use) => { /* ... */ },

  // Adjective + Noun cho state
  authenticatedPage: async ({}, use) => { /* ... */ },
  adminContext: async ({}, use) => { /* ... */ },

  // Verb cho actions/factories
  createUser: async ({}, use) => { /* ... */ },
  mockAPI: async ({}, use) => { /* ... */ }
});

// ❌ Bad naming
const test = base.extend({
  db: async ({}, use) => { /* ... */ },        // Too short
  test: async ({}, use) => { /* ... */ },      // Confusing
  doLogin: async ({}, use) => { /* ... */ }    // Verb for resource
});

Scope Management

Sử dụng scope cho phù hợp: worker-level, test-level. Việc dùng đúng scope sẽ giúp tối ưu hiệu suất, quản lý tài nguyên hiệu quả hơn; đảm bảo tính độc lập của test và kiểm soát được life cycle của test.

Trong ví dụ dưới đây, chúng ta tạo ra 2 fixture:

  • tempFile: có scope là test-level, sẽ tự động được tạo mới mỗi khi test mới chạy. Việc này giúp đảm bảo tính độc lập giữa các file
  • sharedDatabase: có scope là worker, sẽ được tạo một lần duy nhất mỗi khi worker start. Việc này giúp tiết kiệm tài nguyên, dùng lại shared resource giữa các test.
// Test scope - Default, isolated per test
const test = base.extend({
  tempFile: async ({}, use) => {
    const file = await createTempFile();
    await use(file);
    await deleteTempFile(file);
  }
});

// Worker scope - Shared across tests in worker
const test = base.extend({
  sharedDatabase: [async ({}, use) => {
    console.log('Setting up shared database...');
    const db = await createDatabase();
    await use(db);
    await dropDatabase(db);
  }, { scope: 'worker' }]
});

// Guidelines:
// - Use test scope for: test data, page objects, mocks
// - Use worker scope for: expensive resources (DB, servers)
// - Consider parallelism when using worker scope

Error Handling

Đừng quên: fixture cũng chỉ là một đoạn code bình thường thôi. Mà code nào chẳng có thể…lỗi. Vì vậy việc tính tới tình huống có lỗi xảy ra và xử lý nó là một việc vô cùng cần thiết.

const test = base.extend({
  apiClient: async ({}, use) => {
    let client;

    try {
      client = new APIClient();
      await client.connect();
      await use(client);
    } catch (error) {
      console.error('Fixture setup failed:', error);
      throw error; // Re-throw to fail test
    } finally {
      // Cleanup ALWAYS runs
      if (client?.isConnected()) {
        await client.disconnect();
      }
    }
  },

  retryableResource: async ({}, use) => {
    let resource;
    let attempts = 3;

    // Retry logic for flaky resources
    while (attempts > 0) {
      try {
        resource = await acquireResource();
        break;
      } catch (error) {
        attempts--;
        if (attempts === 0) throw error;
        await new Promise(r => setTimeout(r, 1000));
      }
    }

    await use(resource);
    await releaseResource(resource);
  }
});

Performance Considerations

Fixture không chỉ là code, mà còn chạy code – và code chạy thì phải chạy nhanh thôi! Một test suite chậm chạp là một test suite ít ai muốn chạy. Vậy nên tối ưu hiệu năng cho fixture là việc không thể thiếu.

// 1. Lazy initialization - Tạo khi cần, không tạo lung tung
const test = base.extend({
  heavyResource: async ({}, use) => {
    let resource;

    // Lazy getter - chỉ tạo khi thực sự cần
    const getResource = async () => {
      if (!resource) {
        resource = await createHeavyResource();
      }
      return resource;
    };

    await use(getResource);

    if (resource) {
      await resource.cleanup();
    }
  }
});

// 2. Parallel setup - Làm nhiều việc cùng lúc
const test = base.extend({
  testEnvironment: async ({ page }, use) => {
    // Setup song song, không chờ nhau
    const [api, db, cache] = await Promise.all([
      setupAPI(),
      setupDatabase(),
      setupCache()
    ]);

    await use({ api, db, cache, page });

    // Cleanup cũng song song luôn
    await Promise.all([
      api.cleanup(),
      db.cleanup(),
      cache.cleanup()
    ]);
  }
});

// 3. Resource pooling - Tái sử dụng thay vì tạo mới
const connectionPool = [];

const test = base.extend({
  dbConnection: async ({}, use) => {
    // Lấy connection có sẵn trước
    let conn = connectionPool.pop();
    if (!conn) {
      conn = await createConnection();
    }

    await use(conn);

    // Reset và trả về pool để test khác dùng
    await conn.reset();
    connectionPool.push(conn);
  }
});

Ba chiêu này giúp fixture chạy nhanh hơn hẳn: lazy initialization tránh tạo những thứ không dùng, parallel setup giảm thời gian chờ, còn resource pooling thì tái sử dụng thay vì tạo đi tạo lại.

Reusability và Maintainability

Fixture viết một lần, xài mãi mãi – đó mới là “cảnh giới” cao nhất! Thay vì copy-paste code fixture khắp nơi rồi sau này sửa một chỗ phải sửa trăm chỗ, hãy tổ chức chúng thành các module có thể tái sử dụng và bảo trì dễ dàng.

// fixtures/base.ts - Base fixtures
export const baseTest = base.extend({
  config: async ({}, use) => {
    await use(loadConfig());
  }
});

// fixtures/api.ts - API fixtures  
export const apiTest = baseTest.extend({
  apiClient: async ({ config }, use) => {
    const client = new APIClient(config.api);
    await use(client);
  }
});

// fixtures/auth.ts - Auth fixtures
export const authTest = apiTest.extend({
  loginAs: async ({ apiClient }, use) => {
    // Factory for different user types
    await use(async (userType: 'admin' | 'user') => {
      const creds = getCredentials(userType);
      return await apiClient.login(creds);
    });
  }
});

// tests/feature.spec.ts - Compose fixtures
import { authTest as test } from '../fixtures/auth';

test('admin features', async ({ loginAs, page }) => {
  const adminAuth = await loginAs('admin');
  // Use admin auth...
});

Cách tổ chức này như xây nhà vậy: base làm móng, api làm tường, auth làm mái. Mỗi layer tái sử dụng layer dưới, test thì chỉ cần import cái gần nhất. Muốn thêm tính năng mới? Extend thêm một layer. Cần sửa config? Chỉ sửa ở base.ts. Đơn giản, sạch sẽ, dễ maintain!

Troubleshooting thường gặp

Fixture Dependency Issues

// ❌ Circular dependency
const test = base.extend({
  fixtureA: async ({ fixtureB }, use) => {
    await use('A');
  },
  fixtureB: async ({ fixtureA }, use) => {
    await use('B');
  }
});
// Error: Circular dependency between fixtures

// ✅ Solution: Refactor dependencies
const test = base.extend({
  baseFixture: async ({}, use) => {
    await use({ shared: 'data' });
  },
  fixtureA: async ({ baseFixture }, use) => {
    await use({ ...baseFixture, a: 'A' });
  },
  fixtureB: async ({ baseFixture }, use) => {
    await use({ ...baseFixture, b: 'B' });
  }
});

// ❌ Wrong dependency order
const test = base.extend({
  database: async ({ authenticatedUser }, use) => {
    // Needs user but user needs database!
    await use(db);
  },
  authenticatedUser: async ({ database }, use) => {
    const user = await database.createUser();
    await use(user);
  }
});

// ✅ Solution: Proper layering
const test = base.extend({
  database: async ({}, use) => {
    await use(db);
  },
  userFactory: async ({ database }, use) => {
    await use({
      create: () => database.createUser()
    });
  },
  authenticatedUser: async ({ userFactory }, use) => {
    const user = await userFactory.create();
    await use(user);
  }
});

Memory Leaks trong Fixture

// ❌ Memory leak - không cleanup
const test = base.extend({
  leakyFixture: async ({}, use) => {
    const bigArray = new Array(1000000).fill('data');
    global.leakedData = bigArray; // Leaked to global!

    await use(bigArray);
    // Không cleanup!
  }
});

// ✅ Proper cleanup
const test = base.extend({
  properFixture: async ({}, use) => {
    const resources = [];
    const intervals = [];
    const listeners = [];

    try {
      // Track resources
      const bigArray = new Array(1000000).fill('data');
      resources.push(bigArray);

      // Track intervals
      const interval = setInterval(() => {}, 1000);
      intervals.push(interval);

      // Track listeners
      const handler = () => {};
      process.on('message', handler);
      listeners.push({ event: 'message', handler });

      await use(bigArray);
    } finally {
      // Clean everything
      resources.length = 0;
      intervals.forEach(i => clearInterval(i));
      listeners.forEach(({ event, handler }) => {
        process.removeListener(event, handler);
      });
    }
  }
});

// Monitor memory usage
test.beforeEach(async ({}, testInfo) => {
  const memBefore = process.memoryUsage();

  testInfo.attachments.push({
    name: 'memory-before',
    body: JSON.stringify(memBefore),
    contentType: 'application/json'
  });
});

test.afterEach(async ({}, testInfo) => {
  global.gc && global.gc(); // Force GC if available

  const memAfter = process.memoryUsage();
  testInfo.attachments.push({
    name: 'memory-after',
    body: JSON.stringify(memAfter),
    contentType: 'application/json'  
  });
});

Async/Await Problems

// ❌ Missing await
const test = base.extend({
  asyncFixture: async ({}, use) => {
    const data = fetchData(); // Missing await!
    use(data); // Missing await!
  }
});

// ✅ Proper async handling
const test = base.extend({
  asyncFixture: async ({}, use) => {
    const data = await fetchData();
    await use(data);
  }
});

// ❌ Race conditions
const test = base.extend({
  racyFixture: async ({}, use) => {
    let result;

    // Race condition!
    setTimeout(() => {
      result = 'value';
    }, 100);

    await use(result); // undefined!
  }
});

// ✅ Proper synchronization
const test = base.extend({
  syncFixture: async ({}, use) => {
    const result = await new Promise(resolve => {
      setTimeout(() => {
        resolve('value');
      }, 100);
    });

    await use(result);
  }
});

// ❌ Unhandled promise rejection
const test = base.extend({
  errorFixture: async ({}, use) => {
    const promises = [
      fetchData1(),
      fetchData2(),
      fetchData3()
    ];

    // If one fails, others continue!
    await use(promises);
  }
});

// ✅ Proper error handling
const test = base.extend({
  safeFixture: async ({}, use) => {
    try {
      const results = await Promise.all([
        fetchData1(),
        fetchData2(),
        fetchData3()
      ]);

      await use(results);
    } catch (error) {
      console.error('Fixture setup failed:', error);
      // Provide fallback or re-throw
      throw new Error(`Fixture setup failed: ${error.message}`);
    }
  }
});

Debugging Fixture Lifecycle

// Debug helper fixture
const test = base.extend({
  debugInfo: [async ({}, use, testInfo) => {
    console.log(`[${testInfo.title}] Starting test`);
    const startTime = Date.now();

    await use({
      testName: testInfo.title,
      startTime
    });

    const duration = Date.now() - startTime;
    console.log(`[${testInfo.title}] Test completed in ${duration}ms`);
  }, { auto: true }],

  trackedFixture: async ({ debugInfo }, use) => {
    console.log(`[${debugInfo.testName}] Setting up trackedFixture`);

    const resource = await createResource();

    await use(resource);

    console.log(`[${debugInfo.testName}] Tearing down trackedFixture`);
    await cleanupResource(resource);
  }
});

// Fixture với detailed logging
const test = base.extend({
  verboseDatabase: async ({}, use, testInfo) => {
    const log = (message: string) => {
      const timestamp = new Date().toISOString();
      console.log(`[${timestamp}] [${testInfo.title}] ${message}`);
    };

    log('Connecting to database...');
    const db = await connectDB();

    log('Running migrations...');
    await db.migrate();

    log('Database ready');
    await use(db);

    log('Cleaning up database...');
    await db.cleanup();

    log('Disconnecting...');
    await db.disconnect();
  }
});

// Visual fixture dependency tree
const test = base.extend({
  root: async ({}, use) => {
    console.log('├─ root');
    await use('root');
  },

  branch1: async ({ root }, use) => {
    console.log('│  ├─ branch1 (depends on root)');
    await use('branch1');
  },

  branch2: async ({ root }, use) => {
    console.log('│  ├─ branch2 (depends on root)');
    await use('branch2');
  },

  leaf: async ({ branch1, branch2 }, use) => {
    console.log('│  │  └─ leaf (depends on branch1, branch2)');
    await use('leaf');
  }
});

Kết luận

Lợi ích của việc sử dụng Fixture

  1. Code Reusability: Viết một lần, sử dụng nhiều nơi
  2. Maintainability: Thay đổi ở một nơi, áp dụng everywhere
  3. Test Isolation: Mỗi test có môi trường riêng
  4. Resource Management: Tự động cleanup, không memory leak
  5. Performance: Share expensive resources hiệu quả
  6. Type Safety: Full TypeScript support

Khi nào nên tạo Custom Fixture

Nên tạo fixture khi:

  • Setup/teardown lặp lại nhiều lần
  • Cần quản lý lifecycle phức tạp
  • Muốn share state giữa các test
  • Cần dependency injection
  • Setup tốn resources (DB, API)

Không nên tạo fixture khi:

  • Logic quá đơn giản
  • Chỉ dùng một lần
  • Không có cleanup requirements
  • Simple constants/config

Trả lời