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 filesharedDatabase
: 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
- Code Reusability: Viết một lần, sử dụng nhiều nơi
- Maintainability: Thay đổi ở một nơi, áp dụng everywhere
- Test Isolation: Mỗi test có môi trường riêng
- Resource Management: Tự động cleanup, không memory leak
- Performance: Share expensive resources hiệu quả
- 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