Creating Modular and Reusable Test Code
Creating Modular and Reusable Test Code
As automated test suites grow, maintaining them becomes increasingly difficult unless the code is modular and reusable. Well-structured test automation improves:
Readability
Scalability
Maintenance
Reliability
Team collaboration
Using modern frameworks like Playwright, you can design clean, reusable architectures that reduce duplication and simplify updates.
1. Why Modular Test Code Matters
Poorly structured tests often contain:
Repeated selectors
Duplicate login logic
Hardcoded data
Long procedural scripts
Problems caused:
Fragile tests
High maintenance cost
Difficult debugging
Slow feature updates
Good modular design solves these issues by separating:
Test logic
UI interactions
Data
Configuration
Utilities
2. Core Principles of Reusable Test Design
Single Responsibility Principle
Each module should do one thing well.
Examples:
Login page handles authentication
Cart page handles cart operations
API helper manages requests
Avoid giant utility files doing everything.
DRY (Don’t Repeat Yourself)
If logic appears multiple times, extract it.
Bad:
await page.fill('#username', 'admin');
await page.fill('#password', 'password');
await page.click('#login');
Repeated across many tests.
Better:
await loginPage.login('admin', 'password');
3. Using the Page Object Model (POM)
The Page Object Model is one of the most effective modular patterns.
Example Structure
pages/
LoginPage.ts
DashboardPage.ts
tests/
utils/
fixtures/
Example Login Page
import { Page } from '@playwright/test';
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login');
}
async login(username: string, password: string) {
await this.page.fill('#username', username);
await this.page.fill('#password', password);
await this.page.click('button[type=submit]');
}
}
Example Test
import { test } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test('user can login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('admin', 'password');
});
Benefits:
Centralized selectors
Easier UI updates
Cleaner tests
4. Creating Reusable Components
Modern applications often reuse UI elements:
Navigation bars
Modals
Tables
Toast notifications
Extract shared components.
Example:
export class Navbar {
constructor(private page: Page) {}
async openProfile() {
await this.page.click('.profile-menu');
}
async logout() {
await this.page.click('.logout-btn');
}
}
This avoids duplicating navigation logic.
5. Using Fixtures for Shared Setup
Playwright fixtures help inject reusable dependencies.
Example:
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
export const test = base.extend({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
}
});
Usage:
test('login test', async ({ loginPage }) => {
await loginPage.goto();
});
Benefits:
Cleaner setup
Dependency injection
Shared initialization
6. Organizing Test Data
Hardcoded values reduce flexibility.
Bad:
await loginPage.login('admin', 'admin123');
Better:
const user = testUsers.admin;
await loginPage.login(user.username, user.password);
Example data file:
export const testUsers = {
admin: {
username: 'admin',
password: 'secret'
}
};
7. Utility Functions
Utilities handle generic reusable logic.
Examples:
Date formatting
Random data generation
File handling
API requests
Example:
export function generateEmail() {
return `test${Date.now()}@example.com`;
}
Keep utilities:
Small
Focused
Independent
8. Custom Commands and Helpers
Extract repeated workflows.
Bad:
await page.click('#cart');
await page.click('.checkout');
await page.fill('#address', '123 Main St');
Better:
await checkoutHelper.completeCheckout();
Example helper:
export class CheckoutHelper {
constructor(private page: Page) {}
async completeCheckout() {
await this.page.click('#cart');
await this.page.click('.checkout');
await this.page.fill('#address', '123 Main St');
}
}
9. Separating Test Logic from Assertions
Tests should focus on behavior.
Bad:
await page.click('#submit');
await page.waitForTimeout(3000);
await expect(page.locator('.success')).toContainText('Done');
Better:
await formPage.submit();
await formPage.expectSuccessMessage();
Page object:
async expectSuccessMessage() {
await expect(this.page.locator('.success'))
.toContainText('Done');
}
10. Reusable API Layers
Playwright supports API testing too.
Example API client:
export class UserApi {
constructor(private request: APIRequestContext) {}
async createUser(data) {
return this.request.post('/api/users', {
data
});
}
}
Benefits:
Shared API logic
Easier backend setup
Cleaner integration tests
11. Environment Abstraction
Avoid environment-specific logic inside tests.
Bad:
await page.goto('https://staging.example.com');
Better:
await page.goto(process.env.BASE_URL!);
This enables:
Multi-environment execution
CI/CD flexibility
Easier scaling
12. Folder Structure for Scalability
Recommended structure:
project/
├── tests/
├── pages/
├── components/
├── fixtures/
├── utils/
├── api/
├── data/
├── config/
└── playwright.config.ts
Large teams may further organize by:
Features
Domains
Services
13. Avoiding Common Anti-Patterns
Giant Base Classes
Avoid one huge parent class with everything.
Prefer:
Composition
Small reusable modules
Over-Abstraction
Do not abstract simple logic unnecessarily.
Bad:
await clickButton(button.submit.primary.large);
Sometimes simpler is clearer.
Hidden Assertions
Avoid assertions buried deep inside helpers unless intentional.
Tests should remain understandable.
14. Improving Maintainability
Good reusable architecture makes updates easier.
Example:
If a selector changes:
Update one page object.
Hundreds of tests continue working.
Without modularity:
Every test breaks individually.
15. Advanced Reusability Patterns
Factory Pattern
Generate reusable test objects.
Example:
export function createTestUser(role = 'user') {
return {
email: `test${Date.now()}@mail.com`,
role
};
}
Builder Pattern
Useful for complex data setup.
Example:
const order = new OrderBuilder()
.withItem('Laptop')
.withDiscount(10)
.build();
16. Reusable Authentication
Store authentication state once.
Example:
use: {
storageState: 'auth.json'
}
This reduces:
Login duplication
Execution time
Flaky authentication flows
17. Documentation and Naming
Readable naming is critical.
Good:
completeCheckout()
Bad:
doStuff()
Good naming reduces onboarding time significantly.
18. Best Practices Summary
Keep Tests Short
Tests should express behavior clearly.
Centralize Selectors
Use page objects or components.
Reuse Setup Logic
Fixtures and helpers improve consistency.
Separate Concerns
UI, API, data, and utilities should remain independent.
Prefer Composition
Smaller reusable modules scale better.
Avoid Overengineering
Simplicity improves long-term maintainability.
Final Thoughts
The goal of modular and reusable test code is not just cleaner syntax — it is sustainable automation.
Well-designed test architecture enables:
Faster development
Easier debugging
Better scalability
More reliable CI/CD pipelines
The best automation frameworks evolve like production software:
structured, maintainable, and thoughtfully engineered.
Learn Playwright Course in Hyderabad
AT Quality Thought Institute in Hyderabad
Comments
Post a Comment