Test AutomationIntermediate

Test Automation Best Practices

Learn the essential best practices for building maintainable and scalable test automation frameworks

5 min read
...
automationbest-practicesframeworktesting

Introduction

Test automation is crucial for maintaining software quality while moving fast. However, poorly designed automation can become a maintenance nightmare. This guide covers essential best practices for building robust test automation.

Core Principles

1. The Test Pyramid

Follow the test pyramid approach:

  • Unit Tests (70%): Fast, isolated, testing individual components
  • Integration Tests (20%): Testing component interactions
  • E2E Tests (10%): Full user journey testing
// Good: Unit test
describe('UserValidator', () => {
  it('should validate email format', () => {
    const validator = new UserValidator();
    expect(validator.isValidEmail('test@example.com')).toBe(true);
    expect(validator.isValidEmail('invalid-email')).toBe(false);
  });
});
 
// Good: Integration test
describe('User Registration', () => {
  it('should create user and send welcome email', async () => {
    const user = await createUser({ email: 'test@example.com' });
    const emailSent = await checkEmailQueue('test@example.com');
    expect(emailSent).toBe(true);
  });
});

2. Keep Tests Independent

Each test should be able to run independently without relying on others.

// Bad: Tests depend on execution order
test('create user', async () => {
  userId = await createUser(userData);
});
 
test('update user', async () => {
  await updateUser(userId, newData); // Depends on previous test
});
 
// Good: Each test is independent
test('create user', async () => {
  const userId = await createUser(userData);
  expect(userId).toBeDefined();
});
 
test('update user', async () => {
  const userId = await createUser(userData); // Set up own data
  const updated = await updateUser(userId, newData);
  expect(updated).toBe(true);
});

3. Use Page Object Model

Separate test logic from page implementation details.

// page-objects/LoginPage.js
class LoginPage {
  constructor(page) {
    this.page = page;
    this.emailInput = '#email';
    this.passwordInput = '#password';
    this.submitButton = '[type="submit"]';
  }
 
  async login(email, password) {
    await this.page.fill(this.emailInput, email);
    await this.page.fill(this.passwordInput, password);
    await this.page.click(this.submitButton);
  }
 
  async getErrorMessage() {
    return await this.page.textContent('.error-message');
  }
}
 
// tests/login.spec.js
test('login with invalid credentials', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.login('invalid@example.com', 'wrongpassword');
  const error = await loginPage.getErrorMessage();
  expect(error).toContain('Invalid credentials');
});

Test Data Management

1. Use Test Data Builders

Create reusable test data builders:

class UserBuilder {
  constructor() {
    this.user = {
      email: `test${Date.now()}@example.com`,
      name: 'Test User',
      role: 'user',
    };
  }
 
  withEmail(email) {
    this.user.email = email;
    return this;
  }
 
  withRole(role) {
    this.user.role = role;
    return this;
  }
 
  build() {
    return this.user;
  }
}
 
// Usage
const adminUser = new UserBuilder()
  .withEmail('admin@example.com')
  .withRole('admin')
  .build();

2. Clean Up Test Data

Always clean up after tests:

describe('User Management', () => {
  let testUser;
 
  beforeEach(async () => {
    testUser = await createTestUser();
  });
 
  afterEach(async () => {
    await deleteTestUser(testUser.id);
  });
 
  test('should update user profile', async () => {
    // Test code
  });
});

Waiting Strategies

1. Avoid Hard Waits

// Bad: Hard wait
await page.waitForTimeout(3000);
 
// Good: Wait for specific condition
await page.waitForSelector('.data-loaded');
await page.waitForLoadState('networkidle');

2. Use Smart Waiting

// Wait for element to be visible and enabled
await page.waitForSelector('button.submit', {
  state: 'visible',
  timeout: 5000
});
 
// Wait for API response
await page.waitForResponse(
  response => response.url().includes('/api/users') && response.status() === 200
);

Assertions

1. Use Descriptive Assertions

// Bad: Generic assertion
expect(result).toBe(true);
 
// Good: Descriptive assertion
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.data).toMatchObject({
  id: expect.any(String),
  email: 'test@example.com'
});

2. Test Both Positive and Negative Cases

describe('Email Validation', () => {
  test('accepts valid email formats', () => {
    expect(validateEmail('user@example.com')).toBe(true);
    expect(validateEmail('user.name@example.co.uk')).toBe(true);
  });
 
  test('rejects invalid email formats', () => {
    expect(validateEmail('invalid')).toBe(false);
    expect(validateEmail('@example.com')).toBe(false);
    expect(validateEmail('user@')).toBe(false);
  });
});

CI/CD Integration

1. Parallel Execution

Run tests in parallel to speed up execution:

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
 
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v3
      - name: Run tests
        run: npm test -- --shard=${{ matrix.shard }}/4

2. Fail Fast

Configure tests to fail fast on critical issues:

// jest.config.js
module.exports = {
  bail: 1, // Stop after first test failure
  maxWorkers: '50%', // Use 50% of CPU cores
  testTimeout: 10000, // 10 second timeout
};

Reporting and Debugging

1. Capture Screenshots on Failure

test('complex user flow', async ({ page }) => {
  try {
    // Test steps
  } catch (error) {
    await page.screenshot({ path: `failure-${Date.now()}.png` });
    throw error;
  }
});

2. Use Meaningful Test Names

// Bad
test('test1', () => {});
 
// Good
test('should display error message when login fails with invalid credentials', () => {});

Common Pitfalls to Avoid

  1. Over-reliance on UI tests: Keep most tests at unit/integration level
  2. Flaky tests: Use proper waits and avoid race conditions
  3. Tight coupling: Don't tie tests to implementation details
  4. Poor test organization: Group related tests and use descriptive names
  5. Ignoring test failures: Fix or remove failing tests immediately
  6. Not reviewing test code: Test code needs reviews too

Conclusion

Effective test automation requires:

  • Following the test pyramid
  • Writing independent, maintainable tests
  • Using proper patterns like Page Object Model
  • Smart waiting and assertion strategies
  • Good CI/CD integration
  • Continuous improvement

Remember: The goal is to increase confidence in your codebase, not just to increase test count.

Further Reading

Comments (0)

Loading comments...